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.parse can 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