Node.js Concurrency & Worker Threads — Study Guide¶
A comprehensive guide to understanding Node.js performance, concurrency models, and when (and when not) to use Worker Threads.
Table of Contents¶
- The Three Execution Systems
- Event Loop
- libuv Thread Pool
- Worker Threads
- Data Transfer & Structured Clone
- Worker Pool Pattern
- Queue Behaviour Under Load
- When Workers Help vs. Hurt
- Common Mistakes
- Production Failure Modes
- Real-World Architecture
- Code Examples
- Debugging Workers
- Key Rules to Memorise
- Practice Scenarios
1. The Three Execution Systems¶
Node.js has three distinct execution layers. Understanding these removes most confusion.
| System | Threads | Purpose | Example APIs |
|---|---|---|---|
| Event Loop | 1 | Executes all JavaScript | Express handlers, JSON.parse |
| libuv Thread Pool | 4 (default) | OS-level blocking tasks | fs.readFile, crypto.pbkdf2 |
| Worker Threads | Configurable | CPU-heavy JavaScript tasks | Image processing, compression |
Node.js Process
│
┌────┼─────────────┐
▼ ▼ ▼
Event Loop libuv Pool Worker Threads
(JS logic) (OS tasks) (CPU JS)
2. Event Loop¶
The Event Loop runs all JavaScript on a single thread using non-blocking I/O and cooperative multitasking.
How I/O Works Efficiently¶
app.get("/user", async (req, res) => {
const user = await db.query("SELECT * FROM users"); // non-blocking wait
res.json(user);
});
While waiting for the database, the event loop handles other requests — no thread wasted.
What Blocks the Event Loop¶
These synchronous operations freeze the entire server while running:
| Operation | Risk |
|---|---|
Large JSON.parse() (e.g. 50 MB) | Blocks until parsing completes |
| Billion-iteration loops | Blocks for seconds |
| Complex regular expressions | Can cause catastrophic backtracking |
| Synchronous crypto operations | CPU-bound on main thread |
Example of blocking impact:
| Request | Task | Expected | Actual (blocked) |
|---|---|---|---|
| User A | /calculate (3 s CPU) | 3 s | 3 s |
| User B | /user (10 ms I/O) | 10 ms | 3 s (waits for A) |
| User C | /user (10 ms I/O) | 10 ms | 3 s (waits for A) |
This is called Event Loop Blocking — the entire API stalls during heavy computation.
3. libuv Thread Pool¶
Node uses libuv internally to run blocking OS tasks on background threads.
- Default pool size: 4 threads
- Max pool size: 128
APIs That Use the libuv Pool¶
fs.readFile · fs.writeFile · crypto.pbkdf2 · zlib.gzip · dns.lookup
Saturation Example¶
10 crypto.pbkdf2 calls arrive:
→ 4 execute immediately (one per thread)
→ 6 wait in queue
Increase the pool size via environment variable:
UV_THREADPOOL_SIZE=8 node app.js
Diagnosing Pool Saturation¶
| Symptom | Meaning |
|---|---|
| Slow crypto / file I/O | Threads are all busy |
| Low CPU usage | Work is queued, not executing |
4. Worker Threads¶
Introduced in Node.js v10.5.0, Worker Threads allow parallel execution of CPU-heavy JavaScript inside the same process.
Why They Exist¶
Before Worker Threads, developers had two poor options:
child_process/cluster— heavy memory, slow startup, complex IPC- Separate service (e.g. Python worker) — two systems to maintain
What Each Worker Gets¶
| Resource | Isolation |
|---|---|
| V8 engine | Own instance |
| Event loop | Own loop |
| Memory heap | Own heap (~10–20 MB) |
Workers run a separate JavaScript runtime, enabling true parallelism. Memory is isolated by default to prevent race conditions and data corruption.
Worker Lifecycle Costs¶
| Phase | Time |
|---|---|
| Create worker | ~20–30 ms |
| Serialise message | ~1–5 ms |
| Run task | Varies |
| Return result | ~1–5 ms |
| Destroy worker | ~5–10 ms |
Key insight: If your task takes < 100 ms, the overhead of creating a worker can exceed the task itself.
The Tradeoffs¶
- Thread creation overhead — workers are expensive to spin up
- Memory duplication — each worker loads its own runtime
- Data transfer cost — large payloads are copied, not shared
- Complexity — multi-threaded debugging is harder
5. Data Transfer & Structured Clone¶
Workers communicate via message passing using the Structured Clone Algorithm.
// Main thread sends
worker.postMessage(data);
// Worker receives
parentPort.on("message", (data) => { /* ... */ });
The Hidden Cost¶
Objects are copied, not shared:
Send 150 MB object to a worker:
Main thread → 150 MB
Worker → 150 MB
Total memory → 300 MB
Additional costs: serialisation CPU time, deserialisation, and GC pressure from large temporary objects.
Best Practice¶
- Send small messages (IDs, parameters)
- Let the worker fetch its own data if needed
- Avoid sending large JSON blobs
6. Worker Pool Pattern¶
Never create a worker per request. Instead, pre-create a pool of long-lived workers.
Incoming Requests
│
▼
Job Queue
│
┌─────┼─────┐
▼ ▼ ▼
Worker Worker Worker (long-lived, reused)
Sizing¶
workers ≈ CPU cores (or cores − 1 to leave room for the event loop)
Example: 8-core machine → 7–8 workers
Recommended Libraries¶
7. Queue Behaviour Under Load¶
When jobs arrive faster than workers can process them, the queue grows.
Throughput Calculation¶
Workers: 8
Task time: 500 ms each
Throughput: 8 jobs / 500 ms = 16 jobs/sec
Latency Under Spike¶
| Scenario | Jobs | Workers | Task Time | Total Time | Last Request Waits |
|---|---|---|---|---|---|
| Small spike | 100 | 8 | 500 ms | ~6.5 s | ~6.5 s |
| Large spike | 10,000 | 8 | 1 s | ~1,250 s | ~20 minutes |
Memory Risk with Unbounded Queues¶
20,000 queued jobs × 2 MB each = 40 GB → server crash
Mitigation Strategies¶
- Queue size limits — reject when full
- Rate limiting — throttle incoming requests
- Backpressure — return 503 to clients
- Streaming — avoid buffering full payloads
8. When Workers Help vs. Hurt¶
✅ Use Workers When¶
- Task is CPU-bound (not I/O)
- Task takes > 100–200 ms
- Input/output is small
- Tasks are independent of each other
Good candidates: image resizing, password hashing, data compression, ML inference, large JSON parsing
❌ Do NOT Use Workers When¶
- Task is < 100 ms — overhead exceeds benefit
- Task is I/O-bound — databases and HTTP are already async
- Data transfer is large — clone cost dominates
- You'd create a new worker per request — memory explosion
Example of workers making things worse:
Task: 20 ms CPU
Worker: ~30 ms startup + 20 ms task + ~10 ms teardown = ~60 ms
Direct: 20 ms
9. Common Mistakes¶
| # | Mistake | Why It's Bad | Fix |
|---|---|---|---|
| 1 | Using workers for I/O | DB/file ops are already async; workers add overhead | Use async/await directly |
| 2 | Creating a worker per request | 1,000 requests = 1,000 workers = crash | Use a worker pool |
| 3 | Sending massive objects | 100 MB JSON → copied → memory spike | Send IDs; let workers fetch data |
| 4 | Ignoring worker crashes | Lost tasks, silent failures | Always listen to worker.on("error") and worker.on("exit") |
| 5 | Unbounded queues | Memory grows without limit under load | Set max queue size; apply backpressure |
10. Production Failure Modes¶
Event Loop Blocking¶
| Symptom | Cause |
|---|---|
| CPU 100%, high event loop lag, all requests slow | Heavy synchronous JS on main thread (e.g. JSON.parse(50MB)) |
Thread Pool Saturation¶
| Symptom | Cause |
|---|---|
| Slow crypto / file I/O, low CPU | libuv pool fully queued |
Queue Memory Explosion¶
| Symptom | Cause |
|---|---|
| High memory, swap usage, GC pauses, eventual OOM | Unbounded queue with large payloads |
11. Real-World Architecture¶
For heavy workloads, production teams often separate compute from the API:
HTTP API (Node.js)
│
▼
Job Queue (Redis / Kafka)
│
┌────┼────────┐
▼ ▼ ▼
Worker Services (separate processes or containers)
Benefits:
- Isolated failures — worker crash doesn't affect the API
- Horizontal scaling — add workers independently
- No API latency spikes
- Separate memory budgets
Senior engineer mindset: Don't just ask "should I use a worker thread?" Ask: "Should this computation run in the API process at all?"
12. Code Examples¶
Main Thread¶
// main.js
const { Worker } = require("worker_threads");
function runWorker(input) {
return new Promise((resolve, reject) => {
const worker = new Worker("./worker.js", {
workerData: input,
});
worker.on("message", resolve);
worker.on("error", reject);
worker.on("exit", (code) => {
if (code !== 0) {
reject(new Error(`Worker stopped with exit code ${code}`));
}
});
});
}
runWorker(100_000_000).then((result) => {
console.log("Result:", result);
});
Worker File¶
// worker.js
const { parentPort, workerData } = require("worker_threads");
function heavyCalculation(n) {
let sum = 0;
for (let i = 0; i < n; i++) {
sum += i;
}
return sum;
}
const result = heavyCalculation(workerData);
parentPort.postMessage(result);
⚠️ Common Bug: workerData vs postMessage¶
Wrong — worker reads workerData, but main sends via postMessage:
// main.js — sends via postMessage
worker.postMessage(1000000);
// worker.js — reads workerData (will be undefined!)
const result = calc(workerData);
Fix — either pass data as workerData in the constructor, or listen with parentPort.on("message") in the worker:
// Option A: Use workerData
new Worker("./worker.js", { workerData: 1000000 });
// Option B: Use message passing
parentPort.on("message", (n) => {
parentPort.postMessage(calc(n));
});
13. Debugging Workers¶
Strategy¶
- Use structured logging with worker IDs
- Implement message tracing between main and worker
- Add timeout protection — kill workers that run too long
- Always handle errors:
worker.on("error", (err) => console.error(`Worker error: ${err.message}`));
worker.on("exit", (code) => {
if (code !== 0) console.error(`Worker exited with code ${code}`);
});
14. Key Rules to Memorise¶
| # | Rule |
|---|---|
| 1 | Node.js runs JavaScript on one event loop thread |
| 2 | libuv pool handles OS-level blocking tasks (default 4 threads) |
| 3 | Worker threads handle CPU-heavy JavaScript |
| 4 | Size your pool: workers ≈ CPU cores |
| 5 | Never allow unbounded queues |
| 6 | Avoid sending large objects to workers |
| 7 | Workers only help when CPU task >> worker overhead (> 100 ms) |
| 8 | I/O tasks don't need workers — they're already async |
15. Practice Scenarios¶
Scenario A: No Workers vs. 4 Workers¶
10 requests arrive simultaneously, each with a 2-second CPU task.
| Metric | No Workers | 4 Workers |
|---|---|---|
| Execution | Sequential (event loop blocked) | Parallel in batches of 4 |
| Total time | ~20 s | ~6 s (3 batches × 2 s) |
| Worst-case latency | 20 s (last request) | ~6 s |
Scenario B: Video Upload API¶
200 uploads arrive; each needs 10 s of CPU compression. Server has 8 cores.
- Workers: 7–8
- Batches:
ceil(200 / 8) = 25 - Total time:
25 × 10 s = 250 s - Why not 200 workers?
200 × 20 MB = 4 GBmemory + context-switching overhead
Scenario C: Tiny Task¶
Task takes 20 ms. Worker creation takes 30 ms.
- With worker: ~60 ms total
- Without worker: 20 ms
- Verdict: Workers make it worse
Scenario D: Large Queue + Small Payload¶
100 requests, 8 workers, 500 ms per task.
- Batches:
ceil(100 / 8) = 13 - Total:
13 × 500 ms = 6.5 s
Scenario E: Low CPU but Slow System¶
8 workers, 20,000 queued jobs, 2 MB each, CPU at 40%.
- Queue memory:
20,000 × 2 MB = 40 GB - Bottleneck: Memory pressure, GC pauses, and swap — not CPU
Practice Exercises¶
- Write a worker that calculates factorial
- Build a worker pool with 4 workers
- Benchmark
JSON.parseof a 50 MB string on the event loop vs. a worker thread - Simulate 1,000 jobs with a queue and measure per-job latency
Final thought: The goal isn't to use worker threads everywhere — it's to understand the execution model so you choose the right tool for each problem.