Dev.to · 12 min read

Escaping the Event Loop — A Deep Dive into worker_threads (Part 3/3)

Escaping the Event Loop — A Deep Dive into worker_threads (Part 3/3)

In part 1, we built the core stack/microtask/macrotask model. In part 2, we saw how the browser and Node implement that model differently — rendering interleaved with tasks in the browser, libuv's phase-based loop in Node. Both of those posts share an unspoken assumption: your callbacks are fast. A few milliseconds, do their thing, return, let the loop move on. But what happens when that assumption breaks? What happens when you genuinely need to do slow, CPU-bound work — and there's no I/O to wait on, no callback to defer, just raw computation that has to happen? That's what this post is about. No amount of clever queue ordering saves you here. You need actual parallelism, and in Node, that means worker_threads. The problem worker_threads solves Everything from parts 1 and 2 works because the actual JS execution — the code inside your callbacks — is assumed to be short. The event loop is brilliant at making single-threaded JS feel concurrent when the bottleneck is I/O: waiting on a database, a file read, a network response. While you wait, the thread is free to do other things. But CPU-bound work is a different beast entirely. Consider this: function fibonacci(n) { if (n < 2) return n; return fibonacci(n - 1) + fibonacci(n - 2); } const server = require('http').createServer((req, res) => { if (req.url === '/fib') { const result = fibonacci(40); // this takes a few seconds res.end(`Result: ${result}`); return; } res.end('OK'); }); server.listen(3000); Hit /fib, and for however long that synchronous computation runs, the entire server is frozen. Not just that request — every request. No other client's /OK request gets served, no timers fire, no I/O callbacks run, because the single JS thread is stuck inside fibonacci(40) and the event loop can't do anything until the call stack unwinds. This is the single most common misunderstanding about Node's concurrency model: async I/O doesn't make your CPU-bound code non-blocking. fs.readFile doesn't block because the actual disk read happens off the JS thread, in libuv's thread pool, and only the (fast) callback runs on the JS thread. But a tight synchronous loop, a big JSON parse, a hash computation, image processing — there's no I/O to hand off. It's just your code, doing math, on the one thread you've got. You have three tools in Node for genuinely parallel work, and knowing which one to reach for actually matters. worker_threads vs child_process vs cluster These get confused constantly because they all sound like "run more stuff at once." They solve different problems. child_process spawns an entirely separate OS process, potentially running a different program altogether (a shell command, a Python script, another Node script). Full isolation — separate memory space, separate V8 instance. Good for running external programs or when you want maximum isolation (a crash in the child can't take down the parent). Heavier to spin up, and communication happens via serialized IPC (stdin/stdout/message passing) — no shared memory. cluster is built on top of child_process, specifically for scaling Node servers across multiple CPU cores. It forks multiple copies of your entire Node process, and the OS (or a Node-managed load balancer) distributes incoming connections across them. Great for "I have a web server and want to use all my CPU cores" — but each worker is a full separate process with its own memory, event loop, everything. It's about scaling throughput of independent requests, not about parallelizing a single computation. worker_threads spawns actual OS-level threads within the same process — lighter weight than a full process, and critically, capable of sharing memory via SharedArrayBuffer. Each worker gets its own V8 instance and its own event loop, so it's still not shared-everything like threads in languages such as Java or C++, but message passing is fast and structured data doesn't need full IPC serialization to a separate process. This is the right tool when you have one chunk of CPU-heavy work you want to offload without blocking the main thread — image processing, cryptographic hashing, parsing a huge file, running a compute-heavy algorithm. Quick way to decide: scaling a whole server across cores → cluster. Running an external program or needing hard process isolation → child_process. Offloading one CPU-bound task from inside your existing app → worker_threads. The rest of this post is about that last one. Anatomy of a worker A worker is created from the main thread by pointing at a separate JS file: // main.js const { Worker } = require('worker_threads'); const worker = new Worker('./worker.js'); worker.on('message', (result) => { console.log('Got result:', result); }); worker.on('error', (err) => { console.error('Worker error:', err); }); worker.on('exit', (code) => { if (code !== 0) console.error(`Worker stopped with exit code ${code}`); }); // worker.js const { parentPort } = require('worker_threads'); // do some CPU-heavy work here const result = heavyComputation(); parentPort.postMessage(result); A few key pieces: parentPort — the worker's side of the communication channel back to whoever created it. .postMessage() sends data out; listening on parentPort.on('message', ...) receives data in. workerData — lets you pass initial data into the worker at creation time, instead of messaging it in after startup: // main.js const worker = new Worker('./worker.js', { workerData: { n: 40 } }); // worker.js const { workerData, parentPort } = require('worker_threads'); function fibonacci(n) { if (n < 2) return n; return fibonacci(n - 1) + fibonacci(n - 2); } parentPort.postMessage(fibonacci(workerData.n)); Message passing between main thread and worker uses the structured clone algorithm — the same mechanism postMessage uses in browsers between windows/iframes. It can handle most JS values (objects, arrays, Maps, Sets, dates, even ArrayBuffers by transfer), but not everything — functions and certain non-serializable values can't cross the boundary. Each message is cloned by default, which has a real memory/CPU cost for large payloads — that's part of why SharedArrayBuffer exists, more on that below. Fixing the frozen server Let's rewrite the earlier example properly: // main.js const http = require('http'); const { Worker } = require('worker_threads'); function runFibWorker(n) { return new Promise((resolve, reject) => { const worker = new Worker('./fib-worker.js', { workerData: { n } }); worker.on('message', resolve); worker.on('error', reject); worker.on('exit', (code) => { if (code !== 0) reject(new Error(`Worker exited with code ${code}`)); }); }); } const server = http.createServer(async (req, res) => { if (req.url === '/fib') { const result = await runFibWorker(40); res.end(`Result: ${result}`); return; } res.end('OK'); }); server.listen(3000); // fib-worker.js const { workerData, parentPort } = require('worker_threads'); function fibonacci(n) { if (n < 2) return n; return fibonacci(n - 1) + fibonacci(n - 2); } parentPort.postMessage(fibonacci(workerData.n)); Now hitting /fib spins up a worker thread to do the computation, and the main thread's event loop stays completely free to keep serving other requests while it waits for the message event. This is the actual fix — not a clever async trick, but real parallel execution on a separate thread. Don't spin up a worker per request — use a pool The example above works, but spawning a brand-new Worker (and its own V8 instance) for every single request is expensive — worker startup isn't free. In any real system, you want a worker pool: a fixed set of long-lived workers that pick up jobs from a queue, so you pay the startup cost once and reuse threads across many tasks. Here's a reasonably complete pool implementation: // worker-pool.js const { Worker } = require('worker_threads'); const os = require('os'); class WorkerPool { constructor(workerScript, poolSize = os.cpus().length) { this.workerScript = workerScript; this.poolSize = poolSize; this.workers = []; this.freeWorkers = []; this.taskQueue = []; for (let i = 0; i < poolSize; i++) { this._addWorker(); } } _addWorker() { const worker = new Worker(this.workerScript); worker.on('message', (result) => { const { resolve } = worker.currentTask; worker.currentTask = null; resolve(result); this._takeNextTask(worker); }); worker.on('error', (err) => { const { reject } = worker.currentTask || {}; if (reject) reject(err); // Replace the crashed worker so the pool stays at full size this.workers = this.workers.filter((w) => w !== worker); this._addWorker(); }); this.workers.push(worker); this.freeWorkers.push(worker); } _takeNextTask(worker) { if (this.taskQueue.length === 0) { this.freeWorkers.push(worker); return; } const { data, resolve, reject } = this.taskQueue.shift(); worker.currentTask = { resolve, reject }; worker.postMessage(data); } runTask(data) { return new Promise((resolve, reject) => { const freeWorker = this.freeWorkers.pop(); if (freeWorker) { freeWorker.currentTask = { resolve, reject }; freeWorker.postMessage(data); } else { this.taskQueue.push({ data, resolve, reject }); } }); } async destroy() { await Promise.all(this.workers.map((w) => w.terminate())); } } module.exports = WorkerPool; // pool-worker.js const { parentPort } = require('worker_threads'); function fibonacci(n) { if (n < 2) return n; return fibonacci(n - 1) + fibonacci(n - 2); } parentPort.on('message', (n) => { parentPort.postMessage(fibonacci(n)); }); // main.js const http = require('http'); const WorkerPool = require('./worker-pool'); const pool = new WorkerPool('./pool-worker.js'); const server = http.createServer(async (req, res) => { if (req.url === '/fib') { const result = await pool.runTask(40); res.end(`Result: ${result}`); return; } res.end('OK'); }); server.listen(3000); Sizing the pool to os.cpus().length is a sensible default — one worker per logical core, so you're not oversubscribing the CPU with more compute-bound threads than you have cores to run them on. This pattern (or a maintained library like piscina, which does essentially this with more polish) is genuinely how you'd offload CPU work in production Node. SharedArrayBuffer and Atomics — when message passing isn't enough By default, data sent between the main thread and a worker is cloned. For most use cases that's fine, but for large datasets — a big typed array you want multiple workers reading and writing without copying gigabytes back and forth — cloning becomes the bottleneck itself. SharedArrayBuffer gives you a chunk of memory that's genuinely shared between threads — no cloning, both sides see the same bytes: // main.js const { Worker } = require('worker_threads'); const sharedBuffer = new SharedArrayBuffer(4); // 4 bytes = one Int32 const sharedArray = new Int32Array(sharedBuffer); const worker = new Worker('./increment-worker.js', { workerData: { sharedBuffer } }); worker.on('exit', () => { console.log('Final value:', sharedArray[0]); // both threads touched the same memory }); // increment-worker.js const { workerData, parentPort } = require('worker_threads'); const sharedArray = new Int32Array(workerData.sharedBuffer); Atomics.add(sharedArray, 0, 1); parentPort.close(); Note the use of Atomics.add rather than sharedArray[0]++. This matters: when multiple threads can touch the same memory simultaneously, plain read-modify-write operations are subject to race conditions — two threads can both read the same old value before either writes back, and one increment gets silently lost. Atomics provides operations (add, sub, compareExchange, load, store, and more) that are guaranteed to be indivisible at the hardware level, so concurrent access doesn't corrupt the value. Atomics.wait and Atomics.notify go further, letting threads actually block and signal each other — a real synchronization primitive, not just safe arithmetic. This is a genuinely deep topic on its own — safe concurrent memory access is one of the harder problems in any multithreaded system, not a JS-specific quirk — so treat this section as "know it exists and roughly what it's for," not a complete guide. Most real workloads are well served by message passing through a pool; reach for SharedArrayBuffer specifically when you've profiled and confirmed that cloning large data is the actual bottleneck. When to actually reach for worker_threads Worth being honest about the tradeoffs before you reach for this in your own code: Worker startup has real cost. Spinning up a V8 instance per thread isn't free — this is exactly why pooling matters for anything beyond a one-off script. Not everything benefits. If your bottleneck is I/O (database calls, network requests, file reads), workers don't help — that's exactly what the async event loop from parts 1 and 2 already handles well, without the overhead of a thread. Message passing has a cost too. For small payloads it's negligible; for large ones, either restructure to send less data or use SharedArrayBuffer. Debugging is harder. Errors in a worker don't naturally propagate the way synchronous exceptions do — you have to explicitly listen for 'error' events, and stack traces across threads are less convenient to work with. The honest heuristic: profile first. If your server is slow because it's waiting on a database, adding worker threads won't fix anything — that's an I/O problem, and the event loop already handles it well. Worker threads earn their complexity specifically when you've identified genuine CPU-bound work — a synchronous computation heavy enough to visibly block the main thread — and optimizing the algorithm itself isn't enough. Tying the series together Three parts, one throughline: Part 1 gave you the spec-level model — stack, microtask queue, macrotask queue, and the rule that microtasks always drain first. Part 2 showed that model isn't implemented identically everywhere — the browser interleaves it with rendering, Node runs it through libuv's ordered phases, and Node adds process.nextTick() as a queue-jumper the browser doesn't have. Part 3 showed the model's actual limit — a single thread, no matter how cleverly you schedule callbacks on it, can't parallelize genuine CPU-bound work. worker_threads is Node's way out of that constraint. Put together, this is really the full picture of "how does async JS work" — from the queue ordering that decides what runs next, to the runtime-specific machinery underneath it, to the point where you need real threads instead of clever scheduling. If you made it through all three, you're in a genuinely small group of JS developers who could actually explain why setImmediate and setTimeout(fn, 0) race unpredictably at the top level of a script, or why a recursive .then() chain can freeze a browser tab without ever blocking the call stack. That's the kind of understanding that turns "the event loop is confusing" into "the event loop is just a queue, some phases, and a very specific set of rules about what runs first" — which, at the end of the day, it is.

This is a summary aggregated from Dev.to. Read the complete article on the original site:

Read full article at Dev.to

More Startup & VC News