{"schemaVersion":"1.0","type":"TechArticle","types":["Article","TechArticle"],"slug":"node-js-memory-leaks-how-to-find-them-fix-them-and-stop-writing-them-h1w8w","url":"https://zyvop.com/node-js-memory-leaks-how-to-find-them-fix-them-and-stop-writing-them-h1w8w","title":"Node.js Memory Leaks: How to Find Them, Fix Them, and Stop Writing Them","subtitle":"The six patterns, a production-safe profiling workflow, GC pressure vs true leaks, and a diagnostic decision tree that tells you exactly where to look","tldr":"Memory leaks in Node.js have a reputation for being mysterious. They are not. There is a small, well-defined set of causes responsible for nearly every producti...","keywords":["Node.js heap snapshot","Node.js GC pressure","event listener memory leak","Node.js performance 2026","Node.js stream memory leak","process.memoryUsage","WeakMap Node.js","Node.js profiling","lru-cache Node.js","Node.js memory leak"],"entities":["ZyVOP","Node.js heap snapshot","Node.js GC pressure","event listener memory leak","Node.js performance 2026","Node.js stream memory leak","process.memoryUsage","WeakMap Node.js","Node.js profiling","lru-cache Node.js","Node.js memory leak"],"keyTakeaways":["Memory leaks in Node.js have a reputation for being mysterious.","There is a small, well-defined set of causes responsible for nearly every production memory leak, and once you know what they look like, you start seeing them before they become incidents.","This guide covers how to detect a leak in production, the six patterns that cause almost all Node.js memory leaks, how to fix each one, and a profiling workflow you can run in under 20 minutes."],"headings":["How to Know You Have a Leak","The Six Causes of Almost Every Node.js Memory Leak","1. Unbounded In-Process Caches","2. Event Listeners Never Removed","3. Closures Holding Large Objects","4. Streams Not Properly Closed","5. Timers and Promises Never Resolved","6. Growing Request/Response Log Objects","Profiling a Leak: The 20-Minute Workflow","The Quick Reference","Reading memoryUsage() Correctly","GC Pressure vs a True Leak: How to Tell the Difference","Profiling in Production Safely with --inspect","WeakMap and WeakRef: When to Use Them","The Diagnostic Decision Tree","Things That Look Like Leaks But Are Not"],"outboundLinks":[],"contentText":"Memory leaks in Node.js have a reputation for being mysterious. They are not. There is a small, well-defined set of causes responsible for nearly every production memory leak, and once you know what they look like, you start seeing them before they become incidents. This guide covers how to detect a leak in production, the six patterns that cause almost all Node.js memory leaks, how to fix each one, and a profiling workflow you can run in under 20 minutes. How to Know You Have a Leak The first signal is almost always a memory graph that slopes up and never comes down. CPU stays normal, response times are fine, but RSS (Resident Set Size) climbs steadily over hours or days until the process runs out of memory and crashes — or the OOM killer terminates it. A healthy process has memory that grows under load and shrinks when traffic drops. A leaking process has memory that only goes up. Check current memory usage: # For a running container docker stats your-container-name # In the application process.memoryUsage() // Returns: { rss, heapTotal, heapUsed, external, arrayBuffers }Add this to your metrics endpoint and track it over time: import client from 'prom-client'; const heapUsed = new client.Gauge({ name: 'nodejs_heap_used_bytes', help: 'Node.js heap used in bytes', }); const rss = new client.Gauge({ name: 'nodejs_rss_bytes', help: 'Node.js RSS in bytes', }); setInterval(() =&gt; { const mem = process.memoryUsage(); heapUsed.set(mem.heapUsed); rss.set(mem.rss); }, 10_000);If heapUsed trends upward over hours with no corresponding increase in traffic, you have a leak. If RSS grows but heapUsed is stable, suspect native addons or buffers. The Six Causes of Almost Every Node.js Memory Leak 1. Unbounded In-Process Caches The most common leak. A developer adds a Map or plain object as a cache, things get stored in it, and nothing ever removes them. // LEAKS — grows forever, nothing evicted const cache = new Map&lt;string, UserProfile&gt;(); async function getUserProfile(userId: string) { if (cache.has(userId)) return cache.get(userId); const profile = await db.getUser(userId); cache.set(userId, profile); // Stored forever return profile; }The fix: use a bounded cache with an eviction policy. lru-cache is the standard choice: import { LRUCache } from 'lru-cache'; // Bounded by count and TTL const cache = new LRUCache&lt;string, UserProfile&gt;({ max: 1000, // Maximum 1000 entries ttl: 5 * 60 * 1000, // Entries expire after 5 minutes updateAgeOnGet: false, // Don't reset TTL on read }); async function getUserProfile(userId: string) { const cached = cache.get(userId); if (cached) return cached; const profile = await db.getUser(userId); cache.set(userId, profile); return profile; }For anything that needs to survive process restarts or share across instances, move the cache to Redis with a TTL on every key. 2. Event Listeners Never Removed Every time you call .on() or .addEventListener(), a reference is stored. If you add listeners inside a function that runs repeatedly without removing them, the count grows unboundedly. Node.js warns you about this — \"possible EventEmitter memory leak detected, 11 listeners added\" — but the warning is easy to ignore. // LEAKS — adds a new listener on every request app.get('/subscribe', (req, res) =&gt; { emitter.on('data', (data) =&gt; { // Never removed res.write(data); }); });Fix: always remove listeners when they are no longer needed. app.get('/subscribe', (req, res) =&gt; { const handler = (data: string) =&gt; res.write(data); emitter.on('data', handler); // Remove when the connection closes req.on('close', () =&gt; { emitter.off('data', handler); res.end(); }); });For one-time listeners, use .once() instead of .on() — it removes itself automatically after firing: emitter.once('connect', () =&gt; { // Fires once and self-removes console.log('Connected'); });Detect the problem early: // Warn loudly if any emitter accumulates too many listeners import { EventEmitter } from 'events'; EventEmitter.defaultMaxListeners = 15; // Default is 10 — raise if needed3. Closures Holding Large Objects A closure keeps a reference to everything in its lexical scope. If a closure is stored somewhere long-lived and captures a large object, that object cannot be garbage collected even if you think you are done with it. // LEAKS — the interval closure captures the entire request object app.post('/process', async (req, res) =&gt; { const requestData = req.body; // Could be megabytes // This interval holds a reference to requestData forever const interval = setInterval(() =&gt; { checkStatus(requestData.jobId); }, 1000); res.json({ started: true }); // interval never cleared — requestData never GC'd });Fix: extract only what you need, not the whole object: app.post('/process', async (req, res) =&gt; { const jobId = req.body.jobId; // Only capture the small value needed const interval = setInterval(() =&gt; { checkStatus(jobId); // No reference to the full request }, 1000); // Always clear timers when done setTimeout(() =&gt; clearInterval(interval), 60_000); res.json({ started: true }); });4. Streams Not Properly Closed If a readable stream is not consumed or destroyed on an error path, it stays open and holds its buffer in memory. // LEAKS — on error, the stream is never destroyed async function processFile(filePath: string) { const stream = fs.createReadStream(filePath); stream.on('data', (chunk) =&gt; processChunk(chunk)); stream.on('error', (err) =&gt; { console.error('Stream error', err); // Stream not destroyed — memory held }); }Fix: always destroy streams on error: async function processFile(filePath: string) { const stream = fs.createReadStream(filePath); stream.on('data', (chunk) =&gt; processChunk(chunk)); stream.on('error', (err) =&gt; { console.error('Stream error', err); stream.destroy(); // Release the buffer }); stream.on('close', () =&gt; { console.log('Stream closed cleanly'); }); } // Better: use pipeline which handles cleanup automatically import { pipeline } from 'stream/promises'; await pipeline( fs.createReadStream(filePath), async function* (source) { for await (const chunk of source) { yield processChunk(chunk); } }, outputStream ); // pipeline destroys all streams on error automatically5. Timers and Promises Never Resolved Uncleared intervals and timeouts hold references to their callbacks and the closures those callbacks capture. Promises that are created but never resolved or rejected accumulate in memory. // LEAKS — interval never cleared function startPolling(id: string) { setInterval(async () =&gt; { await checkStatus(id); }, 5000); // No way to stop this } // LEAKS — promise hangs forever if the event never fires function waitForEvent(): Promise&lt;string&gt; { return new Promise((resolve) =&gt; { emitter.on('complete', resolve); // No timeout — if 'complete' never fires, this hangs forever }); }Fix: always provide a cleanup path and a timeout: function startPolling(id: string): () =&gt; void { const interval = setInterval(async () =&gt; { await checkStatus(id); }, 5000); // Return a cleanup function return () =&gt; clearInterval(interval); } function waitForEvent(timeoutMs = 30_000): Promise&lt;string&gt; { return new Promise((resolve, reject) =&gt; { const timer = setTimeout(() =&gt; { emitter.off('complete', handler); reject(new Error('Event timeout')); }, timeoutMs); const handler = (value: string) =&gt; { clearTimeout(timer); resolve(value); }; emitter.once('complete', handler); }); }6. Growing Request/Response Log Objects If you store request or response objects in an array for debugging or auditing, and that array is never bounded, you accumulate the full HTTP context — headers, body, socket references — for every request that ever hit your app. // LEAKS — req objects hold socket references and body buffers const requestLog: express.Request[] = []; app.use((req, res, next) =&gt; { requestLog.push(req); // Grows forever, holds everything next(); });Fix: extract only what you need, bound the storage: interface RequestSummary { id: string; method: string; url: string; timestamp: Date; } const recentRequests: RequestSummary[] = []; const MAX_LOG_SIZE = 1000; app.use((req, res, next) =&gt; { recentRequests.push({ id: req.id, method: req.method, url: req.originalUrl, timestamp: new Date(), }); // Bounded — drop oldest entries when over limit if (recentRequests.length &gt; MAX_LOG_SIZE) { recentRequests.shift(); } next(); });Profiling a Leak: The 20-Minute Workflow When you suspect a leak but do not know which pattern is causing it: Step 1: Take a baseline heap snapshot import v8 from 'v8'; import fs from 'fs'; // Add a temporary debug endpoint app.get('/debug/heap-snapshot', (req, res) =&gt; { const snapshot = v8.writeHeapSnapshot(); res.json({ snapshot }); console.log('Heap snapshot written:', snapshot); });Step 2: Simulate load # Run 1000 requests through the suspected endpoint npx autocannon -c 10 -d 30 http://localhost:3000/suspect-endpointStep 3: Take a second snapshot, then a third Step 4: Open Chrome DevTools → Memory → Load snapshot → Compare The objects that grew between snapshot 1 and snapshot 3 are your leak candidates. Look for unexpected counts of request objects, anonymous functions, or application domain objects that should have been short-lived. The Quick Reference Leak pattern Fix ───────────────────── ────────────────────────────────────── Unbounded Map/Object lru-cache with max count and TTL Event listeners .off() in cleanup, .once() for one-time Closures capturing req Extract only primitive values Streams on error stream.destroy() or use pipeline() Uncleared timers Return cleanup function, always call it Storing req objects Extract summary, bound array sizeMost production Node.js memory leaks are one of these six. Find which one yours is, apply the fix, verify the memory graph flattens, and move on. Reading memoryUsage() Correctly process.memoryUsage() returns five numbers. Most developers look only at heapUsed and miss the others — which are often where the real signal is. const mem = process.memoryUsage(); // { // rss: 150_994_944, // Resident Set Size — total memory the process holds in RAM // heapTotal: 85_196_800, // Total heap allocated by V8 (including unused) // heapUsed: 62_345_216, // Heap actually in use by JS objects // external: 1_234_567, // Memory used by C++ objects bound to JS (Buffers, etc.) // arrayBuffers: 456_789, // Subset of external — ArrayBuffer and SharedArrayBuffer // }What each one tells you: heapUsed climbing — JavaScript objects are accumulating. Check the six leak patterns above. This is the most common signal. rss climbing but heapUsed stable — The V8 heap is healthy but something outside it is growing. Likely causes: Node.js Buffers not being released, a native addon with its own memory management, or the OS not reclaiming pages after a GC cycle (a known V8 behavior on Linux — not always a real leak). external climbing — Buffers and TypedArray instances backed by native memory are accumulating. Common cause: creating Buffer.alloc() or Buffer.from() in a tight loop without giving them a chance to be GC'd. Also common with streams that are not drained. heapTotal much larger than heapUsed — V8 allocated heap it is not using. This is usually fine — V8 is conservative about returning memory to the OS. Not a leak signal by itself. The ratio to watch: if heapUsed / heapTotal is consistently above 85%, you are close to triggering a GC thrash — the garbage collector runs constantly trying to keep up. This is GC pressure, not necessarily a leak, but it degrades performance significantly and is worth addressing. GC Pressure vs a True Leak: How to Tell the Difference These look similar on a memory graph but require different fixes. A true leak: memory climbs indefinitely and never drops, even after traffic stops. Heap snapshots show growing counts of specific object types across captures. GC pressure: memory climbs under load and drops — but slowly, and not as far as it started. The process is allocating objects faster than GC can collect them. Under sustained load it can look like a leak, but it resolves after traffic eases. How to distinguish: # Run your app under load for 2 minutes, then stop all traffic # Watch heapUsed every 5 seconds node -e \" setInterval(() =&gt; { const m = process.memoryUsage(); console.log(new Date().toISOString(), { heapUsed: Math.round(m.heapUsed / 1024 / 1024) + 'MB', rss: Math.round(m.rss / 1024 / 1024) + 'MB', }); }, 5000); \"If heapUsed drops back toward baseline after traffic stops — GC pressure. Fix: reduce allocation rate, pool objects, or increase --max-old-space-size as a short-term mitigation. If heapUsed stays elevated after traffic stops — true leak. Fix: heap snapshots and the six patterns above. You can also force a GC cycle manually to confirm: // Only works when Node.js is started with --expose-gc if (global.gc) { global.gc(); const after = process.memoryUsage().heapUsed; console.log('Heap after forced GC:', Math.round(after / 1024 / 1024) + 'MB'); }Start your process with node --expose-gc src/server.js. If heap drops significantly after a forced GC, V8 has the objects but has not gotten around to collecting them yet — GC pressure, not a structural leak. Profiling in Production Safely with --inspect The heap snapshot approach in the previous section used a debug endpoint that writes files locally. That works in development but is awkward in production containers. The safer production approach is --inspect with port forwarding. Step 1: Start your app with the inspector enabled but not exposed publicly: node --inspect=127.0.0.1:9229 src/server.js127.0.0.1 binds to localhost only — the inspector port is not accessible from outside the container. Never use --inspect=0.0.0.0 in production. Step 2: Forward the port from your VPS to your local machine: # From your local machine ssh -L 9229:127.0.0.1:9229 youruser@your-server-ip -NThis tunnels the remote inspector port to your local machine without exposing it to the internet. Step 3: Open Chrome DevTools: Navigate to chrome://inspect Click \"Configure\" and add localhost:9229 Your remote Node.js process appears under \"Remote Target\" Click \"inspect\" Step 4: Take heap snapshots under Memory tab: Take snapshot 1 (baseline) Let the suspected endpoint handle traffic for 5-10 minutes Take snapshot 2 Take snapshot 3 after another 5 minutes Step 5: Compare snapshots: In the Memory tab, select \"Comparison\" view between snapshot 1 and snapshot 3. Sort by # Delta (count difference). Objects with a large positive delta that you did not expect to grow are your leak candidates. Look for: (closure) — anonymous functions accumulated, usually from the event listener or timer patterns Array — unbounded array growth, usually the request log or cache pattern Your own class names — UserProfile, OrderData, etc. — objects that should be short-lived but are accumulating (string) — sometimes indicates accumulated log strings or cache keys The combination of the class name and its Retained Size tells you which objects are holding the most memory hostage. WeakMap and WeakRef: When to Use Them These exist specifically for cases where you need to associate data with an object but do not want to prevent that object from being garbage collected. WeakMap — a Map where keys are held weakly. If the key object is no longer referenced anywhere else, the entry is automatically removed by GC. The right use case: caching derived data about an object without preventing the object from being collected. // Bad: Map holds strong references to req objects — they cannot be GC'd const requestMetadata = new Map&lt;express.Request, RequestMeta&gt;(); // Good: WeakMap — when req is done and dereferenced, its metadata is collected too const requestMetadata = new WeakMap&lt;express.Request, RequestMeta&gt;(); app.use((req, res, next) =&gt; { requestMetadata.set(req, { startTime: Date.now(), traceId: randomUUID(), }); next(); });Limitations of WeakMap: Keys must be objects, not primitives Not iterable — you cannot loop over a WeakMap or get its size You cannot see what is in it (no .keys(), no .values()) This makes WeakMap unsuitable as a general cache. It is specifically for \"private data associated with an object, same lifetime as the object.\" WeakRef — a reference to an object that does not prevent GC. You can dereference it with .deref(), which returns the object if it still exists or undefined if it has been collected. class ImageProcessor { private cache = new Map&lt;string, WeakRef&lt;ProcessedImage&gt;&gt;(); async getProcessed(key: string): Promise&lt;ProcessedImage&gt; { const ref = this.cache.get(key); const cached = ref?.deref(); // undefined if GC'd if (cached) return cached; const processed = await this.processImage(key); this.cache.set(key, new WeakRef(processed)); return processed; } }WeakRef is useful when cached objects are large and you are comfortable letting GC evict them under memory pressure. The cache becomes best-effort rather than guaranteed — entries may disappear. For most caching scenarios lru-cache with a size limit is more predictable. Use WeakRef when the object lifecycle is genuinely managed elsewhere and you just want an opportunistic reference. The Diagnostic Decision Tree When your memory graph is trending up and you need to figure out why: heapUsed climbing? ├── YES → Take 3 heap snapshots 10 minutes apart │ │ │ ├── Specific object type growing? → Match to the six patterns │ │ ├── Closure/Function count growing → Event listener leak (Pattern 2) │ │ │ or Timer leak (Pattern 5) │ │ ├── Your domain objects growing → Cache leak (Pattern 1) │ │ │ or Log object leak (Pattern 6) │ │ └── Buffer/ArrayBuffer growing → Stream not closed (Pattern 4) │ │ │ └── No clear object type — random growth? │ → Check for 3rd party library leaks │ → Update dependencies, check their issues │ └── NO → Check external and rss │ ├── external climbing → Buffer leak │ → Look for Buffer.alloc() or Buffer.from() without release │ → Look for streams not drained/destroyed │ └── rss climbing, heap stable → Likely V8 memory management → Force GC and check if rss drops → If yes: not a true leak, V8 holding pages → If no: native addon, check with node --profThings That Look Like Leaks But Are Not V8 not returning memory to the OS immediately. After a GC cycle, V8 may hold onto freed heap pages rather than releasing them to the OS. RSS stays high even though heapUsed dropped. This is normal V8 behavior on Linux. You can encourage V8 to be more aggressive with --max-semi-space-size tuning or by running v8.setFlagsFromString('--optimize_for_size') on memory-constrained servers. The initial ramp-up. Node.js processes legitimately use more memory as they warm up — module cache, JIT compilation, connection pools filling. A new process will climb for the first few minutes of traffic and then plateau. Do not diagnose a leak until you have at least 30 minutes of steady traffic on a warmed-up process. High heapTotal vs heapUsed. V8 allocates heap in chunks. heapTotal is the total allocated; heapUsed is what is actually in use. A ratio of 60-70% is normal. V8 will reclaim unused heap pages over time. This is not a leak. Third-party connection pools. A database pool that maintains 10 open connections for the lifetime of the process holds memory for those connections continuously. This is expected behavior, not a leak. Compare your baseline memory (app started, no traffic) against your under-load memory. The difference is your working set. The baseline is expected overhead.","contentHash":"sha256:777178f8cbe784e676de73af1db2ee66cc855b9ac5df1d12b0645366bd163027","authorName":"ZyVOP","authorUrl":"https://zyvop.com/author/zyvop","authorSameAs":["https://zyvop.com","https://github.com/zyvop","https://x.com/zyvop1"],"category":null,"tags":["Node.js heap snapshot","Node.js GC pressure","event listener memory leak","Node.js performance 2026","Node.js stream memory leak","process.memoryUsage","WeakMap Node.js","Node.js profiling","lru-cache Node.js","Node.js memory leak"],"audience":"Senior software engineers, systems architects, and technical leads working with Node.js heap snapshot","tone":"Instructional, practical, code-first","readingTimeMinutes":13,"wordCount":2922,"faqs":null,"primaryTopic":"Node.js heap snapshot","publishedAt":"2026-05-26T06:00:00.929Z","updatedAt":"2026-08-29T03:25:00.145Z","canonicalUrl":"https://zyvop.com/node-js-memory-leaks-how-to-find-them-fix-them-and-stop-writing-them-h1w8w"}