Skip to content

Blocking the event loop: CPU work, sync APIs, ReDoS, and measuring it

While one callback computes, the whole process waits: nothing is refused, nothing runs, and everything arrives late.

Predict first

Before you read: predict

ExamHall's proctor API process answers GET /heartbeat. Its POST /similarity handler runs await checkSimilarity(essays). checkSimilarity is declared async, and its body is a 3-second CPU loop with no await inside. While one check runs, six heartbeats arrive, each on its own connection.

What happens to them?

Pick one

The 30-second version

Node runs every callback on one thread, one at a time. Synchronous work, such as a CPU loop, a Sync API, a huge JSON.parse or a backtracking regex, keeps the current callback running, so no other request, timer or socket callback runs. Arrivals wait in the operating system and are served late. Marking the work async moves nothing.

While one callback computes, the whole process waits: nothing is refused, nothing runs, and everything arrives late.

How it works

Step 1 of 10

One thread. Every callback, whether a request handler, a timer or a socket read, runs on the main thread, one at a time.
In the kitchen
  • one controller, one exchange at a time → one thread (step 1)

Analogy: An approach controller on one radio frequency

Every aircraft near the airport talks to the same controller on the same frequency.

On the frequency In Node
one controller, one exchange at a time one thread (step 1)
the controller reads a long weather bulletin aloud, and no pilot can get a word in a block (step 2)
aircraft keep circling in the hold; none is lost, each is cleared after the bulletin, and some divert first late, not lost (step 3)
scheduled position reports go unanswered until the bulletin ends timers wait (step 4)
"I'll read it in the background," then reading it on the same frequency async (step 5)
reading only the changed paragraph instead of the whole bulletin shortening the work (step 9)

In real terms: a process is only as responsive as its longest synchronous stretch; every other client waits that long.

Breaks down when: a controller can cut a bulletin short when an emergency calls. Node cannot interrupt a running callback: no timeout or signal handler runs until it returns (steps 2, 4).

Contrasting case: a tower with one controller per aircraft. A thread-per-request server ties up one thread per slow request, not every client.

Examples, run for real

4.1 ExamHall: 3,000 candidates "offline"

EXH-1320: the proctor API ran an essay-similarity check synchronously. A sweep timer marks a candidate offline when no heartbeat has arrived for 5 s. After a long check, every candidate was flagged. MR !1520 moves the check off the main thread; how is node-scaling-workers-cluster's lane, so it is not run here. Scaled down: 5 candidates, a heartbeat every 100 ms, a 500 ms deadline, a 1.5 s check.

4.1 as shipped, check on the main thread   heartbeats read during the check: 0 of 5 candidates

The heartbeats kept arriving during the check (step 3), but none could be read (step 2), so every candidate's last-seen time went stale. What happened next was the sweep (step 4). Whether the overdue sweep or the queued heartbeats run first after the check is not documented, and a reviewer's run under load saw 4 of 5 flagged instead of 5, so the pack grades only the count above.

4.2 ReDoS: the Node docs' own example

/(\/.+)+$/ checks a path. Each input is a near-miss: /a repeated, then a newline. Each match ran in its own process under a 2 s watchdog.

4.2 /(\/.+)+$/ on "/a" x 12 + newline      finished before a 2 s watchdog: true
4.2 /(\/.+)+$/ on "/a" x 40 + newline      finished before a 2 s watchdog: false

Twelve repetitions return quickly. Forty never return in practice: every extra segment multiplies the ways the nested + can split the string (step 8). Length alone is not the problem; the pattern's ambiguity is.

Picture it

a woman in a blue suit leans into an oversized wooden crate, tilting it onto a pale green reception counter. On the counter sit a brass service bell, a small round dish and a stack of green papers. Three people wait in a line on the other side of the counter: a man in a green coat, a woman in a teal dress and a grey-haired man in a brown coat. Nobody rings the bell. There is no text, and the right third of the canvas is empty.
An illustration to help it stick. The mechanism is in the sections above.

Try it

Answer first, then check. Nothing is saved.

Question 1 of 4

The catalog API validates its search box with new RegExp('^(\\w+\\s?)+$').test(q) before querying. One request whose query is 40 letters followed by ! stops every endpoint on the process from answering.

Which one change keeps the other endpoints answering while still accepting red wireless headphones and rejecting red!?

Pick one

Question 2 of 4

A payment provider posts settlement reports to a webhook, which reads the whole body and calls JSON.parse(body). Normal reports are a few KB, but a provider bug starts sending 20 MB reports, and while each one is handled the service's /health checks wait behind it.

Which one change keeps /health from waiting behind a report, while 1 KB reports are still accepted with 200?

Pick one

Question 3 of 4

A box office gives each fan 2 s to confirm a held seat: POST /hold stores heldAt = Date.now(), and POST /confirm accepts if Date.now() - heldAt <= 2000. When an organiser edits a venue's layout, the API calls rebuildSeatMap(), a synchronous sort of every seat that runs for about 3 s. After each edit, fans who held a seat just before it and confirmed within a second are told their hold expired.

Which is the bug?

Pick one

Question 4 of 4

A stadium's ticket-scanning gateway sends a keep-alive to the venue server every second with setInterval, and the venue marks a gateway offline after 3 s of silence. Each night the gateway packs its scan log by running the vendor's CLI with child_process.execFileSync, which takes several seconds; the venue marks it offline, and door scans stall until the packing ends.

Which change keeps both the keep-alives and the door scans going during the packing?

Pick one

Go deeper

Pro tips
  1. Find the blocker with a CPU profile: node --cpu-prof --cpu-prof-dir=prof app.mjs writes a .cpuprofile when the process exits; open it in DevTools or VS Code. When: an endpoint or script is slow and you suspect synchronous work (steps 2, 7). Verify: the profile was written, and its hottest user function, with more than half the user samples, was the planted scoreEssays. Opening the file in an editor: not run.
  2. When: a pattern has a quantifier inside a repeated group (step 8). Verify: the 40-segment near-miss never finished inside 2 s; the 12-segment one always did.
Source
  • Node.js, Don't Block the Event Loop (or the Worker Pool): https://nodejs.org/en/learn/asynchronous-work/dont-block-the-event-loop · fetched 2026-09-28. "While a thread is blocked working on behalf of one client, it cannot handle requests from any other clients" (steps 1–2). "This of course also applies to your await's, your Promise.then's, and so on" (step 5). The synchronous APIs not to use in a server include crypto.pbkdf2Sync, zlib.inflateSync, the synchronous file system APIs and child_process.execSync (step 6). JSON.parse and JSON.stringify "are O(n) in the length of the input, for large n they can take surprisingly long" (step 7). A vulnerable regex is "one on which your regular expression engine might take exponential time"; "Avoid nested quantifiers like (a+)*"; the example /(\/.+)+$/. Valid as of 2026-09-28.
  • Node.js v24 API, Child process: https://nodejs.org/docs/latest-v24.x/api/child_process.html · fetched 2026-09-28. execFileSync and the other sync methods "are synchronous and will block the Node.js event loop, pausing execution of any additional code until the spawned process exits", and execFileSync "will not return until the child process has fully closed"; execFile takes a callback "Called with the output when process terminates"; for the sync form, timeout is "the maximum amount of time the process is allowed to run". Valid as of 2026-09-28.
  • Node.js v24 API, HTTP, server.requestTimeout: https://nodejs.org/docs/latest-v24.x/api/http.html · fetched 2026-09-28. It "Sets the timeout value in milliseconds for receiving the entire request from the client" (default 300000); on expiry the server answers 408 "without forwarding the request to the request listener". It does not stop a listener that is already running. Valid as of 2026-09-28.

Checked against the tools and docs as of 2026-09-28.