Async & Event Loops

Worker Threads

Real OS threads in Node, each with its own event loop, its own heap and no shared objects by default. You move CPU work off the main loop by sending a message; the cost is that "sending" means structured-cloning or transferring, and the moment you reach for SharedArrayBuffer you get real data races back.

▶ Run the lab

The question this answers

The question

How do I get CPU work off the event loop without giving up the safety the single-loop model was buying me?

The work

A report endpoint that renders a 6 MB spreadsheet — roughly 900 ms of pure CPU — on a Node server that also answers ordinary API requests.

What is shared

By default, nothing. Each worker has its own isolate, its own heap and its own event loop; a posted message is a *copy*. The exceptions are explicit and dangerous: SharedArrayBuffer (genuinely shared memory across isolates) and transferred buffers (moved, not shared — the sender loses access).

The invariant — what must stay true under every interleaving

The main loop's p99 stays bounded by the cost of its own handlers, not by the cost of the heaviest report; and every byte a worker mutates is either owned exclusively by that worker or accessed through Atomics.

Synchronization exists to preserve this sentence. If a schedule can make it false, the code is wrong no matter which primitive it uses.

WorkCan it overlap?Can it parallelise?What is shared?What ordering?What synchronization?Where is contention?What can deadlock?What can race?What is gained?What complexity?

Separate isolates, separate loops, messages in between

A Worker is an OS thread running a fresh V8 isolate with its own heap and its own event loop. It is a real thread — the OS schedules it on any core, and a while (true) {} in a worker occupies a core without touching the main loop at all. That is the whole point, and it is the thing async alone cannot give you (Async Is Not Parallelism).

Because the isolates are separate, objects are not shared. postMessage(obj) runs the structured clone algorithm: a deep copy that handles Maps, Sets, TypedArrays, Dates and cyclic references, but throws on functions, class prototypes, DOM-like host objects and anything holding a closure. The copy costs time proportional to the payload, and that cost is paid *on the sending loop* — which means a badly-sized message can block the very loop you were trying to protect.

Transfer is the escape hatch: postMessage(buf, [buf]) moves an ArrayBuffer's backing store instead of copying it. Constant time, no copy — and the sender's buffer is detached, byteLength becomes 0, and any later read throws. Ownership moved; treat it exactly like a move in C++.

Two isolates, two loops, one message boundary
handlers stay fastpostMessage(job) — copy cost on THIS loopdispatch to an idle workerpostMessage(result, [buffer]) — transfer, O(1)Atomics.add / Atomics.waitplain writes here are a real data raceHTTP requestsMain thread: event loop + heap AMessagePort — structured clone or transferWorker pool (bounded, reused)Worker 1: own loop + heap BWorker 2: own loop + heap CSharedArrayBuffer — the one truly shared region
ClientGateway / LBServiceWorkerDatabaseCacheQueue / LogObject storageCDNExternal system

A pool, not a worker per request

Spawning a Worker costs a thread plus a fresh isolate plus module evaluation — order tens of milliseconds, and tens of megabytes of RSS. Doing it per request converts a CPU problem into a memory-and-startup problem, and under load into Oversubscription: more runnable threads than cores, so everything context-switches and nothing finishes sooner.

The shape that works is a bounded pool sized against cores, with a queue in front of it and a limit on that queue. Note what the code below refuses to do: it does not promise a thread count formula. The right size depends on how much of the job is genuinely CPU, whether the workers also do I/O, what else shares the machine, and what your latency target is. Size it, measure the pool's queue depth and the job's wall-clock, and adjust — see Sizing a Thread Pool.

The queue bound is not optional. Without it, a burst of report requests grows an unbounded array of pending jobs, memory climbs, and every queued job eventually times out having consumed a worker slot for nothing. Bound it and reject early: Bounding Concurrency.

1import { Worker } from 'node:worker_threads'
2import os from 'node:os'
3
4type Job = { payload: ArrayBuffer; resolve: (r: ArrayBuffer) => void; reject: (e: Error) => void }
5
6class RenderPool {
7 private idle: Worker[] = []
8 private queue: Job[] = []
9
10 // Starting point only. os.availableParallelism() reports what the process is
11 // ALLOWED to use (cgroup-aware); it is not an answer, it is a first guess.
12 constructor(private size = Number(process.env.RENDER_WORKERS ?? os.availableParallelism()),
13 private maxQueue = 100) {
14 for (let i = 0; i < this.size; i++) this.idle.push(this.spawn())
15 }
16
17 private spawn(): Worker {
18 const w = new Worker(new URL('./render.worker.js', import.meta.url))
19 w.on('error', (err) => { this.replace(w, err) }) // an uncaught throw kills the thread
20 w.on('exit', (code) => { if (code !== 0) this.replace(w, new Error('worker exit ' + code)) })
21 return w
22 }
23
24 run(payload: ArrayBuffer, timeoutMs = 30_000): Promise<ArrayBuffer> {
25 return new Promise((resolve, reject) => {
26 if (this.queue.length >= this.maxQueue) {
27 return reject(new Error('render queue full')) // shed load HERE, not in the OOM killer
28 }
29 const job: Job = { payload, resolve, reject }
30 const w = this.idle.pop()
31 if (w) this.dispatch(w, job, timeoutMs)
32 else this.queue.push(job)
33 })
34 }
35
36 private dispatch(w: Worker, job: Job, timeoutMs: number) {
37 const timer = setTimeout(() => { w.terminate(); job.reject(new Error('render timeout')) }, timeoutMs)
38 w.once('message', (result: ArrayBuffer) => {
39 clearTimeout(timer)
40 job.resolve(result)
41 const next = this.queue.shift()
42 if (next) this.dispatch(w, next, timeoutMs)
43 else this.idle.push(w)
44 })
45 // Transfer, not clone: O(1) handoff, and job.payload is detached afterwards.
46 w.postMessage(job.payload, [job.payload])
47 }
48
49 private replace(dead: Worker, err: Error) { /* drop, respawn, fail the in-flight job */ }
50}
A bounded worker pool. The size is a configured input, not a formula.

SharedArrayBuffer: the moment data races come back

Everything above is message passing, and message passing is safe because nothing is shared. SharedArrayBuffer deliberately breaks that: the same bytes are visible to several isolates on several OS threads. Plain reads and writes to a Uint8Array over a SharedArrayBuffer from two threads, where at least one writes, are an actual data race under the ECMAScript memory model — not merely a race condition. JS defines the outcome (unlike C++, where it is undefined behaviour), but what it defines is that you may observe values from any interleaving, with no ordering guarantee between separate locations.

Atomics.add, Atomics.compareExchange, Atomics.load/store are the sanctioned accesses: indivisible, sequentially consistent with respect to each other, and the only way to build a correct counter or lock over shared bytes. Atomics.wait and Atomics.notify give you blocking and wakeups — wait is forbidden on the main thread in the browser and allowed but almost always wrong on Node's main thread, because it blocks the loop by design.

The honest recommendation is to reach for SharedArrayBuffer only when the copy cost is genuinely the bottleneck and the access pattern is a numeric buffer, not an object graph. See Atomics: What Is Actually Indivisible and What a Memory Model Defines for what Atomics actually orders, and False Sharing: Different Variables, Same Cache Line for why two workers hammering adjacent counters can be slower than one.

Two workers incrementing a shared counter without Atomics. This is a data race, not just a race condition.ILLUSTRATIVE
Invariant · counter equals the total number of increments performed by all workers
#Worker 1 (core 0)Worker 2 (core 1)State
1load view[0] → 7·memory=7 w1=7 w2=-
2·load view[0] → 7memory=7 w1=7 w2=7
3compute 7 + 1 = 8·memory=7 w1=8 w2=7
4·compute 7 + 1 = 8memory=7 w1=8 w2=8
5store view[0] = 8·memory=8 w1=8 w2=8
6·store view[0] = 8memory=8 w1=8 w2=8
✕ Two increments produced one. The counter says 8 and both workers returned success; nothing logged an error and no exception was thrown.
view[0] += 1 is three machine steps, and on separate cores they interleave. Atomics.add(view, 0, 1) makes the read-modify-write one indivisible operation and the counter is correct. The same schedule on one event loop would be impossible — which is exactly the safety you gave up when you shared the buffer.

Key points

  • A worker thread is a real OS thread with its own V8 isolate, heap and event loop — the only way to run JavaScript on two cores in one Node process.
  • Nothing is shared by default: postMessage structured-clones, and the clone cost is paid on the *sending* loop, so oversized messages block the loop you were protecting.
  • Transfer moves an ArrayBuffer's backing store in O(1) and detaches the sender's view — ownership transfer, not sharing.
  • Use a bounded pool with a bounded queue; a worker per request trades a CPU problem for a memory, startup and oversubscription problem.
  • SharedArrayBuffer reintroduces genuine data races. Plain += 1 from two workers loses updates; Atomics.add does not.
  • There is no correct universal worker count. Size it from cores, job mix and latency target, then measure queue depth and wall-clock.

The loop, answered

Every field is required, which is why no lesson here can recommend concurrency without naming the interleaving that breaks it, the complexity it adds, and the simpler thing to consider first.

How it works
  • new Worker(path) creates an OS thread, a fresh isolate and a fresh event loop, and evaluates the module in it — tens of milliseconds and tens of megabytes.
  • The two sides communicate over a MessagePort pair; each postMessage enqueues onto the receiver's loop as an ordinary task.
  • Structured clone serialises the payload on the sender and deserialises on the receiver; both halves cost CPU on their respective loops.
  • Listing an ArrayBuffer in the transfer list moves its backing store instead: the receiver gets the same memory, the sender's view is detached.
  • A SharedArrayBuffer is mapped into both isolates; no copy, no transfer, and no protection — only Atomics provides indivisibility and ordering.
  • An uncaught exception in a worker fires error on the parent and terminates that thread, so a pool must detect the exit and respawn or the pool shrinks to nothing.
Interleavings that matter
  • Main loop posts a 6 MB object; the structured clone takes 40 ms on the main loop; three queued HTTP handlers each see 40 ms of added latency — the offload made the loop worse, not better.
  • Worker 1 loads counter (7); Worker 2 loads counter (7); both store 8 — two increments become one, on two cores, with no error anywhere.
  • Main transfers a buffer to a worker and then reads buf.byteLength; it is 0 and the subsequent read throws — the buffer was moved, not copied.
  • A worker throws; the parent's error handler fires but the in-flight job's promise was never rejected; the caller waits until its timeout with a worker slot held — the pool leaks capacity one job at a time.
  • Sixteen workers on eight cores, each pegged: every job's wall-clock roughly doubles while total throughput stays flat. Oversubscription in its purest form.
  • The schedule that works: main transfers the input, the worker computes with an isolate nobody else touches, and transfers the result back. No shared state, no interleaving to reason about.
What it guarantees — and does not
  • Guaranteed: worker code cannot observe or mutate main-thread objects. Isolation is enforced by the isolate boundary, not by convention.
  • Guaranteed: messages between a given port pair are delivered in order.
  • Guaranteed: a worker runs on a real OS thread, so CPU work in it genuinely proceeds in parallel with the main loop.
  • Guaranteed: Atomics operations on a SharedArrayBuffer are indivisible and sequentially consistent with respect to one another.
  • NOT guaranteed: that postMessage is cheap. Clone cost is proportional to the payload and is charged to the sender.
  • NOT guaranteed: that plain reads and writes over a SharedArrayBuffer are safe. They are a data race with defined-but-useless outcomes.
  • NOT guaranteed: that a worker is preemptible from outside. Only terminate() stops a spinning worker, and it stops it without unwinding.
  • NOT guaranteed: any speedup. If the job is I/O-bound, moving it to a worker adds copies and scheduling and gains nothing.
Where contention appears
  • Worker startup contends for CPU with the main loop; spawning a pool during a traffic spike makes the spike worse.
  • Structured clone is a serial CPU cost on the sender's loop — the contention is with every other handler on that loop.
  • Runnable threads beyond available cores produce context-switch churn and cache pollution; wall-clock per job rises while throughput does not.
  • The pool queue is the real backpressure point; its depth is the number you alert on, and its bound is what keeps the process alive.
  • SharedArrayBuffer counters on adjacent cache lines produce coherence traffic between cores — False Sharing: Different Variables, Same Cache Line, which turns a "shared, therefore fast" design into the slow one.
How it fails
  • Data race on a SharedArrayBuffer accessed without Atomics: lost updates, torn logical values across multiple fields.
  • Detached-buffer error: reading a transferred ArrayBuffer on the sender after handing it over.
  • Serialisation failure: postMessage throws DataCloneError on functions, class instances with prototypes, and host objects.
  • Worker death without job rejection: the pool loses a slot and the caller hangs until timeout.
  • Unbounded job queue under burst: memory growth, then every queued job times out having achieved nothing.
  • Oversubscription: more busy workers than cores, so latency degrades for everyone with no throughput gain.
  • Deadlock via Atomics.wait on the main thread — the loop stops, and the worker that would notify it is queued behind the loop.
When it helps
  • Genuine CPU work on the request path: rendering, image and video processing, compression, cryptographic hashing, large parses and transforms.
  • Work whose input and output are buffers, so transfer makes the handoff free.
  • Isolating a job that may crash or spin, since terminate() kills a worker without taking the process down.
  • Multi-core throughput in a single process, when running multiple processes is undesirable because of memory or connection-pool count.
When it hurts
  • I/O-bound work — the loop already handled that, and you have added copies and thread scheduling for nothing.
  • Small jobs, where clone plus dispatch plus response exceeds the work; the crossover is real and you find it by measuring.
  • Large object graphs, where structured clone dominates and there is no buffer to transfer.
  • Designs that reach for SharedArrayBuffer to avoid copies and thereby import the entire threaded-memory-model problem into a codebase built on the assumption it did not have one.
How you would know
  • Main-loop lag before and after the offload — if it did not fall, the clone cost ate the win. See Event-Loop Lag: One Callback, Everybody Waits.
  • Job wall-clock split into queue time, clone time, compute time and response time. The first two are the ones that surprise people.
  • Pool queue depth and rejection count; depth trending up means the pool is undersized or the jobs got heavier.
  • Process RSS per worker, since each isolate carries its own heap and a pool of 32 is a memory decision as much as a CPU one.
  • For the shared-memory case: an assertion that the counter equals the expected total after a stress run. A data race will not show up in latency or in errors — only in accounting.
Complexity it introduces
  • You now maintain two runtimes in one process, with separate module graphs, separate error handling and separate lifecycle.
  • Every message payload becomes a serialisation contract; adding a class instance to a job object breaks it at runtime, not at compile time.
  • Pool lifecycle — spawn, respawn on death, drain on shutdown, timeout and terminate — is a few hundred lines you must get right or the pool degrades silently.
  • Debugging spans threads: a stack trace from a worker has no main-thread frames, and profilers must be pointed at each isolate.
  • If SharedArrayBuffer enters the design, the team now needs the memory-model reasoning the single-loop model had spared them.
Simpler alternatives
  • Split the work into chunks and yield to the loop between them (setImmediate), when the job is a loop over items and latency for others matters more than the job's own duration.
  • A separate process — child_process or a queue-backed worker service. Higher memory, slower handoff, far better isolation, and it scales past one machine. Usually the right answer for reports.
  • A native addon that releases the isolate lock and does the work in C++ on its own threads — no clone cost at all, at the price of a build toolchain.
  • Do it out of band: enqueue the job, return 202, and let the client poll. If the work is 900 ms, the honest answer is often that it should not be on the request path at all — see async-job-pattern.

CPU parallelism simulator

Scaling 100 CPU tasks
100 independent tasks of 20 ms each. The tasks do not share anything — the job around them does.
SIMULATEDA composed model, not a benchmark.

Amdahl’s term, a synchronisation term, an oversubscription term and a bandwidth ceiling, each one a knob you can switch off. Real curves have more causes than four and are rarely this smooth. There is no ideal core count to read off this chart.

Cores
The serial part is the split and the merge, not the tasks. The sync term is what each worker pays to coordinate with the others. The ceiling is where the memory system stops feeding cores, whatever the core count says.
1 workerdashed = linear speedup16 workers · max 16.0×
ideal
4.0× · 500 ms
Amdahl only
3.48×
modelled
3.28× · 610 ms
efficiency
82%
Where the 4× went
delivered3.3×
lost to the serial part0.5×
lost to sync, switching and bandwidth0.2×
At 4 cores the model delivers 3.28× of a possible 4×, so 109 ms of the run is overhead rather than work. The serial part dominates. Splitting the input, merging the results and the one section that cannot overlap now cost more than the cores save — and no core count fixes that term.
One hundred tasks that share nothing still do not scale linearly, because the job that owns them is not the tasks. Read the gap between the dashed line and the curve as the price of coordination — and note it is charged even when every task is independent.
limited by: serialSIMULATED

Thread pool: utilization and queue

Thread pool — utilization, queue depth, and the point where the numbers stop existing
A pool of workers serving a stream of requests. Sakasegawa's M/M/c approximation, with the honest answer above the knee.
utilization ρ75% · capacity 160/s
pool workers busy6 of 8
utilization
75.0%
mean queue depth
1.2
mean wait for a worker
9.8 ms
mean in flight (L = λW)
7.2
capacity  = workers / service = 8 / 50 ms = 160.0 req/s
ρ         = arrivals / capacity = 120 / 160.0 = 0.750
Little    L = λ × W  →  0.120/ms × 59.8 ms = 7.2 in flight
engine    status = healthy
ρ = 75.0%, mean wait 9.8 ms on top of 50 ms of service. Queueing is non-linear: the wait term carries 1/(1 − ρ), so the step from 80% to 90% utilization costs more than everything before it. Little's Law ties the three numbers together — L = λ × W, so 7.2 requests are inside the system at any moment. That is the number to size the pool against, and it is measurable in production; the pool size is not something to derive from a formula about core counts. Push arrivals past 160/s and watch the numbers refuse to answer.
SIMULATEDsmooth arrivals; real traffic is burstier and queues earlier

More workers than cores

More workers than cores
Four cores, purely CPU-bound tasks, no I/O to hide behind. Add workers and watch what the extra ones buy.
4 cores · 0 ms I/O
1 workerdashed = linear speedup64 workers · max 64.0×
Throughput relative to one worker, 1 → 64 workers. The dashed line is what workers would buy if a worker were a core.
throughput800/s · peak is 800/s at 4 workers
context-switch overhead per task0 · 0.00 ms of every 5 ms task, and it grows with every worker past 4
runnable per core
1.0
CPU utilisation
100.0%
vs. peak
at peak
4 workers on 4 cores: each one has a core to itself, so throughput rises roughly linearly. This is the only region where "add a thread" and "add capacity" mean the same thing. The honest form of the rule: for genuinely CPU-bound work with no waiting, more workers than cores adds overhead, latency variance and memory, and adds no throughput. That is *not* a formula for pool size — this workload has no I/O, no lock and no memory-bandwidth ceiling. Add any of those and the useful worker count moves, sometimes far above the core count. Size a pool from measurement of the real workload, not from a rule of thumb.
SIMULATEDContext switching modelled as a flat cost per switch. Real cost depends on cache and TLB footprint and is usually worse — and never better — than this.

What people believe, and what is true

Claim

Worker threads let me share objects between threads.

Reality

They let you *copy* or *move* them. The only genuinely shared thing is a SharedArrayBuffer, and it comes with a memory model attached.

Claim

postMessage is basically free.

Reality

Structured clone is proportional to the payload and is charged to the sending loop. A 6 MB message can block the main loop longer than the work you offloaded.

Claim

More workers means more throughput.

Reality

Past the number of cores the process can actually use, extra runnable workers add context switches and cache pressure. Throughput flattens and per-job latency rises.

Go deeper

Overview

A real thread with its own isolate and loop; you send it a copy of the work and it sends back a copy of the result.

Practical

Bounded pool, bounded queue, transfer buffers instead of cloning them, reject when the queue is full, and respawn workers that die.

Advanced

SharedArrayBuffer plus Atomics avoids the copy entirely and reintroduces data races, memory ordering and false sharing. Worth it for numeric buffers under measured copy pressure; rarely worth it otherwise.

Internals

Each worker is a V8 isolate on its own libuv loop inside the same process, so they share the address space but not the object graph. SharedArrayBuffer works precisely because the address space is shared — the isolate boundary is a language-level construct, not a memory-protection one.

Apply it