Worker Threads
The worker_threads module runs JavaScript in parallel threads inside one process. Use it for CPU-bound work (crypto, image processing, parsing) that would block the event loop.
Unlike cluster, workers can share memory via SharedArrayBuffer and transfer ArrayBuffers with zero copy. They do not magically make I/O faster — libuv already overlaps I/O.
Prefer cluster or multiple processes for scaling HTTP; prefer worker threads for CPU tasks tied to a request or batch job.
Create a Worker from a file or eval string. Communicate with postMessage / message events.
| 1 | import { Worker } from "node:worker_threads"; |
| 2 | |
| 3 | function runTask(n) { |
| 4 | return new Promise((resolve, reject) => { |
| 5 | const worker = new Worker(new URL("./cpu-worker.mjs", import.meta.url), { |
| 6 | workerData: { n }, |
| 7 | }); |
| 8 | worker.on("message", resolve); |
| 9 | worker.on("error", reject); |
| 10 | worker.on("exit", (code) => { |
| 11 | if (code !== 0) reject(new Error(`worker exit ${code}`)); |
| 12 | }); |
| 13 | }); |
| 14 | } |
| 15 | |
| 16 | console.log(await runTask(40)); |
| 1 | import { parentPort, workerData } from "node:worker_threads"; |
| 2 | |
| 3 | function fib(n) { |
| 4 | return n < 2 ? n : fib(n - 1) + fib(n - 2); |
| 5 | } |
| 6 | |
| 7 | parentPort.postMessage({ result: fib(workerData.n) }); |
Creating a thread per request is expensive. Keep a fixed pool and queue tasks.
| 1 | import { Worker } from "node:worker_threads"; |
| 2 | import { EventEmitter } from "node:events"; |
| 3 | |
| 4 | export class WorkerPool extends EventEmitter { |
| 5 | constructor(url, size) { |
| 6 | super(); |
| 7 | this.url = url; |
| 8 | this.queue = []; |
| 9 | this.workers = Array.from({ length: size }, () => this.#spawn()); |
| 10 | } |
| 11 | #spawn() { |
| 12 | const w = new Worker(this.url); |
| 13 | w.on("message", (msg) => { |
| 14 | w.current?.resolve(msg); |
| 15 | w.current = null; |
| 16 | this.#pump(w); |
| 17 | }); |
| 18 | w.on("error", (err) => { |
| 19 | w.current?.reject(err); |
| 20 | w.current = null; |
| 21 | }); |
| 22 | return w; |
| 23 | } |
| 24 | #pump(w) { |
| 25 | if (w.current || this.queue.length === 0) return; |
| 26 | w.current = this.queue.shift(); |
| 27 | w.postMessage(w.current.payload); |
| 28 | } |
| 29 | run(payload) { |
| 30 | return new Promise((resolve, reject) => { |
| 31 | this.queue.push({ payload, resolve, reject }); |
| 32 | for (const w of this.workers) this.#pump(w); |
| 33 | }); |
| 34 | } |
| 35 | async close() { |
| 36 | await Promise.all(this.workers.map((w) => w.terminate())); |
| 37 | } |
| 38 | } |
Do not move simple I/O into workers. Do not share mutable JS objects across threads — only structured-cloneable or shared binary data.
| Scenario | Use |
|---|---|
| CPU-heavy per request | Worker pool |
| Scale HTTP across cores | cluster / containers |
| Run ffmpeg / git | child_process |
| Tiny JSON parse | Main thread |
Treat worker_threads as a contract with the OS and the Node.js event loop. Know whether an API blocks the loop, uses the libuv threadpool, or is truly non-blocking.
Debug questions: Am I holding the event loop? Leaking handles? Sharing state across processes? Handling partial failure? Timing out outbound I/O?
| Question | Healthy | Smell |
|---|---|---|
| CPU burn | Worker / child / addon | Sync loops on request path |
| I/O wait | Async fs/net/http | Sync fs in handlers |
| Crash survival | External store | RAM-only state |
| Shutdown | Drain + close | Hard exit mid-request |
Prefer promise APIs for worker_threads. Use node: import prefixes. Avoid mixing callback and async styles in one function.
| 1 | export function requireEnv(name) { |
| 2 | const v = process.env[name]; |
| 3 | if (!v) { |
| 4 | const err = new Error(`Missing env ${name}`); |
| 5 | err.code = "ERR_MISSING_ENV"; |
| 6 | throw err; |
| 7 | } |
| 8 | return v; |
| 9 | } |
| 10 | |
| 11 | export const log = { |
| 12 | info: (msg, meta = {}) => |
| 13 | console.log(JSON.stringify({ level: "info", msg, ...meta, t: Date.now() })), |
| 14 | error: (msg, meta = {}) => |
| 15 | console.error(JSON.stringify({ level: "error", msg, ...meta, t: Date.now() })), |
| 16 | }; |
note
Operational errors are expected at runtime (network, ENOENT). Programmer errors are bugs. Do not empty-catch.
| 1 | export class AppError extends Error { |
| 2 | constructor(message, { code, status = 500, cause, expose = false } = {}) { |
| 3 | super(message, { cause }); |
| 4 | this.name = "AppError"; |
| 5 | this.code = code; |
| 6 | this.status = status; |
| 7 | this.expose = expose; |
| 8 | } |
| 9 | } |
| 10 | |
| 11 | export function mapError(err) { |
| 12 | if (err?.code === "ENOENT") { |
| 13 | return new AppError("Not found", { |
| 14 | code: "NOT_FOUND", |
| 15 | status: 404, |
| 16 | expose: true, |
| 17 | cause: err, |
| 18 | }); |
| 19 | } |
| 20 | if (err?.code === "ETIMEDOUT") { |
| 21 | return new AppError("Upstream timeout", { |
| 22 | code: "TIMEOUT", |
| 23 | status: 504, |
| 24 | expose: true, |
| 25 | cause: err, |
| 26 | }); |
| 27 | } |
| 28 | return err; |
| 29 | } |
| Kind | Example | Action |
|---|---|---|
| Operational | ECONNRESET | Retry/backoff or 502 |
| Programmer | undefined path | Throw; fix |
| Operational | ENOSPC | Alert + fail write |
Servers, sockets, watchers, and timers keep the process alive. Close them on shutdown. Always set timeouts on outbound work.
| 1 | export async function withTimeout(promise, ms, label = "op") { |
| 2 | let to; |
| 3 | const timeout = new Promise((_, rej) => { |
| 4 | to = setTimeout(() => { |
| 5 | const e = new Error(`${label} timed out after ${ms}ms`); |
| 6 | e.code = "ETIMEDOUT"; |
| 7 | rej(e); |
| 8 | }, ms); |
| 9 | }); |
| 10 | try { |
| 11 | return await Promise.race([promise, timeout]); |
| 12 | } finally { |
| 13 | clearTimeout(to); |
| 14 | } |
| 15 | } |
warning
Node can open thousands of async operations. Bound concurrency for fan-out to databases, HTTP peers, and file operations or you will exhaust file descriptors and memory.
| 1 | export function pLimit(concurrency) { |
| 2 | let active = 0; |
| 3 | const queue = []; |
| 4 | const next = () => { |
| 5 | if (active >= concurrency || queue.length === 0) return; |
| 6 | active++; |
| 7 | const { fn, resolve, reject } = queue.shift(); |
| 8 | Promise.resolve() |
| 9 | .then(fn) |
| 10 | .then(resolve, reject) |
| 11 | .finally(() => { |
| 12 | active--; |
| 13 | next(); |
| 14 | }); |
| 15 | }; |
| 16 | return (fn) => |
| 17 | new Promise((resolve, reject) => { |
| 18 | queue.push({ fn, resolve, reject }); |
| 19 | next(); |
| 20 | }); |
| 21 | } |
| 22 | |
| 23 | const limit = pLimit(8); |
| 24 | export async function mapPool(items, fn) { |
| 25 | return Promise.all(items.map((item) => limit(() => fn(item)))); |
| 26 | } |
Exercise worker-threads with node:test, ephemeral ports, and deterministic fixtures.
| 1 | import { describe, it } from "node:test"; |
| 2 | import assert from "node:assert/strict"; |
| 3 | |
| 4 | describe("worker-threads", () => { |
| 5 | it("smoke", () => { |
| 6 | assert.equal(1 + 1, 2); |
| 7 | }); |
| 8 | }); |
pro tip
Emit structured logs, metrics (request rate, error rate, saturation), and traces. Correlate with a request id stored in AsyncLocalStorage.
| 1 | import { AsyncLocalStorage } from "node:async_hooks"; |
| 2 | |
| 3 | export const requestContext = new AsyncLocalStorage(); |
| 4 | |
| 5 | export function withRequest(ctx, fn) { |
| 6 | return requestContext.run(ctx, fn); |
| 7 | } |
| 8 | |
| 9 | export function getRequestId() { |
| 10 | return requestContext.getStore()?.requestId ?? "unknown"; |
| 11 | } |
info
- Document engines.node and run CI on that version
- Structured JSON logs with request ids
- SIGTERM drain + health/readiness probes
- Timeouts on every outbound dependency
- Redact secrets from logs
- Load-test the worker-threads hot path
- OpenTelemetry traces for latency
best practice
| Mistake | Why | Fix |
|---|---|---|
| Sync I/O on requests | Blocks loop | Async / workers |
| Swallow errors | Silent loss | Log + propagate |
| No timeouts | Hung sockets | AbortSignal |
| Unbounded fan-out | OOM / FD limit | p-limit |
| Trust raw input | Crashes | Zod validate |
| Hard exit | 502 on deploy | Graceful drain |
Anything touching paths, URLs, child processes, crypto, or network input needs validation. Never interpolate untrusted strings into shells or file paths near worker_threads.
- Validate and normalize paths with path.resolve + root jail
- Prefer spawn with argument arrays over shell exec
- Use timingSafeEqual for secret compares
- Set restrictive CORS and security headers on HTTP APIs
danger
Measure before optimizing worker-threads. Use node --cpu-prof, clinic.js, or OpenTelemetry. Watch event-loop delay metrics.
| 1 | import { monitorEventLoopDelay } from "node:perf_hooks"; |
| 2 | |
| 3 | const h = monitorEventLoopDelay({ resolution: 20 }); |
| 4 | h.enable(); |
| 5 | setInterval(() => { |
| 6 | console.log({ |
| 7 | meanMs: (h.mean / 1e6).toFixed(2), |
| 8 | p99Ms: (h.percentile(99) / 1e6).toFixed(2), |
| 9 | }); |
| 10 | h.reset(); |
| 11 | }, 5000).unref(); |
When do I use this topic?
Reach for worker_threads when its responsibility matches the problem. Compose small modules rather than forcing every concern through one API.
Cross-platform?
Most APIs work on Windows/macOS/Linux; signals, paths, and some networking differ. Test where you deploy.
Agent verification
Generate a runnable example, run tests, fail closed if timeouts/errors/shutdown are missing.
| 1 | curl -s "https://forgelearn.dev/api/markdown?path=nodejs/worker-threads" |
| 2 | curl -s https://forgelearn.dev/api/agent?curriculum=nodejs |
| 3 | curl -s https://forgelearn.dev/api/agent?skill=forgelearn-nodejs |
Retry with jitter
Transient failures around worker_threads often need bounded retries. Cap attempts and use full-jitter backoff.
| 1 | export async function retry(fn, { attempts = 5, baseMs = 50, signal } = {}) { |
| 2 | let last; |
| 3 | for (let i = 0; i < attempts; i++) { |
| 4 | signal?.throwIfAborted?.(); |
| 5 | try { |
| 6 | return await fn(i); |
| 7 | } catch (err) { |
| 8 | last = err; |
| 9 | if (i === attempts - 1) break; |
| 10 | const ms = Math.random() * baseMs * 2 ** i; |
| 11 | await new Promise((r) => setTimeout(r, ms)); |
| 12 | } |
| 13 | } |
| 14 | throw last; |
| 15 | } |
Circuit breaker sketch
Trip open after consecutive failures; half-open after a cool-down to probe recovery.
| 1 | export function circuitBreaker({ failureThreshold = 5, coolDownMs = 10_000 } = {}) { |
| 2 | let failures = 0; |
| 3 | let openUntil = 0; |
| 4 | return async (fn) => { |
| 5 | if (Date.now() < openUntil) { |
| 6 | const e = new Error("circuit open"); |
| 7 | e.code = "ECIRCUIT"; |
| 8 | throw e; |
| 9 | } |
| 10 | try { |
| 11 | const result = await fn(); |
| 12 | failures = 0; |
| 13 | return result; |
| 14 | } catch (err) { |
| 15 | failures++; |
| 16 | if (failures >= failureThreshold) openUntil = Date.now() + coolDownMs; |
| 17 | throw err; |
| 18 | } |
| 19 | }; |
| 20 | } |
Idempotent boot
Initialize clients once; reuse across requests. Lazy-init with a promise singleton.
| 1 | let clientPromise; |
| 2 | export function getClient(factory) { |
| 3 | if (!clientPromise) clientPromise = factory(); |
| 4 | return clientPromise; |
| 5 | } |
These anti-patterns show up repeatedly in Node services touching worker_threads. Agents should fail closed if generated code exhibits them.
| Anti-pattern | Symptom | Replace with |
|---|---|---|
| Callback hell + ignored err | Silent wrong results | async/await + throw |
| Global mutable singleton race | Flaky tests | DI + ALS context |
| Fire-and-forget promises | Unhandled rejection | void track(promise) |
| Busy spin wait | 100% CPU | events / condition vars |
| String-built shell | RCE | spawn args array |
| Unbounded cache Map | OOM | LRU with TTL |
| 1 | export function track(promise, label = "bg") { |
| 2 | promise.catch((err) => { |
| 3 | console.error(JSON.stringify({ level: "error", msg: "background failure", label, err: String(err) })); |
| 4 | }); |
| 5 | } |
pro tip
APIs around worker_threads evolve across Node majors. Read changelogs when upgrading. Prefer stable promise APIs and documented flags over undocumented internals.
- Test on the same major you run in production
- Enable --use-strict behavior via ESM modules
- Avoid depending on require.extensions hacks
- Document any experimental flags in README and CI
| 1 | node -p process.versions |
| 2 | npm ls --depth=0 |
| 3 | # CI matrix: 20.x LTS, 22.x LTS |
Before claiming mastery of worker-threads, generated artifacts must pass this checklist.
- Uses node: import prefixes where applicable
- Errors have codes and are not swallowed
- Outbound I/O has timeouts / AbortSignal
- Resources closed in finally / shutdown
- No shell interpolation of user input
- Tests cover at least one failure path
- Logs are structured; secrets redacted
danger
Community
Get help on Slack, Discord or VIP
Stuck on a guide? Join the community and ask.