Stop Node.js Memory Leak in Worker Threads
Callum Davies
TL;DR – Quick Fix
Problem: Node.js 20 process crashes with out‑of‑memory after hours of worker‑thread activity.
Root cause: Open
MessagePortobjects (often fromMessageChannel) remain referenced, preventing garbage collection.Immediate remedy: Close every transferred port (
port.close()) and terminate the worker (worker.terminate()) in afinally‑style block.Preventive measures:
Run with a bounded heap (
--max-old-space-size=512).Capture V8 heap snapshots to verify cleanup.
Add a CI test that watches for leak patterns (e.g.,
memwatch‑next).
Reproducing the Leak
Create the following files under a src/ directory.
src/main.js (leaky version)
// src/main.js – leaky example
const { Worker, MessageChannel } = require('worker_threads');
function startLeakyWorker() {
const worker = new Worker('./worker-leak.js');
const { port1, port2 } = new MessageChannel();
// Transfer port2 to the worker; port1 stays in the main thread.
worker.postMessage({ port: port2 }, [port2]);
// Simulate work for 10 seconds, then forget to close the channel.
setTimeout(() => {
// BUG: port1 is never closed → retained in V8 heap.
console.log('Leaky worker still alive');
}, 10_000);
}
// Spawn a new worker every 2 seconds.
setInterval(startLeakyWorker, 2_000);src/worker-leak.js (leaky version)
// src/worker-leak.js – leaky example
const { parentPort } = require('worker_threads');
parentPort.once('message', ({ port }) => {
// Perform a cheap computation and send a result back.
port.postMessage({ ok: true });
// BUG: the transferred port is never closed.
});Expected behavior: Running node src/main.js will steadily increase the RSS memory until the process is terminated by the OS.
Diagnosing with V8 Heap Snapshots
Start the app with inspection and a limited heap:
node --inspect --max-old-space-size=512 src/main.jsOpen
chrome://inspect→ Open dedicated DevTools for Node.After a few minutes, click Take heap snapshot.
Filter the snapshot by
MessagePort. You will see many retainedMessagePortobjects with[[Detached]]: false– evidence that the ports are still reachable.
Fixing the Leak
Close every transferred port and explicitly terminate the worker.
src/main.js (fixed)
// src/main.js – fixed version
const { Worker, MessageChannel } = require('worker_threads');
function startCleanWorker() {
const worker = new Worker('./worker-fixed.js');
const { port1, port2 } = new MessageChannel();
// Transfer ownership of port2 to the worker.
worker.postMessage({ port: port2 }, [port2]);
// Listen for the worker's reply, then clean up resources.
port1.once('message', () => {
port1.close(); // Release the main‑thread port.
worker.terminate(); // Shut down the thread.
});
}
setInterval(startCleanWorker, 2_000);src/worker-fixed.js
// src/worker-fixed.js – fixed version
const { parentPort } = require('worker_threads');
parentPort.once('message', ({ port }) => {
port.postMessage({ ok: true });
port.close(); // Release the transferred port.
});Result: RSS stabilises around the configured heap limit (≈120 MB in the example) and no longer climbs.
Configuring Memory Limits for Production
Running with a bounded heap prevents runaway growth from accidental leaks and makes OOM failures deterministic.
node --max-old-space-size=512 src/main.js--max-old-space-sizeis expressed in megabytes.When the limit is reached, Node aborts with a clear JavaScript heap out of memory error, surfacing the problem early in the deployment pipeline.
Automated Leak Detection in CI
memwatch‑next emits a leak event when the heap growth rate exceeds a threshold. Integrate it into a Jest (or Mocha) test that runs the worker for a short, reproducible period.
test/memory-leak.test.js
// test/memory-leak.test.js
const memwatch = require('memwatch-next');
const { spawn } = require('child_process');
let leakDetected = false;
memwatch.on('leak', info => {
leakDetected = true;
console.error('Memory leak detected:', info);
});
test('worker threads should not leak memory', done => {
const proc = spawn('node', ['src/main.js'], {
env: { NODE_OPTIONS: '--max-old-space-size=256' },
stdio: 'ignore'
});
// Give the process a few seconds to exhibit a leak, then terminate.
setTimeout(() => {
proc.kill();
expect(leakDetected).toBe(false);
done();
}, 5_000);
});Add this test to your CI workflow (GitHub Actions, GitLab CI, etc.). If a leak is reported, the build fails, preventing the defective code from reaching production.
Comparison Table
Scenario | RSS after 1 min | Heap limit | Leak detected |
|---|---|---|---|
Leaky implementation (no cleanup) | ~350 MB | 512 MB | ✅ |
Fixed implementation (ports closed) | ~120 MB | 512 MB | ❌ |
Fixed + tighter heap ( | ~120 MB | 256 MB | ❌ |
Common Pitfalls & Troubleshooting
Symptom | Likely cause | Fix |
|---|---|---|
RSS keeps growing despite | Worker retains a reference via a global variable or timer | Clear all references before calling |
|
| Guard with a flag or listen to |
CI test flaps intermittently | Test duration too short for the leak to manifest | Increase the timeout or run the process longer in the test |
| Buffer allocation spikes are mistaken for leaks | Call |
FAQ
Q: Does transferring a MessagePort automatically close the original port?
A: No. Transfer moves ownership of the object but the original MessagePort stays open until you explicitly call port.close().
Q: Can worker.unref() replace explicit cleanup?
A: unref() only prevents the event loop from keeping the process alive; it does not free retained objects. You still need to close ports and terminate the worker.
Q: Is setting --max-old-space-size sufficient for production safety?
A: It caps heap growth and forces a deterministic crash, but it does not fix the underlying leak. Combine the flag with proper resource cleanup and CI leak detection.
Related Engineering Guides
Connect with KoderSolution 🚀
Master modern software engineering, scalable architectures, and advanced backend systems.
Read more architectural deep-dives, systems tutorials, and engineering guides on kodersolution.com.
Subscribe to @kodersolution for concise, animated system design breakdowns and technical shorts.
Follow our official page for daily development tips, architecture discussions, and community updates.
Recommended Resources & Courses
Callum Davies
PHP & Laravel practitioner since v5.4. Big believer in thin controllers, explicit return types, and boring modular architecture that doesn't page you on weekends.