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.
Step-by-Step Fix
- Measure a worker’s peak heap. Run the largest realistic task in a worker and log
v8.getHeapStatistics().used_heap_sizefrom inside it at the peak, plusprocess.memoryUsage().arrayBuffers. Verification: you have peak heap and external figures for one task. - Set
maxOldGenerationSizeMbabove 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. - 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.
- Handle termination. Listen for
errorwithERR_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. - 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.
- Log the limits at startup. Inside each worker,
require('node:worker_threads').resourceLimitsreports the effective values. Verification: logs show the intended caps in every environment.
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.
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.
Related
- Worker Threads Memory Isolation — the parent topic
- Sizing Worker Pools with Piscina — thread counts, queues and budgets
- Transferring ArrayBuffers vs Copying Between Workers — keeping external memory down
- How Node.js Sizes the Default Heap in Containers — the main thread’s side of the budget