#Strategies for CPU-intensive work
Node is excellent at waiting: one thread can keep thousands of database queries, HTTP calls and file reads in flight, because the waiting happens in the OS and libuv, not in JavaScript. It is bad at computing on that same thread. A report that takes 8 seconds of JavaScript on the main thread delays every other request in that process for 8 seconds — including a trivial /health check, which makes your orchestrator think the process is dead.
The mental model: the event loop is a single cashier. Customers who need to "wait for the kitchen" (I/O) step aside and the cashier serves the next person. A customer who wants the cashier to personally count 10 million coins (CPU work) holds the whole queue. The fix is never to make the cashier "async"; it's to send the coin-counting somewhere else.
The first trap is believing async or a Promise moves work off the thread. It doesn't:
function fib(n) { return n < 2 ? n : fib(n - 1) + fib(n - 2); }
async function fibAsync(n) { return fib(n); } // "async" changes the return type, not the thread
const t0 = Date.now();
setTimeout(() => console.log(`timer due at 10 ms fired at ${Date.now() - t0} ms`), 10);
fibAsync(38).then((v) => console.log(`fib(38) = ${v} resolved at ${Date.now() - t0} ms`));
console.log(`fibAsync() returned at ${Date.now() - t0} ms`);
// fibAsync() returned at 197 ms
// fib(38) = 39088169 resolved at 198 ms
// timer due at 10 ms fired at 198 ms- A 10 ms timer is scheduled first. If the loop were free, it would fire at about 10 ms.
fibAsync(38)is called. Anasyncfunction runs its body synchronously until its firstawait— and there is noawait, so the whole recursivefib(38)(about 39 million calls) runs right now, on the main thread.fibAsync()only returns (an already-resolved promise) after ~197 ms. Even the next line,console.log, couldn't run before then.- The
.thencallback is a microtask, so it runs as soon as the script finishes (198 ms), before the event loop gets to timers. The timer finally fires at 198 ms instead of 10 ms — 188 ms late, exactly how long the computation held the thread. - Wrapping work in
new Promise(...)has the same problem: the executor runs synchronously. Async syntax decides when you get the result, not which thread computes it.
From my notes, the answer to "How would you handle CPU-intensive tasks in a Node.js application?" lists five techniques: worker threads, offloading to an external service, the cluster module, child processes, and streaming large datasets. They're all real, but they solve different problems:
| The work is… | Use | Why |
|---|---|---|
| Waiting on I/O (DB, HTTP, files) | normal async APIs | already concurrent; nothing to offload |
| Heavy JavaScript (parsing, image math, reports) | a bounded worker pool | runs JS on other cores, main thread stays responsive |
| An external program (FFmpeg, ImageMagick, Python) | a child process | it isn't JavaScript; it needs its own process anyway |
| Long or must-survive-a-restart jobs | a queue + separate worker service | durable, retryable, scaled independently |
| Too much total traffic for one process | more processes: cluster or more pods | scales the whole server, not one request |
| Huge data (a 20 GB CSV) | streams | bounds memory — but per-chunk CPU still blocks |
My notes put it well for cluster: it "increases throughput but doesn't solve the problem of individual requests being CPU-bound." One heavy request still blocks the process it lands on; cluster just means there are other processes to take the rest of the traffic (see Cluster vs worker threads).
Two more nuances:
- Some native APIs already offload.
crypto.pbkdf2,crypto.scrypt,zlib.gzipand asyncfsrun on libuv's thread pool; wrapping them in a worker gains nothing. Their*Syncversions do block (see Avoiding synchronous work on hot paths). - Streams bound memory, not CPU. Streaming a 20 GB CSV keeps memory flat, but if each chunk needs heavy parsing, that parsing still runs on the main thread. Combine streaming with a worker pool when you need both.
For work that takes minutes, or must survive a deploy, don't hold an HTTP request open at all. Accept the job, persist it, and let a separate service do it:
POST /reports -> validate, store job, enqueue -> 202 Accepted { jobId }
|
worker service picks it up (retries on failure)
|
writes result + status to DB / object storage
|
GET /jobs/:id -> { status: "done", url: "…" } (or a webhook / websocket notification)A video-upload pipeline combines every option: stream the upload straight to object storage (bounded memory), enqueue a processing job (request latency independent of processing time), run an independently scaled worker service that spawns FFmpeg as a child process (specialised native CPU work), then store the thumbnails and update the job status.