How to Freeze 10,000 Users With One Line of Code in Node.js
How to Freeze 10,000 Users With One Line of Code in Node.js
Most engineers know that while (true) {} will block Node.js.
The more dangerous failures are subtler: perfectly valid production code that is briefly CPU-heavy can stall the event loop and make your service appear to “hang”.
No crash. No error. Just requests that stop completing.
This is the operational cost of Node’s event loop model: JavaScript execution is cooperative. When you block it, you block everyone sharing that process.
The Problem
Node.js can handle a lot of concurrent connections, but it executes JavaScript on a single main thread.
That thread is responsible for:
- handling incoming requests
- running your route handlers and middleware
- executing callbacks
- resolving promises/microtasks
If the main thread is busy doing CPU work, the process can’t make progress on other requests.
Practically: every request routed to that Node.js process will wait until the blocking work finishes.
In a scaled setup (multiple Node processes/instances behind a load balancer), the blast radius is “all users landing on the stalled instance”—still bad, just not necessarily the entire fleet.
Why It Happens
At the heart of Node.js is the event loop.
A useful mental model is a single conveyor belt:
- pick a task
- execute it
- move to the next
The key rule is run-to-completion:
Nothing else runs until the current JavaScript task finishes.
There’s no preemption for JavaScript execution. If your request handler burns CPU for 2 seconds, other work (including processing completed I/O callbacks) can’t be scheduled on the main thread for those 2 seconds.
Internal Architecture
Here’s what’s happening under the hood at a high level:
flowchart LR
Client1 --> EventLoop
Client2 --> EventLoop
Client3 --> EventLoop
EventLoop --> JSExecution
JSExecution -->|Blocking Code| Stall
Stall --> EventLoop
Even though Node.js uses efficient OS I/O primitives (epoll/kqueue/IOCP via libuv) and has a threadpool for some operations, your JavaScript still runs on one thread.
That single thread is the bottleneck when CPU work shows up in the request path.
Example Scenario
Consider something that looks completely reasonable:
app.post('/process', (req, res) => {
const data = JSON.parse(req.body.largePayload);
res.send("done");
});
Now add real-world conditions:
- Payload size: 20MB
- Concurrent users: 10,000
JSON.parse is synchronous CPU work on the main thread. While one request is parsing, other requests aren’t executing JavaScript.
Step-by-step
sequenceDiagram
participant User1
participant User2
participant EventLoop
User1->>EventLoop: Request (JSON.parse 20MB)
Note right of EventLoop: Blocking CPU work
User2->>EventLoop: Request arrives
Note right of EventLoop: Waiting...
EventLoop-->>User1: Response
EventLoop->>User2: Finally starts processing
While parsing one request:
- other requests queue up
- tail latency spikes (p95/p99)
- throughput collapses for the duration of the stall
The Real Killers (Gotchas)
These are common sources of “one-line” event loop stalls.
1) JSON.parse — the silent blocker
- pure CPU work
- runs on the main thread
- scales with payload size
A sufficiently large payload can create an event loop stall that looks like an outage.
2) Synchronous crypto
const hash = crypto.pbkdf2Sync(password, salt, 100000, 64, 'sha512');
- CPU intensive
- blocks the main thread
Under load, a handful of these requests can dominate the process and starve everything else.
3) Regex backtracking (ReDoS)
const regex = /(a+)+$/;
regex.test("aaaaaaaaaaaaaaaaaaaaaaaa!");
- catastrophic backtracking
- can take exponential time
This is especially dangerous because the triggering input can be small and appear innocuous.
4) Accidental O(n²) code in the request path
for (let i = 0; i < n; i++) {
for (let j = 0; j < n; j++) {
// hidden disaster
}
}
It works in dev.
In production, with real n, it becomes a latency multiplier.
Diagram: What “blocking” actually means
gantt
title Event Loop Stall Timeline
dateFormat X
axisFormat %L
section Event Loop
JSON.parse (User1) :a1, 0, 5
All other requests waiting :crit, 0, 5
User2 execution starts :a2, 5, 2
During the blocking window:
- the process makes no progress on other requests
- queued requests accumulate, increasing tail latency
Key Takeaways
- Node.js is single-threaded for JavaScript execution.
- Blocking doesn’t look like a crash; it looks like a “quiet” outage.
- CPU-heavy work in the request path becomes a system-wide bottleneck.
- “Normal” code like
JSON.parsecan be enough to stall the event loop at scale. - Synchronous APIs (for example, in
crypto) are risky under concurrency.
How Senior Engineers Avoid This
The mitigations are straightforward, but they have to be intentional:
- offload CPU work to:
- worker threads
- separate services (when isolation matters)
- use async APIs where available (or move CPU work off the main thread)
- enforce payload limits early (API gateway / load balancer / middleware)
- avoid parsing or transforming large blobs in the request path
- treat event loop delay as a first-class production metric
Example:
const { Worker } = require('worker_threads');
Final Thoughts
The hardest part of Node.js performance is that failures can be invisible at first:
- the process is still “up”
- memory can be stable
- CPU might not look alarming at the host level (especially in multi-core systems)
Yet a single CPU-heavy line in the wrong place can own the event loop and stall every request handled by that process.
Senior engineers design assuming this will happen, and build systems and guardrails that keep the blast radius small.
Next in the Series
Continue reading: Threadpool Exhaustion in Node.js — The Silent Killer Behind Slow APIs