Back to Explore
GuideVibeFix 编辑部Updated Nov 5, 2026

Pages Get Slower Until They Go Blank: Debugging Memory Leaks in Vibe Projects

Slower with use and fixed by a restart usually means a memory leak: locate it with the three-snapshot method, fix it by its hiding place, verify with the same steps, and leave a memory budget behind.

Real photograph of a developer thinking while looking at code on a monitor

Pages Get Slower the Longer You Use Them, Then Go Blank: That Is a Memory Leak

The most common way a vibe project dies is not an error on launch day. It is a slow decline. Everything works fine when you first open it. After half an hour it starts to stutter, scrolling drops frames, typing lags, and finally the tab goes blank — or the Node service process gets killed by the system, leaving only one line in the logs: Out of Memory. A restart buys you a few good hours, and then the same old problem comes back. This pattern — getting worse the longer it runs, fixed by a restart — is almost always a memory leak: the program keeps putting things into memory and never gives them back.

This guide is about how to find a leak, confirm it, fix it, and keep it from coming back. Let me draw the boundary first: our existing guide on error monitoring and crash reporting is about knowing immediately after something crashes; the performance optimization guide is about making pages run fast; the caching strategy guide is about doing caching well. This guide covers one thing only: where runaway memory growth comes from, how to trace it to a specific line of code, and how to prove the fix actually worked. All examples below are fictional teaching examples; app names and code snippets are simplified versions constructed for explanation.

Real photograph of a developer thinking while looking at code on a monitor

1. First, Tell Three Things Apart: Leaks, Growth, and Normal Usage

Before touching anything, separate three things, or you will fix the wrong problem. Normal usage is the memory a program genuinely needs to do its work — opening a big table loads tens of thousands of rows and memory goes up, which is fine, and it should come back down when you close the table. Growth means a data structure keeps getting longer while the program still knows about the data — a log array that only ever grows, for example — which is a product design problem. A leak means the program no longer needs something, but some reference is still holding on to it, so the garbage collector cannot reclaim it even though it wants to.

To decide whether you have a leak, ask three questions. First, does memory rise steadily with usage time instead of fluctuating around a level line? Second, does repeating the same action ten times raise memory ten times, instead of rising once and then flattening? Third, when you return to the initial state — closing the modal, going back to the home page, logging out and back in — does memory fail to return to where it started? If two or more answers are yes, investigate it as a leak. If memory rises and then holds steady, that is caching or preloading; do not chase it as a leak.

One common misjudgment: the browser's memory number wobbles on its own, because garbage collection does not happen in real time. Look at the trend, not a single second's reading, and force garbage collection a few times before reading the number. The little trash-can icon in Chrome DevTools' Memory panel exists for exactly this.

2. The Seven Places Leaks Love to Hide

Leaks have endless causes but a very short list of hiding places. Memorize this table and work through it item by item when debugging; eighty percent of leaks are in it.

Hiding placeTypical symptomWhy it cannot be collected
Event listeners never removedMemory rises as components mount and unmount repeatedlyThe listener holds the component instance; the DOM is gone, the instance remains
Timers and subscriptions never cleaned upNetwork requests keep running after you leave the pageThe timer callback holds a closure, and the closure holds old state
Closures accidentally capturing big objectsMemory permanently gains a chunk after opening one large fileA small function casually grabbed the whole large object
Detached DOM nodesThe page gets slower as a list refreshes repeatedlyA JS variable still points at a node removed from the page
Unbounded cachesMemory grows the longer it runs; only a restart helpsCache keys only ever increase, never evicted
Accidental global variablesMemory climbs in steps, one step per actionA variable missing its declaration landed on the global object
Third-party scripts and iframesMemory starts climbing after adding some SDKThe external script created its own listeners and timers

The first two are the ones AI-generated code brings in most often. AI loves adding listeners and timers inside a useEffect but often forgets the cleanup function; when writing a Node service it loves a module-level object as a cache and never gives it a limit. When reviewing AI code, check these two places first.

3. In the Browser: The Three-Snapshot Method

The standard way to hunt a frontend leak is the three-snapshot method, using only Chrome DevTools, nothing to install. Open the Memory panel, click the trash-can icon to force collection, and take the first heap snapshot. Then repeat the suspected action ten times — open and close the same modal ten times, or switch list pages ten times — doing exactly the same thing each time; you can click the trash can between rounds, but the key is that the actions are identical. Then force collection once more and take the second snapshot. Compare the two: whichever object types grew roughly tenfold are your leak candidates.

Counts alone are not enough; you need to see who is holding on to them. In the second snapshot, open one of the fastest-growing object types and look at its Retainers (the reference chain). Follow the chain upward until you reach the first function or variable name that belongs to your own code — the leak is right there. A typical chain looks like this: an unmounted component instance held by a global event listener, or a closed modal held by a setInterval callback that was never cleared. The third snapshot is for verifying the fix: after fixing, repeat the same actions, and the counts should no longer grow. Only then is it truly fixed.

Two supporting tools are worth using. The Performance Monitor panel shows live curves for JS heap size, DOM node count, and listener count; if the listener line keeps climbing, the leak is in the listeners. The Allocation Timeline records where newly allocated memory comes from over a period of time, which is good for catching "every action creates a new big object" problems. One detail: before taking snapshots, turn off unrelated browser extensions — their memory shows up in the snapshot too and muddies the reading.

4. On the Server: Hunting Leaks in a Node Process

A Node leak is sneakier because there is no page to refresh. The typical signal is the process's resident memory (RSS) climbing steadily, starting low again after each restart, until the system OOM killer ends it. The first step is to confirm the climb is real: log process.memoryUsage().heapUsed once a minute and draw a line. RSS alone is not enough — it includes plenty of non-heap memory — so watch the trend of heapUsed and heapTotal.

// When you suspect a leak, start with the simplest memory log
setInterval(() => {
  const m = process.memoryUsage();
  console.log(JSON.stringify({
    time: new Date().toISOString(),
    heapUsedMB: Math.round(m.heapUsed / 1024 / 1024),
    heapTotalMB: Math.round(m.heapTotal / 1024 / 1024),
    rssMB: Math.round(m.rss / 1024 / 1024),
  }));
}, 60 * 1000);

Once the climb is confirmed, use heap snapshots to find the culprit. Start the process with --inspect, connect with Chrome, and take snapshots the same way as in the browser; or integrate heapdump in the process so it writes a snapshot file automatically when memory passes a threshold. Compare two snapshots taken some time apart and look for your own business objects among the growing types — instances of a "session", "task", or "connection" class whose count is rising. A rising count with a reference chain pointing at a module-level Map or array almost always means a cache without a limit, or a subscription that was never cancelled.

Another class of server leak comes from connections and streams: a leaking database connection pool, a file stream never closed, a response body never fully consumed. Their shared trait is that heap memory may not grow much, but handle counts and connection counts climb, and eventually the service still runs but every new request times out. When debugging, glance at open handles and active database connections too — do not stare at the heap alone.

5. The Standard Fix for Each of the Seven Types

Once you have located the leak, the fix is mostly formulaic. Match the hiding place and apply the corresponding fix.

Event listeners: whoever adds one removes it. Remove listeners when the component unmounts, and addEventListener and removeEventListener must use the same function reference — an anonymous function can never be removed. In React, put cleanup in the return function of useEffect; do not rely on the assumption that a component "should be destroyed".

// Leaking version: an anonymous function that can never be removed
window.addEventListener('resize', () => setWidth(window.innerWidth));

// Fixed version: named function + cleanup
useEffect(() => {
  const onResize = () => setWidth(window.innerWidth);
  window.addEventListener('resize', onResize);
  return () => window.removeEventListener('resize', onResize);
}, []);

Timers and subscriptions: setInterval, setTimeout, WebSocket connections, and event-bus subscriptions must all be cleaned up when no longer needed. The easy one to miss: the component unmounted but the timer callback is still calling setState. The fix is confirmed when network requests and timers both stop after you navigate away.

Closures: capture only what you need. If a callback only needs an id, pass just the id — do not pass the whole large object. When processing a large file, clear the reference as soon as you are done reading so the garbage collector can reclaim it.

Detached DOM: do not keep DOM nodes in JS variables or Maps for a long time. When you must cache node references, use a WeakMap or WeakRef so a node can be collected after it is removed from the page. In list rendering, use keys to guarantee reuse instead of rebuilding a whole subtree on every refresh.

Unbounded caches: every cache needs a limit and an eviction policy. The simplest is LRU: when it exceeds N entries, evict the least recently used one; or expire by time, invalidating entries past their TTL. Give the cache a size metric so you can see after launch whether it is growing or holding steady.

Global variables: in strict mode a missing declaration throws immediately, so turn strict mode on in development. When reviewing code, search for those objects casually parked at module top level — they are the easiest references to forget.

Third-party scripts: isolate before blaming. Turn off the suspicious SDK, run the three-snapshot round, and only after confirming it is the cause should you limit where it loads or destroy its instance when it is not needed. Do not start by changing your own code — a leak inside an external script cannot be fixed by changing yours.

6. How to Prove the Fix Really Worked

The easiest mistake in leak fixing is declaring "it should be fixed now" on a feeling. The right way is to turn the investigation steps into a repeatable verification script: run the same action ten times, record how much memory grew before the fix and how much it grows after, and put the two numbers side by side as evidence. In the browser, re-run the same three-snapshot flow; in Node, write a load loop at a fixed request rate for ten minutes and check whether the heapUsed curve has flattened.

Two verification points are easy to miss. First, verify the feature still works after the fix: removing listeners too aggressively makes a button stop working after one click, and adding a cache limit can evict hot data too often. Second, verify at a data volume close to real life: a leak invisible with ten rows of test data may reproduce on the first try with ten thousand. Use verification data at roughly the same scale as production.

7. Preventing Recurrence: Keep Leaks Out of the Next Release

Fixing one leak is not hard. The hard part is not writing a new one in the next release. Three mechanisms work best. First, a memory budget: set a limit for key pages and key endpoints — for example, "switching the list page back and forth twenty times grows heap memory by no more than 10 MB" — and write it into the acceptance checklist. Second, regression tests: turn the most important action from the three-snapshot flow into an automated test, run it in CI, and block merges that blow the budget. Third, a code-review checklist: when reviewing AI-generated code, specifically check that listeners have cleanup, timers have cleanup, and caches have limits. It takes ten seconds to scan.

Add two safety nets in production as well. In the browser, add lightweight performance reporting that samples heap size periodically and alerts on abnormal climbs. In Node, automatically write a heap snapshot and alert when heapUsed passes a threshold, preserving a "crime scene" for later analysis instead of having no evidence after the process is killed. Set alert thresholds from real traffic volume: too low and you get false alarms every day, too high and it is the same as having none.

One last note on AI-generated code. Having AI write feature code is fast, but AI has no intuition for lifecycles: it does not know this component will mount and unmount repeatedly, that this service must run for three months straight, or how many entries this cache will accumulate. So AI code often gets the feature right and plants the leak casually. When reviewing AI code, do not read every line — search for three things: does every addEventListener have a matching removeEventListener, do setInterval and subscriptions have a cleanup path, and do module-level objects have a limit. Scan those three and ninety percent of the risk shows itself. Writing them into the team's review checklist is far cheaper than debugging after the fact.

8. Two Debugging Stories: From Symptom to Root Cause

Method alone is not enough, so here are two complete investigations, with the focus on how the reasoning went. Both cases are fictional teaching examples; app names and numbers are constructed for explanation.

Case one: a chatroom page that got slower the longer you chatted. In the fictional app "Chatter Island", a web chatroom, users reported that the input box started lagging after half an hour of chatting. The Performance Monitor showed DOM node count and listener count both climbing slowly. Comparing three snapshots, MessageList component instances had grown more than tenfold, with the reference chain pointing at a subscription to a global message bus. The root cause: every time the chatroom was entered, it subscribed to message events once, and never unsubscribed on exit, so old component instances were held forever by the subscription callback. The fix was to unsubscribe when the component unmounts and to consolidate subscription logic into a single Hook. Verification: entering and leaving the chatroom twenty times brought the component instance count back to single digits and flattened the memory curve.

Case two: an export service killed every night. In the fictional app "Report Workshop", a Node export service ran batch export jobs every night; process memory climbed through the night until the system killed it. The memory log showed heapUsed gaining a step with each batch of jobs and never coming back down. Comparing two heap snapshots, the fastest-growing object was a module-level task record store, with each record still holding the full content of its exported file. The root cause: when a task finished, only its status field was updated — the record was never deleted and the file content never released. The fix was to delete the record and release the file reference on completion, plus a cap and expiry cleanup on the store. Verification: running three batches in a row brought heapUsed back to baseline after each batch, and the service ran a full week without being killed again.

Both cases share the same pattern: the symptom appeared on repeated actions, the root cause was "something finished being used but still referenced", and the location came from comparing two snapshots and following the reference chain back to our own code. Remember that chain and the next similar symptom will not require starting from zero.

9. Pre-Launch Checklist

Walk this checklist item by item before shipping the next release. One, after repeating the key action ten times, memory returns close to its starting point. Two, every listener, timer, and subscription has matching cleanup code. Three, every cache has a limit and an eviction policy. Four, references to large objects are cleared promptly after use. Five, third-party scripts have been tested with on/off comparison and show no climb they cause. Six, the verification script has run, with before-and-after memory numbers recorded. Seven, production memory alerts and automatic snapshots are configured. Eight, verification data is close to real production volume.

Memory leaks are not scary. What is scary is that they happen quietly and get worse slowly, and by the time users notice, the reputation damage is done. Treat it as a fixed process: first confirm it is a leak by looking at the trend, then locate it with snapshots and the reference chain, fix it with the standard fix for its type, and verify with the same actions. Once the process runs smoothly, a leak usually goes from discovery to fix in half a day. More importantly, after fixing it, leave the budget and the checklist behind, so the next leak gets blocked before merging instead of being reported by users in the comments telling you the page is stuck again.

Views 0Comments 0

Comments (0)

ME
0/1000
Loading comments...
Browse projectsPublish your project

Related articles