Debug Node.js Memory Leaks with Chrome DevTools Heap Snapshots
Capture and diff heap snapshots to pinpoint exactly which objects are leaking in a running Node.js service.
What you'll build / learn
You'll attach Chrome DevTools to a running Node.js service, capture two heap snapshots, and diff them to identify exactly which objects — and which line of code — are leaking memory.
Prerequisites
- Node.js 18 or later — any current release works; this walkthrough was verified on Node 20.19 and applies unchanged to the 22 and 24 LTS lines.
- Google Chrome (or any Chromium browser — use
edge://inspecton Edge). Chrome DevTools ships with the browser; no install needed. - Any OS. One note for later: the
kill -USR1trick for attaching to an already-running process doesn't exist on Windows.
1. Create a leaky server
Save this as server.js. It simulates a classic real-world leak: a module-level "cache" that grows on every request and is never evicted.
const http = require('node:http');
const cache = [];
const server = http.createServer((req, res) => {
cache.push({ url: req.url, body: Buffer.alloc(100 * 1024).toString('base64') });
res.end(`handled ${cache.length} requests\n`);
});
server.listen(3000, () => console.log('listening on http://localhost:3000'));
2. Start it with the inspector enabled
node --inspect server.js
You'll see:
Debugger listening on ws://127.0.0.1:9229/c80a792d-5887-42bf-b084-414055b99f2f
For help, see: https://nodejs.org/en/docs/inspector
listening on http://localhost:3000
--inspect opens a debugging endpoint on 127.0.0.1:9229. For a service that's already running (and that you can't restart), send kill -USR1 <pid> instead — Node activates the same inspector on a live process.
3. Attach Chrome DevTools
- Open
chrome://inspectin Chrome. - Under Remote Target, find your
server.jsprocess and click inspect. (You can also click Open dedicated DevTools for Node — it auto-connects tolocalhost:9229.) - In the DevTools window, open the Memory panel.
4. Take a baseline snapshot
In the Memory panel, select Heap snapshot and click Take snapshot. This is your "before" picture — the total size of all reachable objects appears under the snapshot in the left sidebar.
5. Generate load, then snapshot again
In a terminal, hit the server 500 times:
for i in {1..500}; do curl -s localhost:3000 > /dev/null; done
Back in DevTools, click the record icon (Take heap snapshot) again to capture Snapshot 2.
6. Diff the snapshots
- With Snapshot 2 selected, change the view dropdown at the top of the panel from Summary to Comparison, comparing against Snapshot 1.
- Sort by the Size Delta column, descending.
- The top offender is
(string)— 500 new ~133 KB base64 strings that exist in Snapshot 2 but not Snapshot 1. The# Newand# Deltacolumns both read ~500, matching your 500 requests. That correlation between "operations performed" and "objects added" is the leak signature. - Expand
(string), click one entry, and read the Retainers pane at the bottom. The chain reads roughly:bodyin Object → element inArray→cacheinserver.js. That's your culprit variable, named, with the file it lives in.
One metric worth knowing while you're here: Shallow Size is the memory an object holds itself; Retained Size is what would be freed if the object became unreachable. Sort by Retained Size when you want to find the object keeping everything else alive (here, the cache array).
Verify it works
You've found the leak when all three of these line up:
- The Comparison view shows a constructor with
# Delta≈ your request count (here, ~500(string)and ~500Objectentries). - Size Delta for that constructor accounts for most of the heap growth (~65 MB here — 500 × 133 KB).
- The Retainers pane traces back to a variable you recognize —
cacheinserver.js.
To confirm the fix: cap or evict the cache (e.g. if (cache.length > 100) cache.shift();), restart, rerun the 500-request loop, and re-diff. Size Delta for (string) should now hover near zero.
Troubleshooting
Starting inspector on 127.0.0.1:9229 failed: address already in use— another process already owns the inspector port. Kill it, or pick a different port withnode --inspect=9230 server.jsand addlocalhost:9230under Configure… inchrome://inspect.WebSockets request was expected— you openedhttp://localhost:9229directly in a browser or with curl. That port speaks the DevTools protocol, not HTML; connect throughchrome://inspectinstead.- No Remote Target appears in
chrome://inspect— check Discover network targets is enabled and click Configure… to confirmlocalhost:9229is listed. If Node runs in Docker or on another machine,--inspectbinds to127.0.0.1and is unreachable; use--inspect=0.0.0.0:9229and publish the port — but only on trusted networks, since anyone who can reach the inspector can run arbitrary code in your process. - DevTools freezes or disconnects while snapshotting — snapshots serialize the whole heap, so multi-GB heaps take a while. Wait it out, or reproduce the leak with a smaller load so the heap stays snapshot-friendly.
Next steps
- Use Allocation instrumentation on timeline (same Memory panel) to see allocations live as they happen, instead of diffing after the fact.
- Snapshot production without DevTools attached: call
v8.writeHeapSnapshot()or start with--heapsnapshot-signal=SIGUSR2, then load the.heapsnapshotfile into the Memory panel offline. - Read the Chrome DevTools memory guide for the full tour of Containment and Statistics views.
Sources & further reading
- Debugging Node.js — nodejs.org
- Record heap snapshots — developer.chrome.com
- Fix memory problems — developer.chrome.com
- V8 - Node.js API documentation — nodejs.org
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.