Node.js memory leak: find runaway listeners, caches, timers
Track down a Node.js memory leak by spotting runaway listeners, caches, and timers, then proving the fix with heap snapshots and repeatable tests.

What a memory leak looks like in a Node.js app
A Node.js memory leak is when your app keeps holding onto memory it no longer needs. The key detail is that memory usage rises over time and never fully comes back down, even after the work that caused the spike is finished.
In production, it often shows up as a slow creep: things feel fine for minutes or hours, then responses get slower, the process starts garbage collecting more often, and eventually the app restarts or crashes with an out-of-memory error. If you watch basic metrics, you may see RSS and heap usage trending upward across deploys, traffic cycles, or background jobs.
Common signs include:
- Memory climbs after each request or job run
- GC pauses get longer and more frequent
- Restarts become predictable (for example, every night after a batch runs)
- Containers hit memory limits even at normal traffic
- The app gets slower without any obvious CPU spike
Not everything that goes up is a leak. Some growth is normal: a cache warming up, a one-time compilation step, or a burst of traffic that settles once the load drops. Also, some “leaks” are actually intentional caching that is too big or has no limits. The difference is whether memory levels stabilize. A healthy app might jump up, then hover around a baseline. A leaking app keeps setting new baselines.
A simple example: a prototype adds an event listener on every request but never removes it. Each request “finishes,” but the listener keeps a reference to data from that request. Memory rises one small step at a time until it becomes a big problem.
To fix leaks without guessing, you need two things: a repeatable way to trigger the growth, and a way to prove the fix worked. That proof usually looks like memory leveling off and heap snapshots showing the “growing” objects are no longer growing.
Quick triage: confirm you are chasing the right problem
Before you hunt for a Node.js memory leak, make sure the app is actually leaking (memory rises and stays high), not just handling a temporary spike (memory rises, then drops after GC).
Start by watching a few numbers together. One metric alone can mislead you, especially in prototype code where “quick fixes” often hide the real cause.
- Process memory (RSS): total memory the OS says the process uses.
- Heap used: JavaScript objects managed by V8.
- Event loop lag: if it climbs, the app is getting stuck doing work or GC is thrashing.
- Request latency: leaks often show up as slower responses over time.
- Error rate / timeouts: a “leak” sometimes is retry storms or stuck background jobs.
Next, tell “heap growth” from “native memory” growth at a high level. If heap used keeps climbing across several minutes of steady load, you’re likely retaining JS objects (listeners, caches, closures, arrays, Maps). If RSS grows but heap used stays mostly flat, suspect memory outside the JS heap: large Buffers, streams not being closed, native add-ons, or even a logging/metrics library keeping data.
Prototype-to-production leaks usually come from the same few habits: global singletons that accumulate state, caches with no size limit, event listeners added per request, and timers started without a stop path. These show up a lot in apps generated or heavily modified by AI tools, where code “works once” but lacks cleanup.
Set a simple goal before touching code: reproduce the growth in minutes, not days. Pick one route or job that triggers the problem, apply steady load, and define “success” as: after the test starts, memory increases in a repeatable pattern (for example, +20 MB every 2 minutes). Once you have that, you can prove the fix later.
If you inherited a messy prototype and can’t even get stable measurements, that’s exactly the point where a quick audit (like what FixMyMess does) can identify the biggest retention risks before you rewrite half the app.
Make the leak reproducible before you touch the code
A Node.js memory leak is hard to fix if it only shows up “sometimes”. Before changing anything, turn the problem into a repeatable action that makes memory rise on command.
Start by picking one trigger that matches real usage. Good candidates are a single HTTP route, a background job tick, a WebSocket connect and disconnect cycle, or an import task. Choose the smallest action that still causes the climb.
Then repeat that trigger in a tight loop. You can do it manually (refresh the same page 50 times), but a tiny script is better because it removes human variation. The point is not load testing. The point is consistency.
Keep the variables stable while you reproduce it:
- Use the same user account and permissions each run
- Use the same input size (same payload, same number of records)
- Use the same environment (local or staging, not mixed)
- Restart the app between experiments so you start from a clean baseline
- Turn off unrelated traffic so background noise does not hide the pattern
Now define a clear pass/fail metric. A simple one is: after the loop stops and you force a garbage collection during testing, the heap should return close to baseline. If it keeps climbing run after run, you have a reliable repro.
Concrete example: say memory grows when users open a “live updates” screen. Make a loop that connects to the WebSocket, waits 3 seconds, then disconnects, and repeat it 200 times. If the heap grows with each connect cycle, you have narrowed the leak to listeners, timers, or per-connection caches.
This is also where prototype apps often break. If your code was generated by tools like Replit, v0, or Cursor and it is hard to make the leak repeatable, FixMyMess can run a quick audit to isolate the one action that reliably triggers the growth before you spend hours guessing.
Heap snapshots: the one tool that makes leaks visible
A heap snapshot is a picture of the objects your Node.js process is holding in memory at a moment in time. It shows how many objects exist, how big they are, and what is keeping them alive. When you are dealing with a Node.js memory leak, this is often the fastest way to stop guessing.
What it can tell you: which object types keep growing (arrays, Maps, strings, closures), and the paths that keep those objects reachable. What it cannot tell you: the exact line of code that created an object, or whether a short-term spike is “bad” by itself. You still need a reproducible test and a bit of detective work.
A key idea in snapshots is retainers. An object does not get garbage-collected if something still references it. Retainers are the chain of references that keep an object alive, like: a global singleton holds a Map, the Map holds request payloads, and those payloads include large strings. The “leak” is usually not the object you see growing, but the retainer that should have been cleared.
Plan to take three snapshots so you can compare what grows over time:
- Baseline: right after startup, before any load.
- Snapshot 2: after a known amount of load (for example, 200 requests).
- Snapshot 3: after more of the same load (for example, 600 requests).
If the same groups of objects increase from snapshot 2 to snapshot 3, you have a strong signal. If memory rises but the object counts stay flat, you might be looking at buffers, native addons, or normal caching.
Privacy matters. Heap snapshots can include user data, tokens, cookies, request payloads, and even exposed secrets that prototypes sometimes log or store in memory. Treat snapshots like production data: save them carefully, share them sparingly, and delete them when you are done.
Step-by-step: capture and compare snapshots to find what grows
When you suspect a Node.js memory leak, heap snapshots are the fastest way to stop guessing. The trick is to take snapshots around a repeated action so you can see what grows each time.
1) Capture three snapshots around the same action
Start your app in a way that lets you take heap snapshots (for example via your debugger or inspector). Then repeat one user action that you believe triggers the leak (a request, a page load, a background job run).
Use this simple rhythm:
- Snapshot A: take a baseline right after the app is “settled”
- Do the same action N times (start with 20-50)
- Snapshot B: take a second snapshot immediately after
- Do the same action N times again
- Snapshot C: take a third snapshot
If Snapshot B is bigger than A, and C is bigger than B by a similar amount, that steady increase per iteration is a strong leak signal.
2) Compare snapshots and follow the retaining path
Open the comparison view between A and B (then B and C). Focus on object types that increase, not the one-time spikes.
Look for:
- Constructor names that keep rising (for example: Array, Map, Listener, Timeout, Buffer)
- Collections that grow (Map entries, Set items, cached objects)
- Detached or “unreachable” looking objects that are still retained
- The retaining path (what is holding the object in memory)
The retaining path is the money part. It often points to a global singleton, a module-level cache, an event emitter, or a timer list.
3) Write down what you see before changing code
While inspecting, keep quick notes so you do not lose the thread:
- The top 2-3 growing constructor names
- The retaining root (global, module export, request handler closure)
- Any file or module hints shown in the snapshot
- Rough growth rate (for example: +500 objects per 50 requests)
That short list makes the fix focused. It is also the same evidence teams like FixMyMess use in a free audit to pinpoint whether the leak is listeners, caches, or timers before touching the code.
Runaway event listeners: the most common prototype leak
A classic Node.js memory leak in prototype code is simple: a listener gets added again and again, but never removed. Memory grows slowly, then suddenly the process starts pausing, timing out, or crashing.
This often happens when a “setup” function runs on every request, reconnect, or job, and it does something like emitter.on(...) without checking whether it already subscribed. Each new listener can keep extra data alive, especially when the handler closes over request objects, user data, or large buffers.
Common places this shows up:
EventEmitterinstances used as global buses- WebSocket connections that reconnect and resubscribe
- HTTP streams where
dataanderrorhandlers pile up - Database clients that attach listeners on every query
processevents likeuncaughtExceptionorSIGTERMregistered repeatedly
Heap snapshots can reveal this pattern. Look for a growing number of functions under listener arrays (often on an emitter), or many similar closures that reference the same outer variables. A strong clue is seeing retained objects that look like request/response data hanging off a listener function’s “context” or closure.
A concrete example: an Express route calls subscribeToUpdates(userId) on each request, and that function adds ws.on('message', ...). If it never unsubscribes when the request finishes (or when the user disconnects), the WebSocket keeps references to old handlers and their captured data.
Fixes are usually boring but effective:
- Use
oncefor events that should fire a single time - Call
off/removeListenerduring cleanup (disconnect, request end, job finish) - Avoid per-request subscriptions to global emitters; route events through a scoped object
- Store the handler function so you can remove the exact same reference later
- Add guardrails: log
listenerCount, and treat warnings as real bugs
If you inherited AI-generated code with these patterns, FixMyMess often starts by mapping listener lifecycles and removing hidden “subscribe on every call” traps before anything else.
Caches that only grow: Maps, memoization, and global singletons
A lot of “memory leaks” in prototypes are not mysterious bugs. They are caches that never let go. In a Node.js memory leak hunt, this is one of the first places to look because it often looks like “the app works fine” until traffic or time makes the cache huge.
The classic pattern is a Map or plain object used as a quick lookup, but with no size limit and no expiry. If the key is built from user input (search terms, URLs, headers, user IDs, prompts), the number of unique keys can grow forever.
What to look for
Start by finding anything that stores data across requests: module-level variables, singletons, or “helper” files that export a cache.
A few common culprits:
- A request de-dupe map (
inFlightRequests.get(key)) that never deletes on error paths - Memoization around expensive functions, keyed by raw input
- A global “last responses” map for debugging or analytics
- Caches that store whole response objects, DB rows, or Buffers
- Session-like data stored in memory instead of a real store
Here’s a small scenario that leaks fast: you cache GET /search?q=... results keyed by the full query string. A week later, you have hundreds of thousands of unique queries, and each value includes a large JSON payload. Heap snapshots will often show a big Map (or Object) retaining arrays, strings, and nested objects.
Safer cache patterns
Fixing it usually means making the cache behave like a cache, not an archive:
- Add a hard max size (evict least-recently-used or oldest entries)
- Add TTL expiry and clean up on an interval that can be stopped
- Normalize keys (lowercase, trim, sort params) to reduce key explosions
- Store IDs or small summaries, not entire objects or raw responses
- Always delete entries on failure and on timeout paths
If you inherited an AI-generated prototype, these caches are often scattered across “utility” files and singletons. FixMyMess often finds 2-3 separate growing maps in the same codebase, each one holding more than it needs.
Intervals and background loops that never stop
Timers are an easy way to create a Node.js memory leak, especially in apps that started as prototypes. The classic mistake is creating a new setInterval() (or chained setTimeout()) inside a request handler, and never clearing it. Every request adds another background loop that keeps references alive.
This often happens with “quick” features: polling a third-party API, retrying failed jobs, checking a queue, or refreshing a cache. When that code sits inside a route or per-user setup, the timer closes over request data (user id, auth token, payload), and that closure stays in memory as long as the timer exists.
A realistic example: an Express route like /start-sync sets an interval to poll progress every 2 seconds. If the user refreshes the page or calls it twice, you now have two intervals for the same user. Multiply that by real traffic and memory climbs steadily.
Heap snapshots can give you strong hints. You will often see growing counts of timer-related objects, plus retained closures pointing back to request or session objects. If the snapshot comparison shows more “listeners” and “Timeout” objects after each test run, the timer list is growing.
Fix patterns that usually work:
- Create scheduled timers once at startup, not inside routes.
- Store timer IDs and always call
clearInterval()orclearTimeout()when the job finishes. - Tie timer lifetime to the connection: cancel on disconnect, logout, or when a WebSocket closes.
- Guard against duplicates (for example, one interval per user or per workspace).
- Prefer a single worker loop that pulls jobs from a queue over one timer per request.
After the change, rerun the same load and take new heap snapshots. If the fix is real, timer-related objects stop growing and memory starts to level off.
If your app was generated in tools like Lovable, Bolt, or Replit and timers are scattered across routes, FixMyMess can do a quick audit and point out exactly where the loops are being created and why they are never stopped.
Prove the fix: rerun the test and confirm memory stabilizes
A real fix changes what the app keeps in memory. A mask only changes what you notice. Restarting the server, increasing container memory, or forcing GC can make graphs look better for a while, but the same leak is still there.
Treat the proof step like a lab experiment. Use the exact same reproduction steps, the same data size, and the same runtime settings you used when you first saw the Node.js memory leak.
Capture a fresh set of heap snapshots: one at a clean start, one after the leak has had time to grow, and (if your test includes a cooldown) one after load stops. Then compare them to your earlier “before” snapshots.
You are done when two things are true:
- The object types that used to grow (for example, arrays of listeners, Map entries, cached responses, timer closures) stop increasing between snapshots.
- After load ends, the heap rises and falls, then levels off near a steady range instead of climbing with every run.
A simple example: you removed a stray setInterval that was created per request. On the next run, snapshot two should no longer show thousands of identical interval callbacks retaining request data, and snapshot three should not be higher than snapshot two by a meaningful amount.
If the leak is “fixed” but the heap still climbs, double-check that you did not just move the growth somewhere else. Common masks include adding an LRU cache but never setting a size limit, or removing listeners in one code path but not on error.
For long-term safety, add a small regression check before release. Keep it short and boring, just enough to catch a return of the same pattern:
- Run a tiny load for 2 to 5 minutes on a staging build.
- Record peak RSS/heap and fail if it grows past a sensible threshold.
- Optionally save one heap snapshot artifact and compare retained object counts.
If you inherited a prototype from tools like Bolt, Lovable, v0, Cursor, or Replit, this proof step is where teams usually discover “one more” leak path. FixMyMess often runs this rerun-and-compare cycle after repairs so the change is verified, not just hoped for.
Common mistakes that waste hours
One snapshot is not proof. A single heap view can show a lot of objects, but it cannot tell you what is growing. You need at least two snapshots taken at the same point in your test, so you can compare and see which constructors and retainers keep increasing.
Noise is the other time killer. If you hit the app with mixed traffic patterns (login, uploads, cron tasks, random pages) you will see growth that is hard to explain. Keep one repeatable loop that triggers the suspected leak, then only change one thing at a time.
It is also easy to blame garbage collection. Node.js GC can look “lazy” under load, but if memory keeps climbing and never comes down, something is still strongly referenced. The usual culprits are global Maps, arrays in modules, closures capturing big objects, and event listeners that are added on every request but never removed. When you are chasing a Node.js memory leak, focus on what is holding references, not on GC settings.
Another trap is fixing the symptom instead of the cause. Clearing a cache “when it gets big” might hide the issue for a week, then it returns in production.
What to do instead
Aim for fixes that make growth impossible:
- Add size limits and eviction (TTL or LRU) to in-memory caches.
- Ensure listeners are registered once, or removed on cleanup.
- Stop timers and intervals when a job finishes or a socket closes.
- Avoid storing request objects, sessions, or large responses in globals.
Example: a prototype adds setInterval per user session to “refresh data”, but never clears it on logout. The heap looks random until you run a login/logout loop and compare snapshots. Then a single retained timer callback shows up as the root.
If you inherited an AI-generated Node app and the same leak keeps reappearing after quick patches, FixMyMess typically starts with a short audit to pinpoint the exact retaining path, then applies a real cleanup and limits so it stays fixed.
Quick checklist and next steps
A Node.js memory leak is easy to argue about and hard to prove. Use this quick checklist to keep your work focused and make sure you can show the leak is gone, not just “better on your machine.”
Quick checklist
- Can you reproduce memory growth in under 10 minutes with a repeatable test (same endpoints, same payloads, same concurrency)?
- Did you take at least 3 heap snapshots (baseline, mid-run, near-failure) and compare what grows between them?
- Did you specifically inspect the usual suspects: event listeners, in-memory caches (Maps, arrays, memoization), and timers/intervals?
- After code changes, did you rerun the exact same test and confirm memory stabilizes (and GC cycles do not keep climbing)?
- Did you also check the “side signals” that point to the cause, like listener counts, open handles, and ever-growing key counts in Maps?
If one item fails, pause and fix your process first. Most wasted time comes from changing code before you can reproduce the problem reliably, or from taking a single snapshot and guessing.
Next steps
Once you’ve proven stability, keep the win from slipping back in:
- Add a simple soak test to your release routine (10-20 minutes is often enough to catch regressions).
- Put guardrails around growth-prone code: cap caches, remove listeners on cleanup, and stop intervals when work is done.
- Document “ownership” for background loops and singletons so they do not multiply as the app evolves.
If your app started as an AI-generated prototype, leaks often come from tangled global state, duplicated listeners, or background jobs that were added during experimentation and never removed. In those cases, a focused audit plus a targeted refactor is usually faster than piecemeal patching. FixMyMess can run a free code audit, pinpoint what’s growing, and help turn the prototype into production-ready code with verified fixes.