Skip to content
Dev Tools Intermediate Tutorial

Track Down Node.js Memory Leaks with Heap Snapshot Diffs

Attach Chrome DevTools to a running Express service, diff heap snapshots, and pin a real leak to one line.

Lenn Voss
Lenn Voss
Cloud & Infrastructure Writer · Sep 4, 2026 · 5 min read
Track Down Node.js Memory Leaks with Heap Snapshot Diffs

What you'll learn

You'll build a small Express service with a deliberate memory leak, attach Chrome DevTools to the running process, and use heap snapshot diffing to walk from "memory keeps growing" to the exact line of code holding the objects. Then you'll fix it and prove the fix with a third snapshot.

Prerequisites

  • Node.js 22 or 24 LTS. Verified against Node 24.20.0; everything here works on 18+, since the inspector protocol and flags haven't changed.
  • Express 5.2.1 (installed below).
  • Chrome, any recent version. The Memory panel steps match current stable.
  • autocannon 8.0.0 for load generation, run via npx so there's nothing to install globally.

macOS and Linux commands are identical. On Windows, run them in PowerShell and replace pkill in the troubleshooting section with Task Manager.

1. Create the leaky service

mkdir leak-demo && cd leak-demo
npm init -y
npm install express@5

Create server.js:

const express = require('express');
const app = express();

class RequestLog {
  constructor(req) {
    this.method = req.method;
    this.url = req.originalUrl;
    this.headers = { ...req.headers };
    this.timestamp = Date.now();
    this.payload = Buffer.alloc(10 * 1024); // simulate a captured body
  }
}

const recentRequests = [];

app.use((req, res, next) => {
  recentRequests.push(new RequestLog(req)); // never trimmed: the leak
  next();
});

app.get('/api/orders', (req, res) => {
  res.json({ orders: [], count: recentRequests.length });
});

app.listen(3000, () => console.log('listening on :3000'));

This is a stylized version of a leak that ships to production constantly: debug middleware that records every request into a module-level array and never evicts. Each entry retains about 10 KB, so the math gets ugly fast. In my test run, 76,000 requests pushed the process from ~60 MB to 880 MB of RSS.

One deliberate choice here: RequestLog is a named class. Named classes group under their own constructor in heap snapshots, which turns "something is growing" into "RequestLog is growing." Anonymous object literals all land in the same Object bucket and are much harder to spot.

2. Start it with the inspector attached

node --inspect server.js

Expected output:

Debugger listening on ws://127.0.0.1:9229/69152863-1198-46df-a54d-71970b9a5b9c
For help, see: https://nodejs.org/en/docs/inspector
listening on :3000

--inspect binds the inspector to 127.0.0.1:9229 by default, loopback only. Never bind it to 0.0.0.0 on a reachable host: anyone who can connect to that port can run arbitrary code in your process.

3. Connect Chrome DevTools

Open chrome://inspect in Chrome. Your process appears under Remote Target as server.js. Click its inspect link, and a dedicated DevTools window opens attached to the Node process. Switch to the Memory panel.

4. Take a baseline snapshot

In the Memory panel, select the Heap snapshot profiling type, click the Collect garbage button (the broom icon) so short-lived garbage doesn't pollute the baseline, then click Take snapshot. Snapshot 1 appears in the left sidebar with the total size of reachable objects under its name.

5. Apply load, then snapshot again

In a second terminal:

npx autocannon -a 2000 -c 10 http://localhost:3000/api/orders

-a 2000 sends exactly 2,000 requests and exits, which gives you a predictable delta to look for. When it finishes, go back to DevTools, click Collect garbage again, and take snapshot 2. The garbage-collect step matters: without it the diff fills up with request and socket objects that were about to be freed anyway.

6. Diff the snapshots

With snapshot 2 selected, change the view dropdown from Summary to Comparison and pick Snapshot 1 as the base. Sort by Size Delta, or type RequestLog into the Class filter field.

The RequestLog row shows a # Delta of +2,000 and a size delta around 20 MB, matching the load you just applied. That's the signature of a leak: object count that tracks request count instead of returning to baseline after GC. A healthy service shows deltas near zero for its own classes once garbage is collected.

7. Read the retainer chain and fix it

Click any RequestLog instance. The Retainers pane at the bottom shows what's keeping it alive: the instance is an element of an Array, retained by the recentRequests variable in server.js. That's your line number.

The fix is to bound the buffer. Replace the middleware with:

app.use((req, res, next) => {
  recentRequests.push(new RequestLog(req));
  if (recentRequests.length > 100) recentRequests.shift();
  next();
});

For production code you'd reach for a proper ring buffer or an LRU with TTL, but a hard cap is enough to stop unbounded growth. Restart the server after saving; the DevTools window reconnects when you reopen chrome://inspect.

Verify it works

Re-run the load, harder this time:

npx autocannon -a 20000 -c 10 http://localhost:3000/api/orders
curl http://localhost:3000/api/orders

Expected output from curl:

{"orders":[],"count":100}

The count stays pinned at 100 no matter how many requests you send. Repeat the snapshot-diff cycle: collect garbage, snapshot, load, collect garbage, snapshot, Comparison view. RequestLog now shows a # Delta of 0 (or +100 on the very first cycle, then flat). In my run, the fixed server sat at ~100 MB RSS after 20,000 requests, versus 880 MB before the fix.

Troubleshooting

Starting inspector on 127.0.0.1:9229 failed: address already in use Another Node process already owns the inspector port, usually a forgotten earlier run. Kill it (pkill -f "node --inspect") or pick a different port with node --inspect=127.0.0.1:9230 server.js, then click Configure in chrome://inspect and add 127.0.0.1:9230 to the target list.

WebSockets request was expected You opened http://localhost:9229 directly in a browser tab. Port 9229 speaks the DevTools WebSocket protocol, not HTML. Connect through chrome://inspect instead, or grab the devtoolsFrontendUrl from http://127.0.0.1:9229/json/list and paste it into the address bar.

Nothing shows under Remote Target Click Configure next to "Discover network targets" and confirm localhost:9229 is listed. Then check the process is actually running and printed the Debugger listening line; if the app crashed on startup, there's no target to discover.

The Comparison view only shows growth in (string), (system), and Object Your leaking objects are anonymous, so they're buried in generic buckets. Wrap the leaked data in a named class as in step 1, or sort by Size Delta and expand the Object row, then use the Retainers pane on the biggest entries to find the owning variable.

Next steps

For leaks you can't reproduce locally, capture snapshots from production without attaching DevTools: start the process with --heapsnapshot-signal=SIGUSR2 and send the signal to write a .heapsnapshot file, or call v8.writeHeapSnapshot() from a health endpoint. Load the file in the Memory panel with the record's load button and diff it the same way. When snapshot diffs aren't enough, the Memory panel's Allocation instrumentation on timeline mode shows exactly which call stacks allocated the surviving objects. The Chrome team's memory problems guide covers both in depth.

Sources & further reading

  1. Debugging Node.js — nodejs.org
  2. Record heap snapshots — developer.chrome.com
  3. V8 - Node.js API documentation — nodejs.org
  4. express - npm — npmjs.com
  5. autocannon - npm — npmjs.com
Lenn Voss
Written by
Lenn Voss · Cloud & Infrastructure Writer

Lenn writes about cloud platforms, Kubernetes internals, and the infrastructure decisions that quietly make or break engineering organizations. Based in Berlin's vibrant tech scene, they have a talent for turning dense platform-engineering topics into prose that people actually finish reading.

Discussion 0

Join the discussion

Sign in or create an account to comment and vote.

No comments yet

Be the first to weigh in.

Related Reading