What is a memory leak in a program? It is memory the program no longer needs but does not release for reuse. Manual code may fail to free a block of memory; JavaScript can keep an unwanted object alive through a reference, even with garbage collection.
High RAM use alone does not prove either problem.
The useful question is not just how much memory a process holds. Ask what it still holds, who owns it, and whether that owner should still be there. Those are claims you can test.
Below, a small Node.js example keeps the same objects in two places. Clearing the cache leaves them alive. Removing the second reference changes the result.
We ran the check on November 5, 2026, using Node.js 24.19.0 and V8 13.6.233.17-node.51.
Those are the tested versions, not a claim about the newest release. The example uses generated data in a local process. It does not measure a production leak, saved bytes or speed.
What is a memory leak in a managed runtime?
Memory allocation gives code space for data. Later, that space should be free for reuse when the data is no longer needed. How that works depends on the language and runtime.
In manual memory management, code must free each block it no longer needs. If it loses the last pointer first, it may have no way to reach or free that block. The missed release is the bug.
This guide does not give a C or C++ cleanup recipe.
In JavaScript, garbage collection (GC) follows references from roots to find objects it can still reach. It cannot tell whether your app still needs each object it can reach. A global list of old requests may keep working exactly as coded while keeping data long past its useful life.
MDN’s memory-management guide explains this reachability model. One object may hold another through a field, a list or a function’s scope. Removing one link is not enough if another path still leads to it.
A cycle is not automatically a leak in a modern JavaScript engine. Two objects can point to each other and still be collected when neither is reachable from a root. Looking only for cycles can send the search down the wrong path.
The same distinction applies to a cache. Keeping useful data for a set time is a choice. Keeping all old entries forever may be a bug.
State how long the data should live before calling it a leak.
| Observation | What it could mean | What to check next |
|---|---|---|
| Old objects survive completed work | Unwanted data still held | Follow their remaining owners |
| A cache grows, then stays bounded | Planned reuse | Check its limits and expiry rules |
| RSS grows while the heap stays steady | Memory outside that heap view, or fragmentation | Check the right process stats |
| Open resources run out | Missing resource cleanup | Check which code owns the open handles |
These cases can happen at the same time. A dashboard label does not tell them apart. Note what the work should keep before trying to shrink each number.
Treat memory leak symptoms as clues
Watch an app that grows each time it does the same work. Users may see slow responses, pauses or failures when memory pressure is high. None of those signs alone tells you which object or code path caused it.
Even a healthy app may grow while it starts up, loads data or fills a cache. A larger input may also need more working space.
Compare the same kind of work at the same stage. A fresh idle process and one serving a large job are not a fair match.
In Node, the pinned process API reports several values. RSS describes resident memory for the process, including JavaScript, native objects and code. The V8 heap counters cover just part of that total.
The external-memory counter covers C++ objects bound to JavaScript objects. The array-buffer counter includes Node Buffers and is already included in external memory. Adding all the fields together would double-count some memory.
The same docs note that glibc can cause RSS growth while the V8 heap size stays steady. Fragmentation of the space it manages can cause that pattern.
That is one cause to check, not a diagnosis. This example does not test glibc or measure RSS.
Use monitoring signals and actionable alerts to connect pressure with a user-facing symptom and a next step. An alert is not proof of a specific leak.

A full case does not reveal which contents are needed.
A memory leak example with two retaining collections
Our test creates 1,000 small objects with a distinct constructor name. A Map and an array both hold each object. We count those object nodes in four heap snapshots, rather than using the Map’s size as a substitute for a heap check.
Run this only in an empty test folder you can discard, with the tested Node version. The script creates local snapshot files, reads them and deletes each after counting. It makes no network calls and needs no package install.
Before running it, note the cost: a heap snapshot stops other work on the main thread and needs extra memory. The Node snapshot guide warns that capture can double heap size and crash the process. Do not copy this capture loop into a live request handler.
Save the following as retention.cjs:
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const v8 = require('node:v8');
class ResearchPayload { constructor(id) { this.id = id; this.data = 'generated-' + id; } }
const cache = new Map();
let secondRoot = [];
function populate(n) { for (let i=0;i<n;i++) { const item=new ResearchPayload(i); cache.set(i,item); secondRoot.push(item); } }
function snapshotCount(label) {
global.gc();
const path = v8.writeHeapSnapshot();
const snap = JSON.parse(fs.readFileSync(path,'utf8'));
const fields = snap.snapshot.meta.node_fields;
const stride=fields.length, ti=fields.indexOf('type'), ni=fields.indexOf('name');
const types=snap.snapshot.meta.node_types[ti];
let count=0;
for(let j=0;j<snap.nodes.length;j+=stride) {
if(types[snap.nodes[j+ti]]==='object' && snap.strings[snap.nodes[j+ni]]==='ResearchPayload') count++;
}
fs.unlinkSync(path);
return {label,count};
}
const results=[snapshotCount('baseline')];
populate(1000);results.push(snapshotCount('two roots retain objects'));
cache.clear();results.push(snapshotCount('map cleared; second root remains'));
secondRoot=[];results.push(snapshotCount('both roots released'));
assert.deepEqual(results.map(x=>x.count),[0,1000,1000,0]);
console.log(JSON.stringify({node:process.version,v8:process.versions.v8,fixture:'generated objects only; --expose-gc; no production data',results}));
Run it with the diagnostic collection flag:
node --expose-gc retention.cjs
The explicit GC call is a test aid exposed by Node. It is not a normal cleanup API in the JavaScript language.
The code reads the snapshot’s field metadata instead of assuming fixed column offsets. Even so, the format is undocumented and tied to V8. This is a test for one version, not a tool for all runtimes.
Our observed result was:
| Snapshot phase | ResearchPayload object nodes |
|---|---|
| Baseline, before population | 0 |
| Map and array both hold the objects | 1000 |
| Map cleared, array still holds them | 1000 |
| Both collections release their references | 0 |
The third row is the key test of an incomplete fix. If the test checked only whether the Map was empty, it would report cleanup while all 1,000 objects remained in the heap. The second owner shows why that fix falls short.
After both links were removed, the next snapshot held none of those tagged objects.
It does not say that RSS fell to its starting value. Nor does it promise when GC will reclaim data in a normal app.
The parser counts object nodes with the named constructor. It skips strings and code nodes with the same name.
These counts are not bytes. They cannot prove a leak rate.
Real code may hold the same value through several caches, callbacks or long-lived tasks. Replacing this one array is not a fix for all those paths.
How to detect a memory leak without changing the question
Start with a unit of work you can repeat: open and close a view, run a job, or process one batch. Write down what should remain afterward. For example, shared configuration may survive while per-job payloads should no longer be reachable.
Use made-up or scrubbed inputs in a test process first. Let startup and planned cache filling settle before taking the baseline. Keep the build, runtime, input size and workload the same while comparing results.
Take a snapshot, run the suspected workflow, then take another at the same logical stage. Repeat the work to see what grows.
Data loaded once is not the same as data kept after each cycle. Avoid other work that makes the change harder to explain.
In Chrome DevTools, load the older snapshot first and the newer one second. Select the newer snapshot’s Comparison view, then inspect growing groups and the references keeping them alive. The Node guide describes this workflow; the tool gives leads that you must trace back to code.
Ask which owner creates the reference and what should remove it. A completed task may still be reachable through a registry even though its screen or response has gone away. Follow the whole path before changing the apparent nearest owner.
Chrome’s memory tools guide gives a browser example: a detached DOM tree can still be held by JavaScript. Removing an element from the document and releasing every unwanted reference are separate steps. That example illustrates ownership; it does not make a browser DOM test part of our Node fixture.
Make one small change and repeat the same work. Check that the old objects stop building up and the feature still works. Deleting useful state until a graph looks smaller is not a valid fix.
Keep a short record of the build, workload, suspected owner, cleanup event and observed result. Include a case where cleanup must keep shared data. That helps reviewers see both the memory claim and the behavior that must be preserved.

One coat remains among the empty seats.
Capture snapshots with a clear safety boundary
A snapshot shows a heap in detail. It can contain application values that should not be shared.
Use generated data for this exercise and do not upload a production heap to a public issue or image service.
The V8 snapshot API documentation also limits a snapshot to one V8 isolate. A main-thread capture does not include the heaps of worker threads. Base each claim on the heap you did capture.
If an incident requires production evidence, plan for the extra load and risk of losing the process first. Choose a process whose loss the service can tolerate, control access to the files, and agree who will inspect them. Do not expose an unauthenticated capture endpoint.
A failed capture is not a clean bill of health, and a huge snapshot is not a cause by itself. If the growth is outside that heap, choose tools that can see it. More captures of the same heap will not reveal every kind of resource leak.
The HTTP 203 video, Debugging memory leaks, discusses web debugging with Jake and Surma. Its official episode page is dated August 3, 2021. Treat it as browser context, not as the source of our Node 24 results.

The glass encloses one plant, leaving two outside.
Fix ownership and then test the lifetime
Give long-lived lists and maps a clear purpose, limit and removal rule. Decide what happens when work ends, a task fails or a caller cancels. A cleanup path that runs only after success leaves an important case untested.
For a cache, an entry limit and an expiry policy answer different questions. The separate Java caching example explores those choices in JCS. It is not a drop-in Node fix, and an entry count does not cap bytes when values differ in size.
For callbacks or event subscriptions, define who owns the registration and when that owner must release it. Check the relevant API’s teardown behavior rather than assuming a closed screen or finished request removes every reference. Test that repeated setup and teardown does not keep old work alive.
Do not rely on weak references or finalization callbacks for prompt cleanup. MDN explains that collection timing cannot be relied on. Resources needing an explicit close or release still need a lifecycle suited to their API.
Changing runtimes also changes the tooling question. The Bun runtime guide discusses compatibility checks, but we have not run this fixture on Bun or in a browser. Verify the relevant heap tools and data format before transferring this parser.
Restarting can clear the current process’s state; a larger heap can change how much fits. Neither action proves that the retaining cause has been removed. Keep incident relief separate from a verified code fix.
Start with one repeatable workflow and one suspected owner. Preserve the negative control that catches an incomplete cleanup, then rerun after the change. The useful result shows which objects were freed, why, and how the app still does its job.
