Stop Node.js Memory Leak in Worker Threads

Avatar
C

Callum Davies

Senior Backend Engineer••
3Views
5mRead
0Reactions

TL;DR – Quick Fix

  • Problem: Node.js 20 process crashes with out‑of‑memory after hours of worker‑thread activity.

  • Root cause: Open MessagePort objects (often from MessageChannel) remain referenced, preventing garbage collection.

  • Immediate remedy: Close every transferred port (port.close()) and terminate the worker (worker.terminate()) in a finally‑style block.

  • Preventive measures:

    1. Run with a bounded heap (--max-old-space-size=512).

    2. Capture V8 heap snapshots to verify cleanup.

    3. 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

  1. Start the app with inspection and a limited heap:

    node --inspect --max-old-space-size=512 src/main.js
  2. Open chrome://inspect → Open dedicated DevTools for Node.

  3. After a few minutes, click Take heap snapshot.

  4. Filter the snapshot by MessagePort. You will see many retained MessagePort objects 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-size is 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 (256 MB)

~120 MB

256 MB

❌


Common Pitfalls & Troubleshooting

Symptom

Likely cause

Fix

RSS keeps growing despite port.close()

Worker retains a reference via a global variable or timer

Clear all references before calling worker.terminate()

worker.terminate() throws ERR_WORKER_NOT_RUNNING

terminate() invoked twice or before the worker has started

Guard with a flag or listen to worker.once('exit', …)

CI test flaps intermittently

Test duration too short for the leak to manifest

Increase the timeout or run the process longer in the test

memwatch-next reports false positives on large buffers

Buffer allocation spikes are mistaken for leaks

Call memwatch.next.gc() before measuring to force a GC


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.

What's your setup?What's your production setup for this scenario? Have you encountered similar architectural bottlenecks or edge-case gotchas?

Recommended Resources & Courses

Partner Links
Callum Davies
C
Written by•Senior Backend Engineer

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.

React to this article