How to Find and Fix JavaScript Memory Leaks
Learn to diagnose retained memory in browser pages and Node.js, trace it to its owner, fix the retaining path, and verify the repair with heap snapshots.
A JavaScript memory leak occurs when an object remains reachable even though the application no longer needs it. To find one, reproduce the same action repeatedly, compare heap snapshots or allocation profiles, and follow the retaining path back to the code that owns the reference. Fix that ownership or cleanup problem, then repeat the original workload and profile again.
A rising memory graph is a reason to investigate, not proof of a leak. Allocation churn, memory bloat, and frequent garbage collection can look different. There is no universal memory threshold that identifies a leak across devices and runtimes. [Chrome: Fix memory problems]
1. What counts as a JavaScript memory leak?
JavaScript engines reclaim objects that are no longer reachable from program roots. A reference from a global, long-lived cache, event listener, closure, or other live object can keep an object reachable after the feature that used it has ended. The garbage collector cannot infer that your application considers the object obsolete.
Cycles alone are not a leak diagnosis: modern JavaScript engines use mark-and-sweep collection, which can reclaim a cycle when it is unreachable from roots. Look for an unwanted retaining path, not merely two objects that reference each other. [MDN: JavaScript memory management]
| Symptom | What it may indicate | Useful evidence |
|---|---|---|
| Objects accumulate after the same action and its reverse | Retained objects or a leak | Heap snapshot comparison and retainers |
| Memory stays high but does not keep growing | Memory bloat, a large working set, or expected caching | Repeated measurements and object ownership |
| Frequent pauses or sluggish interaction | Frequent garbage collection or high allocation churn | Allocation profile and performance timeline |
| Process memory rises while JavaScript heap looks stable | Memory outside the ordinary JavaScript heap may contribute | Runtime and operating system memory measurements |
A heap snapshot shows reachable JavaScript objects and related DOM nodes, not every part of process memory or every native-backed property. Treat heap measurements as one view of the problem. [Chrome: Record heap snapshots]
2. Make the memory symptom repeatable
- Write down the exact operation suspected of retaining memory: opening and closing a view, navigating, processing a batch, or handling a repeat request.
- Record runtime and version, the steps or workload, and whether memory remains high after the operation completes.
- Let the app reach a stable state, then record a baseline. Perform the operation and its reverse several times.
- Compare measurements or profiles at equivalent points in the lifecycle. Look for object types or retained sizes that increase across cycles.
- Repeat in a stable environment and reduce unrelated activity. A single high reading does not establish a leak.
Warm-up matters: startup and first-use allocations can obscure a leak. In a service, let it initialize before running the suspect workload. In a page, allow initial loading and background activity to settle before capturing the baseline.
3. Find browser memory leaks with Chrome DevTools
Choose a profile for the question
- Heap snapshot: inspect reachable objects and DOM nodes at a point in time. Summary groups by constructor or source; Comparison shows differences between snapshots; Containment helps inspect object structure and references.
- Allocation instrumentation on timeline: record allocations over time and investigate objects allocated in an interval that remain alive at its end.
- Allocation sampling: estimate allocation volume by JavaScript stack with lower profiling overhead than detailed timeline instrumentation.
- Detached elements: focus the investigation on DOM elements removed from the document but still retained by JavaScript.
Chrome’s Memory panel provides these profiling approaches. A heap snapshot starts with garbage collection and shows objects reachable from the global object. [Chrome: Record heap snapshots]
Snapshot comparison workflow
- Open the page in Chrome DevTools and select Memory.
- Select Heap snapshot and capture a baseline after the page settles.
- Perform the suspect action and its reverse several times, such as opening and closing the same view.
- Capture a second snapshot. Select the newer snapshot and use the Comparison view against the baseline.
- Inspect object groups whose retained count or size grows. Select a suspicious object and follow its retainer chain toward the reference that keeps it alive.
- For DOM-related growth, search for detached nodes and trace each node to the JavaScript reference or component that owns it.
A detached node is a clue, not automatically the root cause. Find the JavaScript reference that keeps it reachable and identify why that reference outlives the view. Chrome documents detached DOM trees as a common leak pattern. [Chrome: Fix memory problems]
Read the snapshot carefully
- Shallow size is memory attributed to an object itself. Retained size estimates memory that could become collectible if that object were no longer retaining its dependents. Follow the retaining path before changing ownership.
- Closures can retain local variables accessible to nested functions. Inspect closure contexts when a callback remains reachable longer than expected.
- Objects evaluated or referenced in the DevTools console may remain retained by the console. Avoid using console-held objects as evidence without accounting for that possibility.
- Snapshots do not show all native-backed properties or all process memory. A stable JavaScript heap does not rule out memory use elsewhere.
4. Find Node.js memory leaks
For Node.js, reproduce the same workload after warm-up and compare heap snapshots. Node.js documents Inspector snapshots, the --heapsnapshot-signal option, and v8.writeHeapSnapshot(); availability depends on the Node.js version, so check the documentation for the deployed runtime. [Node.js: V8 API]
Capture a snapshot with the V8 API
This standalone CommonJS script takes a snapshot when sent SIGUSR2. Run it only in a safe diagnostic environment or a crash-tolerant process: snapshot creation is synchronous, blocks the event loop, and can require memory around twice the heap size.
// save as snapshot.js
const v8 = require('node:v8');
const process = require('node:process');
process.on('SIGUSR2', () => {
const filename = `heap-${Date.now()}.heapsnapshot`;
try {
const saved = v8.writeHeapSnapshot(filename);
console.log(`Heap snapshot written to ${saved}`);
} catch (error) {
console.error('Could not write heap snapshot:', error);
}
});
console.log(`Diagnostic process ${process.pid} ready; send SIGUSR2 to capture.`);
node snapshot.js
# In another terminal, replace PID with the printed process ID:
kill -USR2 PID
This signal handler is a local diagnostic example, not a secure production endpoint. If you expose any snapshot trigger through an application, restrict access. Node also supports a command-line signal route on versions that document it, for example node --heapsnapshot-signal=SIGUSR2 app.js; verify the flag and signal support on your Node.js release. [Node.js: Command-line options]
Compare snapshots in DevTools
- Start the service and let it finish bootstrapping.
- Run the suspect request or workload repeatedly under consistent conditions.
- Capture one snapshot, run more of the same workload, then capture another.
- Open the older snapshot in Chrome DevTools, then load the newer one and select Comparison.
- Investigate positive object deltas and follow retainers to the owning code. Repeat with unrelated traffic minimized where possible.
Node.js heap snapshots are specific to a V8 isolate. A main-thread snapshot does not include worker-thread heaps; investigate workers separately. Snapshot generation pauses main-thread work and may consume enough extra memory to crash the process, so avoid taking one on a process whose failure would compromise service availability. [Node.js: V8 API]
5. Fix the retaining path and verify the repair
Fix the owner identified in the retainer chain. The examples below are patterns to consider only when the profiler evidence points to that lifecycle or reference.
Remove references when a view ends
class Panel {
constructor(button, onClick) {
this.button = button;
this.onClick = onClick;
button.addEventListener('click', onClick);
}
destroy() {
this.button.removeEventListener('click', this.onClick);
this.button = null;
this.onClick = null;
}
}
Use the same listener function when removing it. If a long-lived object still holds the element, callback, or component after teardown, remove that reference as part of the lifecycle cleanup.
Clear timers and subscriptions at the right lifecycle boundary
class Poller {
constructor(refresh) {
this.refresh = refresh;
this.timer = setInterval(() => this.refresh(), 5000);
}
dispose() {
clearInterval(this.timer);
this.timer = null;
this.refresh = null;
}
}
Apply similar cleanup to subscriptions and callbacks when their owners are disposed. A timer or listener is not automatically a leak; confirm that its retained path persists beyond the feature’s intended lifetime.
Bound caches and reconsider closure captures
A globally reachable, unbounded collection can keep its entries alive. Delete entries when they are no longer useful or impose a limit appropriate to the application. If a closure retains a large context, restructure it so the callback captures only data it still needs. Verify the specific entries or context in the snapshot before changing the design.
A WeakMap can hold metadata keyed by objects without keeping a key alive solely through that association. Weak collections are not enumerable and have key constraints; they do not replace explicit cleanup when the program needs iteration or deterministic resource release. [MDN: JavaScript memory management]
Prove the fix
- Repeat the original action or workload under the same conditions.
- Take comparable profiles after warm-up and compare object counts, retained sizes, and retaining paths.
- Confirm the suspected object group no longer accumulates across cycles.
- Check that the feature still behaves correctly after teardown, including callbacks, navigation, and re-entry.
- Observe whether the original user-visible symptom improves over a representative period.
A temporary drop in memory is not enough to prove the repair. Raising a heap limit may postpone an out-of-memory failure, but it does not show that unwanted references were released.
6. Common errors and troubleshooting
| Problem | Likely cause | What to do |
|---|---|---|
| Heap usage rises during a single session | Normal allocation churn, startup work, caching, or a leak | Repeat the same lifecycle and compare snapshots after equivalent cycles. |
| Snapshot comparison shows many new objects | Expected workload differences, warm-up, or retained objects | Repeat after warm-up with a consistent workload; inspect retainers for persistent growth. |
| A detached DOM node appears | A JavaScript reference still holds a removed node | Follow the retainer to its owner and release the reference when that view ends. |
| Snapshot capture stalls or crashes Node | Capture pauses work and may need roughly twice the heap size | Use a safe instance with sufficient memory, reduce workload, or reproduce outside production. |
| Main-thread Node snapshot misses worker data | Snapshots cover a single V8 isolate | Capture and inspect each relevant worker’s isolate separately. |
| Heap appears stable but process memory grows | Memory may be outside the ordinary JavaScript heap | Use runtime and process-level measurements in addition to heap snapshots. |
| Memory drops after restarting but returns | Restart resets process state without fixing the retaining path | Reproduce without restarting between workload cycles and compare retained objects. |
| Increasing the heap limit delays failure | More capacity masks growth without releasing objects | Trace and fix the retaining path; treat a larger limit only as capacity management. |
7. Performance, reliability, and cost considerations
- Profiling overhead: allocation instrumentation can affect runtime behavior. Use sampling for a lighter allocation overview, then take focused snapshots for object ownership.
- Snapshot cost: snapshots take time and memory to create and parse. Node.js capture blocks the event loop; plan around that impact.
- Production safety: capture on a staging environment or crash-tolerant instance when possible. Protect any diagnostic trigger and account for service interruption risk.
- Reliable comparisons: use repeatable workloads, warm-up, equivalent capture points, and multiple cycles. Separate JavaScript heap evidence from overall process memory.
- Thresholds: no single heap number defines a leak for every device and workload. Trend, retained objects, user impact, and available memory all matter.
- Cost: the built-in DevTools and Node.js routes described here do not require a paid screenshot API. The practical costs are engineering time, capture overhead, storage for snapshot files, and possible interruption when profiling a live process.
8. Or skip the browser setup
For a website screenshot while diagnosing a page, ScreenshotNeo is a website screenshot API and MCP server. It does not profile JavaScript memory or identify leak retainers; use the workflows above for that. If you need a clean screenshot of the page as part of a reproducible visual record, one GET request can capture it. See the ScreenshotNeo API documentation.
curl -G "https://api.screenshotneo.com/v1/shot" \
-d access_key=YOUR_API_KEY \
--data-urlencode url=https://example.com \
-o shot.webp
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={"access_key": "YOUR_API_KEY", "url": "https://example.com"},
timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({
access_key: 'YOUR_API_KEY',
url: 'https://example.com'
});
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await import('node:fs/promises').then(fs => fs.writeFile('shot.webp', Buffer.from(await res.arrayBuffer())));
Cookie and consent banners, newsletter popups, and chat widgets are removed before capture. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed; response headers report the page verdict and billing status. An MCP server provides take_screenshot, get_page_info, and capture_pdf tools for AI agents. The free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000. Sign up for 1,000 free screenshots a month, no card required.
9. FAQ
Can JavaScript force garbage collection so I can check for a leak?
Do not rely on application code forcing garbage collection. Use the runtime’s profiling tools and compare reachable objects under repeatable conditions.
Does a memory leak always make the heap graph rise steadily?
No. A leak may be obscured by workload variation, and memory bloat or frequent collection can create different symptoms. Look for unwanted retained objects across equivalent cycles.
Should I use a WeakMap to fix every object retention problem?
No. Use it only when weak key ownership fits the data relationship. It cannot provide enumerable entries or deterministic resource cleanup.
Can I take a Node.js heap snapshot from a worker?
Snapshots are isolate-specific. Capture the worker’s heap separately when the suspected objects live there.


