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?
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
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

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!?
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?
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?
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?
Go deeper
Pro tips
- Find the blocker with a CPU profile:
node --cpu-prof --cpu-prof-dir=prof app.mjswrites a.cpuprofilewhen 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 plantedscoreEssays. Opening the file in an editor: not run. - 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, yourPromise.then's, and so on" (step 5). The synchronous APIs not to use in a server includecrypto.pbkdf2Sync,zlib.inflateSync, the synchronous file system APIs andchild_process.execSync(step 6).JSON.parseandJSON.stringify"areO(n)in the length of the input, for largenthey 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.
execFileSyncand 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", andexecFileSync"will not return until the child process has fully closed";execFiletakes a callback "Called with the output when process terminates"; for the sync form,timeoutis "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.
Related lessons
Checked against the tools and docs as of 2026-09-28.