Worker Threads¶
Child processes give you isolation but each one spins up a whole V8 instance That's expensive - hundreds of MB per process , seconds to start Worker threads run inside the same process , share the same V8 heap , and start in milliseconds They're built for CPU work that would paralyze the Event Loop
When to Use Worker Threads¶
Worker threads are for CPU-intensive operations - NOT for I/O
// GOOD use case - CPU-bound work
// Image processing, data transformation, crypto, compression
// BAD use case - I/O work (use async I/O instead)
// File reads, network requests, database queries
Node's I/O is already non-blocking via the Event Loop and libuv's thread pool Throwing a worker at a file read wastes memory and complexity - the Event Loop handles it fine Reach for workers when you have heavy synchronous computation that blocks everything else
worker_threads Module¶
const { Worker, parentPort, workerData, isMainThread } = require('worker_threads')
Worker - creates a new worker thread running a script parentPort - message port on the worker side to communicate with parent workerData - data passed from parent to worker at creation (structured clone) isMainThread - boolean to check if code runs in main or worker
// main.js
const { Worker } = require('worker_threads')
const path = require('path')
function runInWorker(workerFile, data) {
return new Promise((resolve, reject) => {
const worker = new Worker(path.resolve(__dirname, workerFile), {
workerData: data
})
worker.on('message', resolve)
worker.on('error', reject)
worker.on('exit', (code) => {
if (code !== 0) {
reject(new Error(`Worker stopped with exit code ${code}`))
}
})
})
}
// Usage
const result = await runInWorker('./fib-worker.js', { n: 45 })
console.log('fib result:', result)
// fib-worker.js - runs in the worker thread
const { parentPort, workerData } = require('worker_threads')
function fib(n) {
if (n <= 1) return n
return fib(n - 1) + fib(n - 2)
}
const result = fib(workerData.n)
parentPort.postMessage(result)
Communication - Messages and Transferables¶
Messages between threads use structured clone algorithm - same as postMessage in browsers No prototype chains , no functions , no Symbols - just plain data
// main.js
const { Worker } = require('worker_threads')
const worker = new Worker('./processor.js', { workerData: { batchSize: 1000 } })
worker.on('message', (msg) => {
if (msg.type === 'progress') {
console.log(`Progress: ${msg.percent}%`)
} else if (msg.type === 'done') {
console.log('Result:', msg.data)
worker.terminate()
}
})
worker.postMessage({ cmd: 'start', input: largeArray })
// processor.js
const { parentPort, workerData } = require('worker_threads')
parentPort.on('message', async (msg) => {
if (msg.cmd === 'start') {
const total = msg.input.length
let processed = 0
for (const item of msg.input) {
// do CPU work
processed++
if (processed % 100 === 0) {
parentPort.postMessage({
type: 'progress',
percent: Math.round((processed / total) * 100)
})
}
}
parentPort.postMessage({ type: 'done', data: 'all processed' })
}
})
Transferables - move ArrayBuffer ownership to another thread without copying Useful for large binary data (images , video frames)
// Transfer large buffer without copying
const sharedBuffer = new SharedArrayBuffer(1024 * 1024 * 100) // 100MB
worker.postMessage({ buffer: sharedBuffer }, [sharedBuffer])
// After transfer, main thread can't access sharedBuffer anymore
SharedArrayBuffer - Shared Memory¶
const { Worker } = require('worker_threads')
// Create shared buffer
const sharedBuffer = new SharedArrayBuffer(4) // 4 bytes - one Int32
const sharedArray = new Int32Array(sharedBuffer)
const worker = new Worker('./counter-worker.js')
worker.postMessage({ shared: sharedBuffer })
// Wait for worker to finish
Atomics.wait(sharedArray, 0, 0)
console.log('Final count:', sharedArray[0])
// counter-worker.js
const { parentPort, workerData } = require('worker_threads')
parentPort.on('message', (msg) => {
const array = new Int32Array(msg.shared)
for (let i = 0; i < 1000000; i++) {
Atomics.add(array, 0, 1)
}
Atomics.store(array, 0, array[0])
Atomics.notify(array, 0)
parentPort.postMessage('done')
})
SharedArrayBuffer lets threads share memory without copying BUT you need Atomics for synchronization Data races corrupt your state faster than you can debug
Thread Pool Sizing¶
const os = require('os')
const { Worker } = require('worker_threads')
const POOL_SIZE = os.cpus().length
class WorkerPool {
constructor(workerFile, poolSize = os.cpus().length) {
this.workers = []
this.queue = []
this.active = 0
for (let i = 0; i < poolSize; i++) {
const worker = new Worker(workerFile)
worker.on('message', () => this._onComplete(worker))
worker.on('error', () => this._onComplete(worker))
this.workers.push(worker)
}
}
exec(data) {
return new Promise((resolve, reject) => {
this.queue.push({ data, resolve, reject })
this._processQueue()
})
}
_processQueue() {
if (this.queue.length === 0) return
if (this.active >= this.workers.length) return
const worker = this.workers[this.active]
const task = this.queue.shift()
this.active++
worker.postMessage(task.data)
worker.once('message', (result) => {
task.resolve(result)
})
}
_onComplete(worker) {
this.active--
this._processQueue()
}
}
os.cpus().length is the magic number - matches your CPU core count More workers than cores causes context switching overhead , not parallelism For I/O-heavy work you don't need workers (libuv's thread pool handles it)
worker_threads vs cluster vs child_process¶
| Feature | worker_threads | cluster | child_process |
|---|---|---|---|
| Memory | Shared heap | Separate | Separate |
| Start time | ~5ms | ~50ms | ~100ms+ |
| IPC | Structured clone | Built-in round-robin | stdio / message |
| Use case | CPU work | HTTP load balancing | Isolated tasks |
| Crash isolation | No (same process) | Per-worker | Per-process |
| Number of instances | Many (lightweight) | Few (heavy) | Few (heavy) |
worker_threads for CPU parallelism inside your process cluster for scaling HTTP servers across CPU cores child_process for running non-Node binaries or total isolation
Security - Thread Safety and Isolation¶
// DANGER - shared state without synchronization
const shared = new SharedArrayBuffer(4)
const view = new Int32Array(shared)
// Thread 1
view[0] = view[0] + 1 // read-modify-write - not atomic!
// Thread 2
view[0] = view[0] + 1 // both threads read same value - one increment lost
Race conditions with SharedArrayBuffer corrupt data silently Always use Atomics operations for shared memory access
// SAFE - atomic operations
Atomics.add(view, 0, 1) // thread-safe increment
Atomics.load(view, 0) // thread-safe read
Atomics.store(view, 0, 5) // thread-safe write
Workers can't be killed cleanly worker.terminate() kills the thread immediately - no cleanup , no finally blocks , no graceful shutdown If the worker held a file handle or database connection , it's gone
// Graceful shutdown pattern
worker.on('message', (msg) => {
if (msg === 'shutdown') {
worker.postMessage('cleanup')
setTimeout(() => worker.terminate(), 5000) // force kill after timeout
}
})
Workers share process memory space A segfault in one worker crashes the entire Node process Native addons in workers can corrupt shared heap Treat worker code with the same security scrutiny as main thread code
Prerequisites¶
- adv_01_child_process.md - understand process vs thread tradeoffs
next -> adv_03_native_addons.md