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.