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.
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
npxso 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
- Debugging Node.js — nodejs.org
- Record heap snapshots — developer.chrome.com
- V8 - Node.js API documentation — nodejs.org
- express - npm — npmjs.com
- autocannon - npm — npmjs.com
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
No comments yet
Be the first to weigh in.