Worker Threads Memory Isolation

Node.js worker_threads give you real parallelism, but the property that makes them safe is also the property that trips up memory budgets: every worker runs in its own V8 Isolate with a fully separate heap. There is no shared garbage collector and no shared Old Space — so the memory you must plan for is the sum of every worker’s heap plus the main thread’s. This guide sits under Node.js Server-Side Memory Management and focuses on how isolation works, what crossing a thread boundary actually costs, and how to keep total resident memory bounded. Two decisions dominate that cost, each covered in a dedicated child guide: sharing memory with SharedArrayBuffer and transferring vs copying ArrayBuffers.

If you have ever set --max-old-space-size=2048 and still hit an out-of-memory kill in a container with 4 GB, worker isolation is usually why: that flag caps one isolate, not the process. The whole guide turns on that single fact — plan for the sum, measure per isolate, and pay attention to the byte buffers that cross the boundary, because those are the only things that can be shared rather than duplicated.

Conceptual Grounding

A V8 Isolate is a self-contained instance of the engine: its own heap spaces, its own GC threads, its own compiled-code cache. The main thread is one isolate; each Worker you spawn is another. Nothing on the JavaScript heap is shared between them by default. When you call port.postMessage(value), V8 serializes value using the structured clone algorithm, ships the bytes across a pipe, and deserializes a brand-new copy in the destination isolate. For a moment the same logical data exists twice, once in each heap, plus a transient serialization buffer.

There are exactly two escape hatches from copying. First, a transferable object — chiefly an ArrayBuffer — can be moved by handing the receiving isolate the pointer to its backing store and detaching it from the sender. No bytes are copied; the sender’s buffer becomes zero-length. Second, a SharedArrayBuffer allocates its backing store in process-wide native memory that both isolates map simultaneously, so neither copies and neither detaches. Both escape hatches concern the raw byte buffer only — the JS ArrayBuffer wrapper object is still tiny and cheap; it is the backing store, tracked as external memory in process.memoryUsage(), that matters. Understanding this maps directly onto the V8 heap layout: large buffers live outside the generational spaces, so they never benefit from Scavenge and must be reasoned about separately.

The mechanics of the copy matter because they explain the double-peak memory profile you see under load. Structured clone runs in two phases: the sender serializes the object graph into a flat, self-describing byte stream (V8’s serialization format), and the receiver deserializes that stream into fresh heap objects. During the overlap window three copies of the logical data coexist — the original in the sender’s heap, the serialization buffer on the MessagePort, and the reconstructed graph in the receiver’s heap. For a deeply nested object this also costs CPU proportional to the number of nodes, and because serialization happens synchronously on the sending thread, it stalls that thread’s event loop. This is the hidden tax that makes “just postMessage the whole result” scale badly: a job that returns a 40 MB analysis object will momentarily push ~120 MB of transient pressure through the two isolates and pause the sender for the serialization walk. Neither the sender’s nor the receiver’s garbage collector can help until the message settles, so the transient peak is what your container limit must actually tolerate.

It is worth being precise about what “isolation” buys you for garbage collection. Because each isolate marks and sweeps independently, a major GC pause in one worker does not stop the world in another — you have partitioned your GC pauses along with your heaps. That is a genuine latency win for a busy main thread offloading CPU-heavy work. The flip side is that you have also partitioned your visibility: a heap snapshot of the main thread shows nothing about a leaking worker, and process.memoryUsage() reports process-wide RSS but attributes none of it to a specific isolate. Diagnosis therefore has to be per-isolate, which the workflow below makes explicit.

Worker Thread Memory Isolation and Cross-Thread Data Paths Two boxes represent the main-thread isolate and a worker isolate, each with its own heap. Three labelled paths connect them: structured clone which copies, transferable ArrayBuffer which detaches and moves the pointer, and SharedArrayBuffer whose backing store sits in shared native memory mapped by both. Main-thread Isolate Own heap + own GC --max-old-space-size New / Old / Code space Never sees the worker's heap Worker Isolate Separate heap + GC resourceLimits cap New / Old / Code space Its OOM does not crash the main heap postMessage: structured clone COPIES — data exists in both heaps transferList: ArrayBuffer transfer MOVES pointer — sender detached to 0 Shared Native Memory SharedArrayBuffer backing store Mapped by BOTH isolates — never copied Counts once as external memory Coordinate with Atomics to avoid races

Diagnostic Workflow

Follow these steps to attribute memory correctly across threads and prove that a fix removed a copy rather than hid it.

Step 1 — Cap every worker with resourceLimits and confirm the aggregate budget

Action: construct each Worker with a resourceLimits object. The relevant field for heap growth is maxOldGenerationSizeMb. Compute your budget as mainThreadCap + (workerCount * perWorkerCap) and confirm it fits under the container limit (for a 4 GB container, leave ~512 MB headroom for native, stack, and buffers).

Expected output: exceeding maxOldGenerationSizeMb inside a worker raises ERR_WORKER_OUT_OF_MEMORY on the parent’s error event rather than an abrupt process abort.

Step 2 — Measure each worker’s live heap in isolation

CLI: node --expose-gc app.js

API: inside each worker call global.gc() then v8.getHeapStatistics() and post used_heap_size back to the parent. Sum across workers plus the main thread to get the true process live set — process.memoryUsage().heapUsed on the main thread alone is blind to worker heaps.

Expected metric: the summed used_heap_size should track process.memoryUsage().rss minus external and native overhead within roughly 10–15%.

Step 3 — Find the payloads crossing the boundary

DevTools path: launch with node --inspect app.js, open chrome://inspect, and note that each worker appears as its own inspection target. Select a worker target, then DevTools → Memory → Allocation instrumentation on timeline, and trigger a postMessage. A large spike on message receipt is a structured-clone copy landing in the worker heap.

Expected metric: a clone of an N-byte object shows an allocation burst of ~N bytes in the receiving target’s timeline.

Step 4 — Switch large binary payloads to transfer or share, then verify

Action: move the payload into an ArrayBuffer and pass it in the transferList (second argument to postMessage), or allocate it as a SharedArrayBuffer.

Expected output: after a transfer, the sender’s arrayBuffer.byteLength reads 0 (detached), and process.memoryUsage().external does not double when the message is sent — proving no second backing store was allocated. This is the definitive test that the copy is gone.

Step 5 — Recycle workers before their heap normalises upward

Action: long-lived workers accumulate a small amount of retained state per job — cached compiled regexes, interned strings, memoised lookups — that never fully returns to baseline. Track each worker’s post-GC used_heap_size across jobs; when it drifts above a threshold, drain the worker and replace it.

Expected metric: a healthy worker’s post-global.gc() used_heap_size returns within ±5% of its first-job baseline. A steady upward drift of even 1–2 MB per job compounds across thousands of jobs into an eventual resourceLimits breach, so recycling after N jobs keeps the aggregate flat. Confirm by plotting the reported usedMb values over time — a flat line means isolation plus recycling is bounding memory correctly.

Code Patterns & Signatures

Capping a worker’s heap so one runaway job cannot exhaust the whole container.

const { Worker } = require('node:worker_threads');

// Each worker gets its own isolate; the cap applies per worker
const worker = new Worker('./job.js', {
  resourceLimits: {
    maxOldGenerationSizeMb: 256, // Old Space cap for THIS isolate
    maxYoungGenerationSizeMb: 32, // New Space (Scavenge) budget
    codeRangeSizeMb: 16,          // JIT code cap
  },
});

// A heap-limit breach surfaces here, not as a hard crash
worker.on('error', (err) => {
  // err.code === 'ERR_WORKER_OUT_OF_MEMORY' on OOM
  console.error('worker died, restarting:', err.code);
  // ... spawn a replacement worker here ...
});

Reporting each worker’s real heap usage so you can sum the true process live set.

// Runs INSIDE the worker (job.js)
const v8 = require('node:v8');
const { parentPort } = require('node:worker_threads');

parentPort.on('message', (msg) => {
  if (msg === 'report-heap') {
    // Force pending GC so the number reflects live objects only
    if (global.gc) global.gc();
    const s = v8.getHeapStatistics();
    // used_heap_size is in bytes; convert to MB for the parent
    parentPort.postMessage({
      type: 'heap',
      usedMb: (s.used_heap_size / 1024 / 1024).toFixed(2),
    });
  }
});

Transferring a large buffer by pointer instead of copying it — the sender ends up detached.

const { Worker } = require('node:worker_threads');
const worker = new Worker('./consumer.js');

// Allocate a 50 MB buffer of binary data on the main heap
const buf = new ArrayBuffer(50 * 1024 * 1024);

// Second arg is the transferList: move buf, do not clone
worker.postMessage({ payload: buf }, [buf]);

// Ownership has moved; the sender's view is now detached
console.log(buf.byteLength); // 0  -> proof no copy remained here

Sharing one backing store across threads for concurrent access, coordinated with Atomics.

const { Worker } = require('node:worker_threads');

// SharedArrayBuffer lives in native memory both isolates map
const shared = new SharedArrayBuffer(1024);
const view = new Int32Array(shared);

const worker = new Worker('./writer.js');
// The SAB is NOT cloned and NOT detached — both threads see it
worker.postMessage({ shared });

// Read a slot the worker writes; Atomics avoids a data race
Atomics.wait(view, 0, 0);           // block until slot 0 != 0
console.log('got:', Atomics.load(view, 0)); // fresh value

Symptom-to-Fix Reference

Symptom Root Cause Immediate Action Measurable Impact
Container OOM-killed despite --max-old-space-size set Flag caps one isolate; sum of worker heaps exceeds limit Add resourceLimits.maxOldGenerationSizeMb per worker; size the pool to fit Aggregate heap stays under container cap; no OOM kills
postMessage blocks sender for tens of ms Structured clone walks a large object graph Move binary payload to ArrayBuffer and pass in transferList Send time drops to sub-ms; O(1) pointer move
external memory doubles on each message Backing store copied per clone Transfer the buffer or use SharedArrayBuffer external stays flat across sends
One worker crash takes down whole service Uncaught worker OOM aborts process Handle error event; restart worker in place Process survives; only the job restarts
Worker heap grows unbounded per request Per-request state retained in worker global scope Reset state or recycle worker after N jobs Worker used_heap_size plateaus
SharedArrayBuffer reads return stale bytes Missing memory-ordering coordination Use Atomics.load / Atomics.wait on the shared view Reads observe latest writes deterministically
RSS far above summed heap totals Detached buffers not yet GC’d; native pool growth Null out transferred views; audit native addons rss minus summed heap gap narrows

Edge Cases & Gotchas

The main-thread flag does not cap workers

--max-old-space-size applies only to the isolate it launches — the main thread. Workers default to their own generous Old Space limit unless you pass resourceLimits. In a memory-limited container you must set a per-worker cap explicitly, or the process total can quietly exceed the flag you thought was protecting you. Related out-of-heap symptoms are covered in memory limits and out-of-heap errors in Node.js.

A transferred buffer is unusable in the sender

After you list an ArrayBuffer in transferList, it is detached: byteLength becomes 0 and any typed-array view over it throws on access. Code that logs or reuses the buffer after postMessage will fail at runtime. Fix: treat transfer as a hand-off — never touch the buffer again in the sender, and if you need it on both sides, use a SharedArrayBuffer instead.

SharedArrayBuffer needs cross-origin isolation in the browser, but not in Node

In Node.js SharedArrayBuffer is always available, but the same code shipped to a browser requires the Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy headers. If you share isomorphic worker code between server and client, guard the browser path so a missing header does not throw a ReferenceError at module load.

Structured clone silently drops functions and class identity

The clone algorithm cannot serialize functions, DOM nodes, or class prototypes — it throws a DataCloneError for functions and flattens class instances to plain objects. A worker receiving a “class instance” gets its data but loses methods and instanceof identity. Fix: send plain data and reconstruct behaviour in the worker, or pass an identifier the worker uses to look up its own local instance.

Terminating a worker frees its heap, but not instantly

Calling worker.terminate() schedules isolate teardown; the memory is returned to the OS asynchronously, and a rapid spawn-terminate loop can hold several dead isolates’ memory transiently. Fix: pool and reuse workers rather than spawning one per task, and await the terminate() promise before spawning a replacement so teardown completes.

Frequently Asked Questions

Do worker_threads share a V8 heap with the main thread?

No. Each Worker runs in its own V8 Isolate with a completely separate heap, garbage collector, and --max-old-space-size budget. The only memory they can share is a SharedArrayBuffer, whose backing store lives in process-wide native memory outside any single isolate’s heap. Everything else sent over postMessage is either structured-cloned (copied) or transferred (ownership moved). This is why you must budget total memory as the sum of every isolate, not the value of a single flag.

How do I limit the memory a single worker thread can use?

Pass a resourceLimits object to the Worker constructor with maxOldGenerationSizeMb, maxYoungGenerationSizeMb, and codeRangeSizeMb. When the worker exceeds maxOldGenerationSizeMb its isolate throws an out-of-memory error and emits an error event on the parent (code ERR_WORKER_OUT_OF_MEMORY), which you can catch to restart just that worker rather than crashing the whole process. Size the caps so the pool total plus the main thread fits under your container limit with headroom for native memory.

What is the memory cost of postMessage structured cloning?

Structured clone allocates a full duplicate of the payload in the receiving worker’s heap, so a 50 MB object briefly occupies 100 MB across the two isolates plus a serialization buffer in between. The clone also blocks the sending thread while it walks the object graph, adding tens of milliseconds for large graphs. For large binary payloads, use the transferList argument to move an ArrayBuffer by pointer instead, which is O(1) and allocates nothing on the receiving side.

When should I use SharedArrayBuffer instead of transferring an ArrayBuffer?

Transfer an ArrayBuffer when only one thread needs the data at a time and ownership can move hand-to-hand — after transfer the sender is detached and cannot race the receiver. Use a SharedArrayBuffer when multiple threads must read or write the same bytes concurrently, such as a shared frame buffer or a lock-free ring queue. A SharedArrayBuffer is never copied and never detached, but you must coordinate access with Atomics.load, Atomics.store, and Atomics.wait to avoid data races and stale reads.