Capping Worker Heap with resourceLimits

A worker thread processing one unusually large input grows until the whole container is OOM-killed, taking every other request with it — even though you set --max-old-space-size. That flag only applies to the main thread’s isolate. This guide from Worker Threads Memory Isolation, in Node.js Server-Side Memory Management, shows how to cap each worker with resourceLimits, what happens when a worker hits its cap, and how to size the caps so the sum fits your container.

Symptom Root Cause Immediate Action Measurable Impact
Container OOM-killed while main-thread heap looks fine Worker heaps are uncapped; only the main isolate honours --max-old-space-size Set resourceLimits.maxOldGenerationSizeMb on every Worker Worker fails alone instead of the whole pod
One large input crashes the service A single task grows one worker’s heap without bound Cap the worker; reject or split oversized inputs Failure contained to one task
Worker exits with ERR_WORKER_OUT_OF_MEMORY The cap is reached Handle the error event; retry smaller or fail the job Clear, attributable failure
RSS still above budget with caps set External memory (Buffers, ArrayBuffers) and native allocations are not counted Budget external memory separately; transfer instead of copying Accurate container budget
Frequent worker restarts under normal load Cap set below the real working set Measure peak task heap; set the cap above it with margin Restarts only on genuine outliers

Root Cause: Each Isolate Has Its Own Limit

Every Worker runs a separate V8 isolate with its own heap, as described in worker threads memory isolation. V8 sizes each isolate’s heap limit independently. The --max-old-space-size flag on the command line configures the main isolate; a worker created without options gets a limit derived from defaults, which on a large machine can be several gigabytes — far more than your container’s share for that worker.

resourceLimits is an option on the Worker constructor that configures the worker’s isolate directly. Its fields are maxOldGenerationSizeMb (the long-lived heap, where growth from large inputs accumulates), maxYoungGenerationSizeMb (the nursery for new objects), codeRangeSizeMb (space for compiled code) and stackSizeMb (the thread’s stack, 4 MB by default). When a worker’s heap reaches its limit, Node terminates that worker: it emits an error event with code ERR_WORKER_OUT_OF_MEMORY and exits. The main thread and other workers keep running.

Two limits of the mechanism matter for budgeting. First, resourceLimits caps the V8 heap, not the process: memory held by Buffers, ArrayBuffer backing stores and native libraries in the worker is outside it, as explained in RSS growing with a flat heap. Second, the caps are per worker, so the container budget is the main thread’s limit plus the worker count times each worker’s cap, plus external memory — the same arithmetic used in sizing worker pools with Piscina.

Which limit applies to which heap The main isolate honours the max-old-space-size flag. Workers created without options get their own default limit, which can be several gigabytes. A worker created with resourceLimits has an explicit old generation cap, and when it reaches it only that worker is terminated. Buffers, ArrayBuffer backing stores and native allocations sit outside every heap limit. Node.js process (container limit) main isolate --max-old-space-size applies here only worker A no options: default limit, can be GBs worker B no options: default limit, can be GBs worker C resourceLimits: maxOld 384 MB OOM ends C only Outside every heap limit: Buffers, ArrayBuffer backing stores, native library allocations

Step-by-Step Fix

  1. Measure a worker’s peak heap. Run the largest realistic task in a worker and log v8.getHeapStatistics().used_heap_size from inside it at the peak, plus process.memoryUsage().arrayBuffers. Verification: you have peak heap and external figures for one task.
  2. Set maxOldGenerationSizeMb above the peak. Add a margin of roughly 25–50% over the measured peak so normal tasks never hit the cap. Verification: a load test with realistic inputs causes no worker terminations.
  3. Check the sum fits the container. Main-thread limit + workers × cap + external budget + margin must stay below the container limit. Verification: the arithmetic is written down next to the configuration.
  4. Handle termination. Listen for error with ERR_WORKER_OUT_OF_MEMORY, reject the task’s promise, and replace the worker. Verification: an oversized test input produces a clean error response and the service keeps serving.
  5. Reject oversized input early. Where input size predicts memory, check it before dispatch, or split the work. Verification: most oversized inputs never reach a worker.
  6. Log the limits at startup. Inside each worker, require('node:worker_threads').resourceLimits reports the effective values. Verification: logs show the intended caps in every environment.
A worker reaching its cap Two normal tasks rise and fall well below the 384 megabyte cap. A third, oversized task climbs until it reaches the cap, at which point the worker is terminated with ERR_WORKER_OUT_OF_MEMORY, the task is rejected, and a fresh worker starts at its baseline. Without a cap, the same task would keep growing toward the container limit. cap normal tasks ERR_WORKER_OUT_OF_MEMORY task rejected uncapped: keeps growing new worker time

Command and Code Reference

Use case: a capped worker with clean failure handling.

// run-task.js — one worker per task shown for clarity; use a pool in production
const { Worker } = require('node:worker_threads');

function runTask(input) {
  return new Promise((resolve, reject) => {
    const worker = new Worker(require.resolve('./task-worker.js'), {
      workerData: input,
      resourceLimits: {
        maxOldGenerationSizeMb: 384,   // measured peak ~260 MB + margin
        maxYoungGenerationSizeMb: 32,  // nursery; rarely needs changing
        codeRangeSizeMb: 16,           // compiled code space
      },
    });
    worker.once('message', resolve);
    worker.once('error', (err) => {
      if (err.code === 'ERR_WORKER_OUT_OF_MEMORY') {
        // only this worker died; the process and other workers keep running
        return reject(Object.assign(new Error('input too large to process'), { status: 413 }));
      }
      reject(err);
    });
    worker.once('exit', (code) => { if (code !== 0) reject(new Error(`worker exited ${code}`)); });
  });
}

Use case: confirm the effective limits from inside the worker.

// task-worker.js
const { resourceLimits, parentPort, workerData } = require('node:worker_threads');
const v8 = require('node:v8');

// log once at startup: the caps this isolate actually received
console.log('worker limits', resourceLimits,
  'heap_size_limit MB', Math.round(v8.getHeapStatistics().heap_size_limit / 1048576));

parentPort.postMessage(runJob(workerData)); // runJob() is your task function
# The main thread's own cap still comes from the flag
node --max-old-space-size=512 server.js

Verification and Regression Prevention

Verify three behaviours in staging with production container limits. Under normal traffic, no worker terminates. With a deliberately oversized input, exactly one worker terminates with ERR_WORKER_OUT_OF_MEMORY, the request gets a clear error, and other requests keep succeeding. Under a burst at full pool size, the container stays below its memory limit, which confirms that the caps and the external-memory budget add up.

Keep the measured peak and the chosen cap in a comment beside the configuration, and repeat the measurement when task code, dependencies or input sizes change. Alert on the rate of worker out-of-memory errors: a rising rate means either inputs are growing or the task has a leak, and both deserve investigation before the cap is raised.

Three staging behaviours to confirm In staging with production container limits, normal traffic causes no worker terminations; a deliberately oversized input terminates exactly one worker with ERR_WORKER_OUT_OF_MEMORY while other requests succeed; and a burst at full pool size keeps the container below its memory limit. Staging, production container limits Normal traffic No worker terminations; caps sit above real peaks. Oversized input One worker ends with ERR_WORKER_OUT_OF_MEMORY; others keep serving. Full-pool burst Container stays below its limit: caps + external add up.

Edge Cases and Gotchas

External memory is not capped

A worker decoding images into Buffers can exceed its share of the container while its V8 heap stays under the cap. Measure process.memoryUsage().arrayBuffers and RSS in the worker, and bound input sizes that drive external allocations.

Caps that are too tight cause thrashing

As the heap nears its limit, V8 runs more frequent full collections to avoid failing. A cap only slightly above the working set makes tasks slow long before they fail. Leave real headroom.

Pools may set their own defaults

Worker pool libraries pass resourceLimits through their own options. Check that the pool actually forwards your values by logging resourceLimits inside the worker.

Terminated workers lose in-flight state

Anything the worker held — partial results, open handles it owned — is gone after termination. Design tasks so a retry starts cleanly, and release shared resources from the main thread.

Frequently Asked Questions

Does --max-old-space-size apply to worker threads?

No. It configures the isolate that the command line starts, the main thread. Workers get their own limits, which you set with the resourceLimits option on the Worker constructor.

What happens when a worker exceeds maxOldGenerationSizeMb?

Node terminates that worker, emits an error event with code ERR_WORKER_OUT_OF_MEMORY, and the worker exits. The main thread and other workers continue running.

Which resourceLimits field matters most?

maxOldGenerationSizeMb, because large inputs and long-lived data accumulate in the old generation. The young generation and code range rarely need changing unless you have measured a specific reason.

Does resourceLimits limit Buffer memory?

No. Buffers and ArrayBuffer backing stores are external memory outside the V8 heap. Budget them separately and prefer transferring large buffers over copying them.

How do I choose the cap?

Measure the heap peak of the largest realistic task inside a worker and add 25–50% margin. Then confirm the total — main thread plus workers times cap plus external memory — fits the container.

Can I change the limits of a running worker?

No. Limits are fixed when the isolate is created. To apply new values, start new workers with the new options and retire the old ones.

Does a worker out-of-memory error crash the main thread?

No. The failing worker’s isolate is terminated and the main thread receives an error event on the Worker object. If nothing listens for that event, the error is treated like an unhandled error in the main thread, so always attach an error handler to every worker you create.

Should the cap be the same for every worker?

Only if the workers run the same kind of task. A pool that handles small JSON transformations and one that renders PDFs have very different peaks; give each pool its own measured cap rather than one value for all workers.