by Ko-Hsin Liang
Frontend Memory Leaks: A 500-Repository Static Analysis and Five-Scenario Benchmark Study
You know the moment. You’re clicking through your SPA — navigating pages, filling out forms, switching tabs. Everything feels snappy. Then twenty minutes in, the UI starts stuttering. Scroll events lag. Animations drop frames. You open the Memory tab in DevTools and watch a heap line climb steadily — 200 MB, 300 MB, 400 MB — with no sign of leveling off.
That’s a memory leak. Not the dramatic kind that crashes on launch and gets fixed the same afternoon. The slow, silent kind. A missing cleanup return in a useEffect. A subscribe() that never gets an unsubscribe(). A watch() whose stop handle was never captured. Each one invisible during development, each one accumulating retained objects in production.
I wanted to know how common this really is — and what it actually costs. So I built AST-based detectors for React, Vue, and Angular, pointed them at 500 public repositories, then wrote controlled benchmarks that simulate what happens when cleanup is missing. One hundred mount/unmount cycles, fifty independent repeats, forced garbage collection before every measurement.
The short version: 86% of repos have at least one missing-cleanup pattern. The scan found 55,864 potential leak instances across 714,217 files. And every benchmark scenario showed the same result — roughly 8 KB of retained heap growth per cycle when cleanup is missing, versus near-zero growth when it’s handled properly.
Here’s the full data and analysis.
30-minute action plan for engineering leads: Start with the big three:
useEffectwithout a cleanup return,.subscribe()without.unsubscribe(), andaddEventListenerwithoutremoveEventListener. Together these account for 42.2% of all findings. For React, audit everyuseEffectthat callsaddEventListener,setInterval,setTimeout, or.subscribe()— if it doesn’t return a cleanup function, it’s leaking. For Vue, check everyonMountedfor a matchingonUnmounted, and everywatch()/watchEffect()for a captured stop handle. For Angular, ensure every component with.subscribe()has anngOnDestroythat unsubscribes — or use thetakeUntilpattern. Our benchmarks show each unhandled pattern retains ~8 KB per navigation cycle, compounding linearly. The fixes are one-line additions.
What “memory leak” actually means in a garbage-collected runtime
Before the numbers, one misconception needs clearing up.
“JavaScript has garbage collection, so memory leaks can’t really happen.”
This is wrong, and understanding why matters for everything that follows.
V8’s garbage collector reclaims objects that are unreachable — no live reference chain connects them to a GC root. The GC has no concept of developer intent. It only knows what’s reachable.
Here’s what happens when a React component adds an event listener without cleanup:
useEffect(() => {
const handler = () => { /* uses component state */ };
window.addEventListener('resize', handler);
// No cleanup return — handler stays registered forever
}, []);
When the component unmounts, React throws away its fiber. But window is a GC root, and its event listener list still holds a reference to handler. That closure captured the component’s state. The reference chain is:
window → event listener list → handler closure → component state → data
Every object in that chain is reachable. The GC will never collect them. This is not a GC failure — it’s an application-level bug. The developer created a reference chain they didn’t intend to preserve.
The same mechanism applies to every framework:
- React:
useEffectwithout cleanup → listener/timer/subscription stays alive - Vue:
onMountedwithoutonUnmounted→ timer holds component data;watch()without stop handle → watcher registry holds callback - Angular:
.subscribe()without.unsubscribe()→ observable holds subscriber callback; missingngOnDestroy→ subscriptions persist
This distinction is why our benchmarks force GC before every snapshot. We measure what’s retained — what GC cannot collect — not what’s merely pending collection.
Part 1: How common are missing-cleanup patterns in the wild?
How the scan works
I built three AST-based detectors (adapted from Code Evolution Lab), one per framework. Each parses JavaScript and TypeScript source files with Babel and walks the syntax tree, matching setup calls that create resources against corresponding cleanup calls in the same scope.
| Framework | Patterns Detected |
|---|---|
| React | useEffect without cleanup return, addEventListener without removeEventListener, setInterval/setTimeout without cleanup, .subscribe() without .unsubscribe() |
| Vue | onMounted without onUnmounted cleanup, watch/watchEffect without stop handle, manual addEventListener in setup without removal, event bus .on() without .off() |
| Angular | .subscribe() without .unsubscribe() or takeUntil, missing ngOnDestroy, Renderer2.listen() without stored unlisten, timers without cleanup |
Cross-framework: IntersectionObserver/MutationObserver/ResizeObserver without .disconnect(), requestAnimationFrame without cancelAnimationFrame.
Each finding is classified by leak type, context (component lifecycle, event binding, timer, store subscription, service singleton, or unknown), and severity (high or medium).
Repository selection methodology
The 500 repositories were selected through a targeted sampling strategy focused on production-quality code:
Selection criteria:
- Lifecycle intensity — Apps with frequent mount/unmount cycles (dashboards, real-time apps, component libraries) where missing cleanup creates the most impact
- Event-driven workload — Repos that rely on listeners, timers, subscriptions, observers, and background workers where leaks commonly originate
- Production maturity — Active projects with ≥500 GitHub stars, ensuring community-validated, production-grade code
- Framework verification — Confirmed presence of
.tsx,.vue, or Angular decorator files to avoid backend-only or tooling repos - Use-case diversity — Balanced across domains (admin panels, CMS, component libraries, dashboards, dev tools) to capture different leak patterns
Sample composition:
- 210 React repositories (42%)
- 150 Vue repositories (30%)
- 140 Angular repositories (28%)
The sample includes major projects like Kibana, Next.js, Element Plus, and AFFiNE — codebases with millions of users and active development. This isn’t a random sample of all GitHub repos; it’s a curated set of high-impact projects where findings represent patterns in real production code.
The numbers
714,217 files scanned. 55,864 missing-cleanup patterns found. 430 out of 500 repos had at least one.
That’s an 86.0% prevalence rate.
Across 500 well-known, actively maintained open-source projects — from Next.js to Kibana to Element Plus — only 70 repos came back completely clean.
By framework
| Framework | Issues | Share |
|---|---|---|
| React | 34,787 | 62.3% |
| Vue | 15,750 | 28.2% |
| Angular | 5,327 | 9.5% |
React dominates raw count, and the sample was also weighted toward React (210 repos vs 150 Vue and 140 Angular). The more useful lens is pattern distribution — which types of missing cleanup appear most often, regardless of framework.
Which leak patterns?
| Pattern Type | Count | Share |
|---|---|---|
| Missing timer cleanup | 24,538 | 43.9% |
| Missing event listener removal | 10,616 | 19.0% |
| Missing subscription cleanup | 7,740 | 13.9% |
| Missing effect cleanup | 5,200 | 9.3% |
| Missing watch stop handle | 3,989 | 7.1% |
| Missing lifecycle cleanup | 1,641 | 2.9% |
| Missing RAF cancel | 1,230 | 2.2% |
| Missing observer disconnect | 787 | 1.4% |
| Missing renderer unlisten | 123 | 0.2% |
Timers are the number one offender. Nearly half of all findings are setTimeout or setInterval without cleanup. Timers are easy to forget because they’re invisible — you set one up, the feature works, and unless the delay is short enough to cause a visible side effect, you never notice the callback is still pending after unmount.
Event listeners are second at 10,616 instances. addEventListener without removeEventListener is the textbook example of a JavaScript memory leak, and it’s still pervasive across production codebases.
Which specific setup calls?
| Setup Call | Count | Share |
|---|---|---|
setTimeout | 22,384 | 40.1% |
addEventListener | 10,616 | 19.0% |
subscribe | 7,740 | 13.9% |
watch | 3,360 | 6.0% |
useEffect(addEventListener) | 2,165 | 3.9% |
requestAnimationFrame | 1,764 | 3.2% |
setInterval | 1,620 | 2.9% |
useEffect(setTimeout) | 1,276 | 2.3% |
watchEffect | 629 | 1.1% |
setTimeout alone accounts for 40% of all findings: 22,384 instances inside component code without a corresponding clearTimeout on unmount. The useEffect(addEventListener) pattern — an addEventListener placed inside a useEffect with no cleanup return — appears 2,165 times. In those cases the developer chose the right hook but omitted the most important part: the cleanup function.
Where does the leaking code live?
| Context | Count | Share |
|---|---|---|
| Component lifecycle | 18,406 | 32.9% |
| Event binding | 13,680 | 24.5% |
| Timer | 12,650 | 22.6% |
| Store subscription | 5,470 | 9.8% |
| Service singleton | 3,692 | 6.6% |
| Unknown | 1,966 | 3.5% |
A third of all findings sit directly in component lifecycle code — useEffect, onMounted, ngOnInit. This is the highest-impact location because lifecycle code runs on every mount and unmount. Each route change, conditional render, or tab switch creates and destroys components. When each destruction leaks a reference chain, the heap grows linearly with user navigation.
The unknown bucket is small (3.5%), significantly lower than in the blocking-io study. Frontend framework code follows more predictable structural patterns, which makes context classification more reliable.
The biggest offenders
| Repository | Framework | Issues |
|---|---|---|
| elastic/kibana | React | 1,796 |
| dcloudio/uni-app | Vue | 1,218 |
| microsoft/playwright | React | 1,104 |
| vercel/next.js | React | 1,103 |
| ever-co/ever-gauzy | Angular | 1,071 |
| didi/cube-ui | Vue | 970 |
| toeverything/AFFiNE | React | 887 |
| bigbluebutton/bigbluebutton | React | 728 |
| ElemeFE/element | Vue | 720 |
| posthog/posthog | React | 662 |
Raw count doesn’t equal raw risk — Kibana is a massive codebase where a high absolute count may reflect average density rather than unusual negligence. That said, several of these projects serve users in long-lived sessions where leaks compound most aggressively: Kibana (analytics dashboards open for hours), AFFiNE (collaborative workspace with frequent navigation), BigBlueButton (video conferencing sessions lasting hours), PostHog (analytics dashboards with live polling subscriptions).
Severity breakdown
| Severity | Count | Share |
|---|---|---|
| High | 19,115 | 34.2% |
| Medium | 36,749 | 65.8% |
High-severity covers patterns in component lifecycle hooks, subscription chains without cleanup, and listeners/timers in contexts where they accumulate — particularly when multiple instances stack per component.
Framework ecosystem analysis
React’s 62.3% share of findings reflects both market dominance and architectural characteristics. The useEffect hook’s flexible cleanup model enables sophisticated patterns but requires explicit cleanup returns — and that’s easy to forget. A developer writes useEffect(() => { addEventListener(...) }, []), the component works perfectly, and unless they think about the unmount path, the cleanup never gets added.
Vue’s reactivity system shows a different pattern. The watch() and watchEffect() APIs return stop handles, but the return value is optional — the watcher works fine even if you don’t capture it. This makes it easy to write watch(source, callback) and move on. The static analysis found 3,360 instances of watch with an unstored stop handle and 629 of watchEffect. That’s nearly 4,000 potential leak sites from just this one Vue-specific pattern.
Angular’s RxJS-centric approach shows the fewest raw findings (9.5%), but this partly reflects smaller sample size (140 repos vs 210 React repos). The .subscribe() pattern is well-understood in the Angular community, and many codebases use the takeUntil pattern or async pipe to avoid manual unsubscribe. But when manual subscriptions do appear without cleanup, they leak just as reliably as React or Vue patterns.
Tooling gap: Framework-specific linting support varies widely:
- React:
react-hooks/exhaustive-depscatches missing dependencies but not missing cleanup returns. AuseEffectwithaddEventListenerand no cleanup passes linting. - Vue: No official ESLint rule for unstored watch stop handles. The Vue DevTools can show active watchers, but won’t flag the missing cleanup in code review.
- Angular: The compiler warns about unimplemented
ngOnDestroy, but won’t detect a component that hasngOnDestroybut doesn’t unsubscribe from all observables.
This tooling gap explains why these patterns persist at scale. The ecosystem provides partial coverage, but the most common leak patterns slip through.
Part 2: What does missing cleanup actually cost?
Static analysis tells you where potential leaks live. The question that matters for engineering decisions: how much memory does each pattern actually retain?
The setup
Each scenario simulates a component lifecycle in Node.js:
- Mount — create component, allocate state (1,000-element number array), register resource
- Unmount — BAD: do nothing; GOOD: deregister resource, clear state
- Repeat 100 cycles, snapshot (with forced GC) every 10 cycles
- Repeat entire run 50 times for mean and standard deviation
We force global.gc() before every snapshot (Node.js with --expose-gc), measuring retained memory — objects GC was told to collect but can’t because they’re still reachable.
Environment: Node.js v24.11.0, Windows x64, --expose-gc. Each scenario ran 50 independent times to establish statistical confidence.
Benchmark calibration methodology
The 1,000-element array size was chosen through a calibration sweep designed to optimize signal-to-noise ratio (SNR):
Calibration process:
- Tested array sizes: 500, 1,000, 2,500, 5,000, 10,000 elements
- Ran 50 cycles for each size with both BAD and GOOD variants
- Calculated SNR as
|BAD growth - GOOD growth| / |GOOD growth| - Evaluated growth rates (target: 0.5-5 MB/min for realistic session modeling)
Results:
- 500 elements: SNR = 2.1 (acceptable but noisy)
- 1,000 elements: SNR = 5.3 (excellent signal clarity)
- 2,500 elements: SNR = 7.8 (higher signal but diminishing returns)
- 5,000 elements: SNR = 12.4 (maximum clarity but unrealistic growth rate)
Selection rationale: 1,000 elements provided SNR > 5 (BAD-GOOD difference 5× the noise floor) while maintaining a realistic per-cycle growth rate (~8 KB). This represents a small-to-medium component — realistic for a form with validation state, a dashboard tile with charting data, or a modal with user preferences.
The calibration methodology is documented in calibrate-sizes.ts and replicable on other hardware configurations.
SC1: React useEffect — event listener without cleanup
The textbook React memory leak. A component registers an event listener on a shared emitter (simulating window) inside useEffect without returning a cleanup function.
Bad: window.addEventListener('resize', handler) — handler closure references component state; listener persists after unmount.
Good: Same setup, but cleanup return calls removeEventListener and clears state.
| Metric | BAD (50 runs) | GOOD (50 runs) | Delta |
|---|---|---|---|
| Mean heap growth | 807.2 KB | 2.4 KB | 804.8 KB |
| Std deviation | ± 36.8 KB | ± 0.3 KB | ± 36.8 KB |
| Rate | 8.07 KB/cycle | 0.02 KB/cycle | 8.05 KB/cycle |
After 100 cycles, the bad variant accumulated ~0.8 MB of retained heap. The good variant: 2.4 KB total — not per cycle, total. That’s noise-floor jitter.
The standard deviation is tight: ± 37 KB across 50 runs. This is not a flaky measurement — the leak is deterministic and linear.
What’s happening mechanically: Each cycle creates a component holding ~8 KB of state (1,000 Float64 elements × 8 bytes), registers a listener on the shared emitter, then “unmounts” without removing it. The emitter’s listener array grows by one entry per cycle, and each entry’s closure keeps its component alive. After 100 cycles: 100 retained components × ~8 KB ≈ 800 KB. The arithmetic matches the measurement.
SC2: Vue onMounted — timer without onUnmounted cleanup
A Vue component sets up a setInterval in onMounted without clearing it in onUnmounted. The timer callback’s closure references component data.
Bad: setInterval(handler, 86_400_000) — long-delay timer holds reference chain alive; never fires during measurement.
Good: onUnmounted calls clearInterval(timerId) and clears data.
| Metric | BAD (50 runs) | GOOD (50 runs) | Delta |
|---|---|---|---|
| Mean heap growth | 819.1 KB | 2.6 KB | 816.5 KB |
| Std deviation | ± 3.2 KB | ± 1.5 KB | ± 2.0 KB |
| Rate | 8.19 KB/cycle | 0.03 KB/cycle | 8.17 KB/cycle |
Nearly identical profile to SC1, despite a completely different retention mechanism (timer registry vs event listener list). The cost is the same because the underlying pattern is the same: a closure keeps component state reachable.
Why a 24-hour delay? The timer never fires during the benchmark. Retention comes from the pending callback reference, not from callback execution. This isolates the memory signal from CPU and execution noise.
SC3: Angular subscribe — observable without unsubscribe
An Angular component calls .subscribe() on a data service observable without unsubscribing in ngOnDestroy.
Bad: this.dataService.subscribe(callback) — observable’s subscriber list holds callback → component state.
Good: Stores subscription, calls unsubscribe() in ngOnDestroy, clears state.
| Metric | BAD (50 runs) | GOOD (50 runs) | Delta |
|---|---|---|---|
| Mean heap growth | 803.8 KB | 2.6 KB | 801.2 KB |
| Std deviation | ± 0.6 KB | ± 1.2 KB | ± 1.0 KB |
| Rate | 8.04 KB/cycle | 0.03 KB/cycle | 8.01 KB/cycle |
The tightest result in the suite — ± 1.0 KB standard deviation on the delta. The observable subscriber pattern is highly deterministic: an internal array of callbacks that grows by exactly one entry per cycle.
Why this pattern is particularly insidious: it produces zero visible symptoms during development. The component subscribes, receives data, renders correctly. No error, no warning, no degraded functionality. The only signal is a heap that grows steadily and never reclaims between navigations — something you won’t notice without explicit memory monitoring.
SC4: Vue watch/watchEffect — stop handle not captured
A Vue component calls watch() or watchEffect() without storing the returned stop handle. The watcher registry holds the callback alive.
Bad: watcher.watch(callback) — return value discarded; registry holds callback → component.
Good: Stores stop handle, calls it on unmount, clears data.
| Metric | BAD (50 runs) | GOOD (50 runs) | Delta |
|---|---|---|---|
| Mean heap growth | 810.1 KB | 2.5 KB | 807.6 KB |
| Std deviation | ± 1.1 KB | ± 0.5 KB | ± 1.0 KB |
| Rate | 8.10 KB/cycle | 0.03 KB/cycle | 8.08 KB/cycle |
The BAD variant’s standard deviation is ± 1.1 KB — the most consistent measurement in the suite. The watcher registry produces near-perfectly linear heap growth.
This is a Vue-specific anti-pattern that’s easy to miss because watch() and watchEffect() function correctly without capturing their return value. The watcher fires, the reactivity system responds, the component behaves as expected. But the old watcher from the previous component instance is never disposed. Each mount stacks a new watcher on top of the previous ones.
Our scan found 3,360 instances of watch with an unstored stop handle and 629 of watchEffect — nearly 4,000 potential leak sites from this one pattern alone.
SC5: requestAnimationFrame — without cancelAnimationFrame
A component requests an animation frame without cancelling it on unmount. Simulated with long-delay setTimeout in Node.js (the pending callback holds the reference chain alive, just as a pending RAF would in a browser).
Bad: requestAnimationFrame(callback) — pending frame holds closure → component → data.
Good: Stores frame ID, calls cancelAnimationFrame(id) on unmount, clears data.
| Metric | BAD (50 runs) | GOOD (50 runs) | Delta |
|---|---|---|---|
| Mean heap growth | 819.0 KB | 2.5 KB | 816.5 KB |
| Std deviation | ± 3.1 KB | ± 1.2 KB | ± 2.9 KB |
| Rate | 8.19 KB/cycle | 0.03 KB/cycle | 8.16 KB/cycle |
Common in animation-heavy components — carousels, scroll-linked effects, canvas renderers, physics simulations. The scan found 1,230 instances of missing RAF cancel.
In a real browser, a leaked RAF is arguably worse than a leaked timer. RAF callbacks fire at display refresh rate — typically 60 fps. A leaked recursive chain (requestAnimationFrame(function loop() { ... requestAnimationFrame(loop) })) executes every 16.7 ms, holds references indefinitely, and burns CPU. It’s a memory leak and a performance regression in one.
The full picture
| Scenario | Framework | BAD (KB ± std) | GOOD (KB ± std) | Delta (KB ± std) | Rate (KB/cycle) |
|---|---|---|---|---|---|
| SC1: useEffect listener | React | 807 ± 37 | 2.4 ± 0.3 | 805 ± 37 | 8.05 |
| SC2: onMounted timer | Vue | 819 ± 3 | 2.6 ± 1.5 | 817 ± 2 | 8.17 |
| SC3: subscribe leak | Angular | 804 ± 0.6 | 2.6 ± 1.2 | 801 ± 1 | 8.01 |
| SC4: watch stop handle | Vue | 810 ± 1.1 | 2.5 ± 0.5 | 808 ± 1 | 8.08 |
| SC5: RAF cancel | Cross-fw | 819 ± 3.1 | 2.5 ± 1.2 | 817 ± 3 | 8.16 |
All values: mean ± std across 50 independent runs, forced GC, 100 cycles.
Key observations:
- BAD variants cluster tightly around 810 KB across all five mechanisms after 100 cycles
- GOOD variants remain at 2–3 KB total — indistinguishable from zero at this scale
- All five patterns leak at ~8 KB/cycle — the mechanism differs (event target, timer registry, observable, watcher, pending RAF), but the cost is determined by payload size, not mechanism
- Near-perfect linearity: All five BAD variants grow at ~8 KB/cycle with minimal deviation, confirming the leak is deterministic, not probabilistic
- Flat baseline: GOOD variants show minimal noise-floor jitter (~2.5 KB total over 100 cycles) — this is GC overhead and measurement noise, not retained memory
The contrast is unambiguous: BAD variants accumulate 800+ KB while GOOD variants stay under 3 KB. The leak doesn’t happen probabilistically — it occurs on every single cycle, with near-identical magnitude.
Statistical validation
Fifty independent runs per scenario provide enough data to quantify both measurement reliability and the magnitude of the cleanup effect.
Confidence intervals (95% CI) for leak rates:
| Scenario | Leak Rate (KB/cycle) | 95% CI | Coefficient of Variation |
|---|---|---|---|
| React useEffect | 8.07 | ± 0.72 | 0.9% |
| Vue onMounted | 8.19 | ± 0.06 | 0.08% |
| Angular subscribe | 8.04 | ± 0.01 | 0.01% |
| Vue watch | 8.10 | ± 0.02 | 0.02% |
| RAF cancel | 8.19 | ± 0.06 | 0.08% |
95% CI calculated as ± 1.96 × (std / √50). Coefficient of variation = std / mean.
Effect size: All five scenarios showed extremely large effect sizes when comparing BAD vs GOOD variants. Using Cohen’s d:
- React useEffect: d = 21.8 (BAD mean 807 KB, GOOD mean 2.4 KB, pooled std 26.0 KB)
- Vue onMounted: d = 328.7 (BAD mean 819 KB, GOOD mean 2.6 KB, pooled std 2.5 KB)
- Angular subscribe: d = 820.3 (BAD mean 804 KB, GOOD mean 2.6 KB, pooled std 0.98 KB)
- Vue watch: d = 828.5 (BAD mean 810 KB, GOOD mean 2.5 KB, pooled std 0.97 KB)
- RAF cancel: d = 281.7 (BAD mean 819 KB, GOOD mean 2.5 KB, pooled std 2.9 KB)
For reference, Cohen’s d > 0.8 is conventionally labeled “large.” Values above 20 mean the BAD and GOOD distributions have zero overlap — every single BAD run retained more memory than every single GOOD run. The presence or absence of cleanup is not a marginal factor. It is the dominant variable in retained memory.
Statistical significance: All scenarios showed p < 0.001 (Welch’s t-test, two-tailed). With 50 repeats and effect sizes this large, statistical power exceeds 99.99%. Put differently: if cleanup truly had no effect on memory retention, the probability of observing results this extreme would be less than 1 in 10,000.
Measurement reliability: The standard error of the mean (SEM = std / √50) was:
- React useEffect: 5.2 KB (0.6% of measured effect)
- Vue onMounted: 0.45 KB (0.06% of measured effect)
- Angular subscribe: 0.14 KB (0.02% of measured effect)
- Vue watch: 0.14 KB (0.02% of measured effect)
- RAF cancel: 0.41 KB (0.05% of measured effect)
With SEM below 1% of the measured effect for all five scenarios, the ~8 KB/cycle leak rate is a stable, reproducible measurement — not an artifact of variance or environment.
What 8 KB/cycle means in practice
| Navigation Cycles | 1 Leaking Pattern | 3 Stacked | 5 Stacked |
|---|---|---|---|
| 50 | 0.4 MB | 1.2 MB | 2.0 MB |
| 100 | 0.8 MB | 2.4 MB | 4.0 MB |
| 200 | 1.6 MB | 4.8 MB | 8.0 MB |
| 500 | 4.0 MB | 12.0 MB | 20.0 MB |
Why “stacked patterns” matters: Real components often have multiple leaks — a setInterval for polling, an addEventListener for resize, a .subscribe() for a store. Three independent reference chains, each leaking ~8 KB/cycle. They compound.
Mobile limits: iOS Safari kills tabs at roughly 80-120 MB. Android Chrome: 150-300 MB. A dashboard with 3 leaking patterns hitting 12 MB in a moderate session is within range of the OS killing the tab.
Electron apps: Long-running desktop apps (VS Code, Slack, Discord) are especially vulnerable. Users keep them open for hours. Even a single 8 KB/cycle leak over thousands of component mounts across a workday grows to tens of megabytes.
Real-world impact scenarios
The ~8 KB/cycle figure is specific to our benchmark payload (1,000-element array ≈ 8 KB). Real component state varies, but the linear accumulation model holds. Here’s what it looks like in practice:
Scenario 1: E-commerce product browsing
A user browses 50 products in a shopping session. Each product detail page mounts a component with image carousel, reviews panel, and related products. If the component has three unhandled patterns — a resize listener for responsive layout, a polling timer for inventory updates, and a store subscription for cart state:
- Memory cost: 50 cycles × 3 patterns × 8 KB = 1.2 MB retained
- On a mobile device with 2 GB RAM: 0.06% per session
- After 10 similar sessions without closing the tab: 12 MB retained
For a user who keeps their shopping session open across multiple stores and tabs, this compounds. On iOS Safari, the 80-120 MB tab-kill threshold becomes reachable.
Scenario 2: Dashboard with real-time updates
A monitoring dashboard user switches between 5 different views (metrics, logs, alerts, infrastructure, billing) every 30 seconds during an incident. Session length: 30 minutes = 60 view switches. Each view has two unhandled patterns — a WebSocket subscription for live data and a timer for auto-refresh:
- Memory cost: 60 cycles × 2 patterns × 8 KB = 960 KB retained
- Combined with other browser tabs (Slack, docs, Jira): can trigger browser tab throttling or freezing
The dashboard feels sluggish after 20 minutes. The user blames the server, but it’s the client leaking memory on every view switch.
Scenario 3: SaaS admin panel (enterprise use)
A power user keeps an admin panel open 8 hours/day, managing users, reviewing analytics, configuring settings. They navigate between sections 200 times per day. If five different sections each have 1-2 unhandled patterns (timers, listeners, subscriptions):
- Memory cost per day: 200 cycles × 1.5 patterns avg × 8 KB = 2.4 MB/day
- Over a 5-day work week: 12 MB retained
- Compounds across multiple browser tabs and windows
For Electron-based apps (VS Code, Slack, Figma), users often keep the app open for weeks without restart. Even small per-cycle leaks accumulate to hundreds of megabytes over that timespan.
Scenario 4: Video conferencing / collaboration tool
A 2-hour meeting with a collaboration tool that has a participant grid, chat panel, and shared whiteboard. Each participant join/leave event mounts/unmounts a video tile component. With 8 participants joining and leaving throughout the meeting (e.g., people dropping in and out), and each tile having 3 unhandled patterns (RAF for video rendering, canvas context listeners, media stream subscriptions):
- Memory cost: 8 cycles × 3 patterns × 8 KB = 192 KB retained
Seems small in isolation, but video rendering state is typically much larger than our 1,000-element benchmark array. If real per-cycle cost is 10× higher, that’s nearly 2 MB per meeting. A user attending 4–5 meetings daily accumulates 8–10 MB.
The threshold to watch: Mobile browsers aggressively kill tabs above 1.5–2 GB. Desktop thresholds are higher (4–8 GB depending on available RAM) but reachable with extended sessions across multiple leaking tabs.
How scan findings map to benchmarks
| Scan Pattern | Instances | Benchmark | Confirmed Rate |
|---|---|---|---|
missing_timer_cleanup | 24,538 | SC2 (onMounted timer) | 8.17 KB/cycle |
missing_event_listener_removal | 10,616 | SC1 (useEffect listener) | 8.05 KB/cycle |
missing_subscription_cleanup | 7,740 | SC3 (subscribe leak) | 8.01 KB/cycle |
missing_effect_cleanup | 5,200 | SC1 (useEffect listener)* | 8.05 KB/cycle |
missing_watch_stop | 3,989 | SC4 (watch stop handle) | 8.08 KB/cycle |
missing_raf_cancel | 1,230 | SC5 (RAF cancel) | 8.16 KB/cycle |
missing_lifecycle_cleanup | 1,641 | SC2, SC3 (lifecycle-based) | 8.01-8.17 KB/cycle |
missing_observer_disconnect | 787 | Not benchmarked** | — |
missing_renderer_unlisten | 123 | Not benchmarked** | — |
*SC1 demonstrates the useEffect-without-return mechanism using an event listener as the resource.
**Observer and Renderer2 scenarios need DOM. Same closure retention — expect equivalent rates.
The five benchmarked patterns cover 53,313 out of 55,864 findings (95.4%).
When missing cleanup is acceptable (and when it’s not)
Not every finding represents a production bug. Context determines impact.
Generally safe:
- Singletons that mount once. A root-level component that creates a
ResizeObserverand never unmounts. One observer for the app lifetime — no accumulation. - Short-lived timers. A
setTimeoutwith a 100 ms delay in a component that lives for seconds. The callback fires and releases before the next mount. - Finite observables. An HTTP request observable that completes after one emission. RxJS auto-unsubscribes on completion — no manual cleanup needed.
Dangerous:
- Frequently mounted components. Route-level views, list items, modals, tab contents. Each cycle without cleanup accumulates another retained reference chain.
- Multiple resources per component. The scan found components with up to 18
.subscribe()calls — 18 independent leak vectors compounding in a single mount/unmount. - Long-lived sessions. Dashboards, editors, chat apps. At 8 KB/cycle × 200 navigations × 3 patterns, that’s 4.8 MB retained in one session.
The grey zone:
- Rarely mounted components. A settings panel opened once per session. The leak exists but accumulation is negligible.
- Self-cancelling timers. A polling
setIntervalthat clears itself after receiving a response. Static analysis flags it, but the runtime leak is bounded and short-lived.
How to find this in your own codebase
Three approaches, ordered from fastest to most thorough.
Framework-specific grep. Quick, imprecise, but useful for a first pass:
# React: useEffect without cleanup return
grep -rn "useEffect(" src/ --include="*.tsx" --include="*.ts" | grep -v "return"
# Vue: onMounted without onUnmounted
for f in $(grep -rl "onMounted" src/ --include="*.vue" --include="*.ts"); do
grep -L "onUnmounted" "$f" && echo "MISSING: $f"
done
# Angular: .subscribe() without unsubscribe/takeUntil
for f in $(grep -rl "\.subscribe(" src/ --include="*.ts"); do
grep -L "unsubscribe\|takeUntil\|ngOnDestroy" "$f" && echo "MISSING: $f"
done
These are crude heuristics — they’ll flag singletons and self-completing observables as false positives. But they’re fast and catch the obvious cases in under five minutes.
Use AST-based static analysis. Tools like Code Evolution Lab walk the syntax tree, understand scope and lifecycle hooks, and match setup calls against cleanup calls. Significantly less noise than text-based grep.
Monitor heap growth in production. For deployed browser apps:
if (performance.memory) {
setInterval(() => {
const mb = (performance.memory.usedJSHeapSize / 1024 / 1024).toFixed(1);
console.log(`JS Heap: ${mb} MB`);
}, 60_000);
}
If the heap grows steadily without plateauing across navigation cycles, you have a leak. Static analysis finds the code; heap monitoring finds the runtime impact.
The fix is almost always one line
React: useEffect cleanup return
// Before (leaking)
useEffect(() => {
const handler = () => setWidth(window.innerWidth);
window.addEventListener('resize', handler);
}, []);
// After (one line added)
useEffect(() => {
const handler = () => setWidth(window.innerWidth);
window.addEventListener('resize', handler);
return () => window.removeEventListener('resize', handler);
}, []);
React: timer cleanup
// Before
useEffect(() => {
const id = setInterval(() => fetchData(), 5000);
}, []);
// After
useEffect(() => {
const id = setInterval(() => fetchData(), 5000);
return () => clearInterval(id);
}, []);
Vue: onMounted/onUnmounted pair
// Before
onMounted(() => {
window.addEventListener('scroll', handleScroll);
});
// After
onMounted(() => {
window.addEventListener('scroll', handleScroll);
});
onUnmounted(() => {
window.removeEventListener('scroll', handleScroll);
});
Vue: watch stop handle
// Before (stop handle discarded)
watch(source, (val) => { /* ... */ });
// After
const stop = watch(source, (val) => { /* ... */ });
onUnmounted(() => stop());
Angular: takeUntil pattern
// Before
ngOnInit() {
this.dataService.getData().subscribe(data => this.items = data);
}
// After
private destroy$ = new Subject<void>();
ngOnInit() {
this.dataService.getData()
.pipe(takeUntil(this.destroy$))
.subscribe(data => this.items = data);
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
Angular: Renderer2.listen
// Before (unlisten discarded)
ngAfterViewInit() {
this.renderer.listen(this.el.nativeElement, 'click', this.onClick);
}
// After
private unlisten: (() => void) | null = null;
ngAfterViewInit() {
this.unlisten = this.renderer.listen(this.el.nativeElement, 'click', this.onClick);
}
ngOnDestroy() {
this.unlisten?.();
}
The pattern is consistent across all frameworks: store a reference to the resource at setup, release it in the teardown hook. One line of cleanup code prevents linear memory accumulation.
How this study relates to existing approaches
Memory leak detection isn’t new. Browser DevTools, static analyzers, and runtime profilers each address the problem from a different angle. Here’s where this work fits.
Static analysis: Existing tools provide partial coverage:
- ESLint rules:
react-hooks/exhaustive-depscatches missing dependencies inuseEffect, but doesn’t detect missing cleanup returns. AuseEffectwithaddEventListenerand no cleanup passes linting without warnings. - Angular compiler: Warns about missing
ngOnDestroyimplementation, but can’t detect a component that hasngOnDestroybut doesn’t unsubscribe from all observables. - TSLint (deprecated): Had some subscription-related rules, but was never comprehensive.
Our AST-based detectors cover all three frameworks with pattern-specific severity classification. The scan has known limitations (see caveats below), but it’s the first cross-framework analysis at this scale.
Runtime detection: Chrome DevTools heap profiling is the gold standard for confirming leaks in production or local testing. Take a heap snapshot, interact with your app, take another snapshot, compare retained objects. It works, but:
- Requires manual reproduction of the leak
- Needs expertise to interpret retainer chains
- Can’t tell you where in your code the leak originates without significant investigation
- Only catches issues you know to test for
Libraries like why-did-you-render detect unnecessary re-renders (a performance concern), not memory retention (a resource concern). React DevTools’ Profiler shows component render counts but won’t flag a missing cleanup.
Our benchmark approach serves a different purpose: controlled scenarios with forced GC and statistical validation that convert “missing cleanup” from a code smell into a measurable rate (KB/cycle). This isn’t a replacement for DevTools heap profiling — it’s a way to attach concrete memory costs to patterns found through static analysis.
Scale and cross-framework scope: Previous memory leak studies typically examine:
- Individual codebases (case studies)
- Synthetic examples (educational content)
- Single-framework analyses
Our contribution:
- 500 public repositories scanned (55,864 findings across 714,217 files)
- Cross-framework taxonomy: React, Vue, Angular patterns analyzed with the same detector architecture
- Controlled benchmarks: 5 scenarios × 50 repeats × 100 cycles = 25,000 mount/unmount operations measured
To our knowledge, this is the first study to combine large-scale static analysis with controlled runtime measurements across multiple frontend frameworks. The scan quantifies prevalence (86% of repos have at least one pattern). The benchmarks quantify cost (~8 KB/cycle for the tested payload). Together, they convert “you should clean up your subscriptions” from general advice into a quantifiable engineering decision: if you have X patterns in components that mount Y times per session, you’re retaining approximately Z MB.
Caveats and limitations
Several factors are worth considering when interpreting these results.
Synthetic components, not real framework components. Production React/Vue/Angular components carry framework overhead — virtual DOM nodes, fiber trees, reactive proxies, change detection metadata. Our simulated components hold only a number array and a closure. Real-world per-cycle leak size could be larger (heavier state, DOM references) or smaller (lighter state, shared references). The ~8 KB/cycle figure is specific to our 1,000-element payload and should be treated as a baseline rate for a small-to-medium component, not a universal constant.
Heuristic severity model. Severity is assigned based on pattern type and context (component lifecycle → high, unknown → medium). It doesn’t account for runtime frequency — a high-severity finding in a component that mounts once per session poses less risk than a medium-severity finding in a list item that renders 500 times. Treat severity as a triage starting point, not a definitive risk score.
Static analysis has false positives and false negatives. The detectors match structural patterns — useEffect without a return, .subscribe() without .unsubscribe() in the same scope. False positives include: singletons that intentionally never clean up, finite observables that complete automatically, timers with delays shorter than the component lifetime. False negatives include: cleanup logic split across files, dynamic subscriptions created by helper functions, and third-party libraries that create resources internally.
Formal precision and recall measurement would require ground-truth labelling across 714,000 files, which was not feasible. As a practical estimate: the detectors have high precision for structural pattern matching (a useEffect without cleanup return is definitionally a useEffect without cleanup return), moderate precision for impact prediction (not every missing cleanup causes observable memory growth), and unknown recall for indirect patterns. The scan should be interpreted as a reliable lower bound on prevalence, not an exhaustive inventory.
Node.js environment, not browser. The benchmarks run in Node.js with simulated framework lifecycles. Browser-specific factors — DOM node retention, detached DOM trees, Web API registrations (IntersectionObserver, MutationObserver) — are not captured. The closure-based retention mechanism is identical across environments, but total per-cycle cost in a real browser is likely higher due to DOM overhead.
Forced GC overstates the GOOD variant’s cleanliness. In production, GC timing is nondeterministic. The GOOD variant’s ~2 KB total growth reflects a near-ideal scenario where GC runs immediately after cleanup. Real applications will show more short-term variance in the GOOD case, though the long-term trend should remain flat.
Isolated benchmarks, not system-level measurement. Memory pressure from competing browser tabs, OS-level memory compression (particularly on macOS), and process-level limits (iOS WebView) all affect when leaks become user-visible. Our benchmarks measure the per-pattern cost in isolation; real-world impact depends on the deployment environment and user behavior.
What this means for your codebase
If you’re building or maintaining a single-page application in React, Vue, or Angular, here’s a practical priority order based on the data:
- Audit timer cleanup first. 43.9% of all findings. Search for
setTimeout/setIntervalin component code and verify each has aclearTimeout/clearIntervalin the teardown hook. - Then event listeners. 19.0% of findings. Every
addEventListenerneeds aremoveEventListenerin cleanup. - Then subscriptions. 13.9%. Every
.subscribe()needs an.unsubscribe()ortakeUntil. - Then framework-specific patterns.
useEffectreturns (9.3%), Vue watch stop handles (7.1%), lifecycle hooks (2.9%). - Monitor in production. Add periodic heap logging. If it grows linearly during a session, you have a leak somewhere.
The 86% prevalence figure sounds alarming, but it measures potential — many findings sit in low-risk contexts like singletons or rarely mounted components. The dangerous ones are in components that mount and unmount repeatedly during normal user sessions. Prioritize those.
The benchmarks make the cost concrete: ~8 KB per cycle per unhandled pattern, compounding linearly with navigation count. On mobile, that trajectory reaches tab-kill territory within a moderate session. On desktop, it’s the difference between an app that stays responsive through an 8-hour workday and one that gets sluggish after lunch.
The fixes are one-line additions. The cost of not making them compounds with every route change your users make.
Appendix: source code and data reference
All code, data, and results are in the empirical-study repository under studies/03-memory-leaks/.
Static analysis (Step 1)
| File | What it does |
|---|---|
src/step1-repo-scan/detector/react-detector.ts | React useEffect, listener, timer, subscription leak patterns |
src/step1-repo-scan/detector/vue-detector.ts | Vue lifecycle, watch/watchEffect, event bus patterns |
src/step1-repo-scan/detector/angular-detector.ts | Angular subscribe, ngOnDestroy, Renderer2 patterns |
src/step1-repo-scan/detector/types.ts | ScanIssue, ScanResult, AggregatedResults interfaces |
src/step1-repo-scan/detector/parser.ts | Babel parser config with error recovery |
src/step1-repo-scan/scanner.ts | Repo cloning, file collection, scan orchestration |
src/step1-repo-scan/aggregate-results.ts | Cross-repo summary, breakdown tables |
Benchmarks (Step 2)
| File | What it does |
|---|---|
src/step2-benchmarks/run-all.ts | Orchestrator with --cycles, --repeats, --framework flags; mean/std/rate output |
src/step2-benchmarks/calibrate-sizes.ts | Array size calibration with SNR analysis |
src/step2-benchmarks/scenarios/react-useeffect-leak.ts | SC1: EventEmitter listener without removal |
src/step2-benchmarks/scenarios/vue-onmounted-leak.ts | SC2: Long-delay timer without clearInterval |
src/step2-benchmarks/scenarios/angular-subscribe-leak.ts | SC3: Observable subscriber without unsubscribe |
src/step2-benchmarks/scenarios/vue-watch-stop-leak.ts | SC4: Watcher registry without stop handle |
src/step2-benchmarks/scenarios/raf-cancel-leak.ts | SC5: Pending setTimeout (RAF proxy) without cancel |
Result data
| File | Contents |
|---|---|
results/scan-*.json | Raw per-repo scan issues |
results/summary-*.json | Aggregated breakdowns by framework, type, context, severity |
results/bench-*.json | Benchmark metrics: per-scenario mean, std, rate, raw snapshots |
results/calibration-*.json | Array size calibration: SNR, growth rates, recommendations |
data/app-samples.md | Curated list of 500 public repos used in the scan |
docs/calibration-guide.md | How to run and interpret calibration experiments |
Built at Stack Insight.