
If you've been building frontend apps for a few years, you've almost certainly shipped memory leaks.
Not because you're careless.
But because JavaScript makes it very easy to create long‑lived references and very hard to notice them until users complain.
This post is not an academic explanation of garbage collectors.
It's a practical walk through how memory problems actually show up in modern SPAs, why they slip past reviews, and how experienced engineers usually discover them too late.
If this feels familiar at times, that's the point.
First: How JavaScript memory management actually works (only the parts that matter)
You don't need to know GC algorithms to debug memory leaks.
You need one mental model:
If something is still reachable, it will not be garbage collected.
That's it.
JavaScript engines use tracing garbage collection.
In plain terms:
- The engine starts from roots (global scope, active stack frames, DOM references)
- It walks all reachable objects
- Anything not reachable gets freed
No reference means eligible for collection.
One reference means lives forever.
There is no concept of "unused" in JavaScript.
Only reachable and unreachable.
Most memory bugs are just unexpected reachability.
Why memory issues feel invisible in frontend apps
Memory leaks rarely break features.
They degrade sessions.
Real symptoms we see in production:
- Tab memory grows steadily over time
- UI interactions feel slower after long usage
- Mobile browsers reload the tab "randomly"
- Electron apps balloon to gigabytes
And the worst part?
Refreshing the page "fixes" everything.
So bugs don't reproduce easily.
Which means they ship.
The most common frontend memory leaks (you've seen these)
1. Closures that accidentally live forever
Closures are not the problem.
Long‑lived closures holding short‑lived data are.
Real scenario:
- You create a callback inside a component
- It captures props, state, or large objects
- That callback is stored somewhere global
Example pattern:
const handlers = [];
function registerHandler(data) {
handlers.push(() => {
console.log(data.largeObject);
});
}
Even if data is no longer "used" anywhere else, It's reachable through the closure.
So it stays.
In SPAs, this often happens with:
- Event buses
- Analytics hooks
- Global stores
You don't see the leak.
You feel it hours later.
2. Event listeners that are never removed
This is the classic one.
And still one of the most common.
Realistic SPA scenario:
- Component mounts
- Adds a window or document listener
- Component unmounts
- Listener stays
That listener keeps:
- The callback
- Everything the callback closes over
- Often the DOM node itself
Multiply this by route changes.
Memory grows linearly with navigation.
And yes, frameworks reduce this risk.
But they don't eliminate it.
Especially when you step outside React/Vue abstractions.
3. Detached DOM nodes (the silent killer)
This one surprises even senior engineers.
What happens:
- A DOM node is removed from the document
- A JS reference to it still exists
Now you have a detached DOM tree.
It's invisible in the UI.
But fully alive in memory.
Common sources:
- Caching DOM nodes for reuse
- Storing refs in global maps
- Third‑party libraries holding references
Detached DOM leaks are especially expensive.
They include:
- Layout data
- Styles
- Subtrees
This is how tabs reach hundreds of MB.
4. Caches that only grow
Caches are just memory leaks with good intentions.
Typical pattern:
const cache = new Map();
function getData(key) {
if (!cache.has(key)) {
cache.set(key, expensiveFetch(key));
}
return cache.get(key);
}
What's missing?
- Size limits
- Eviction
- Expiration
In real apps:
- Keys are user IDs
- Or route params
- Or feature combinations
The cache grows with usage.
Nobody notices, until production traffic hits.
5. Subscriptions that outlive their owners
Modern apps are full of subscriptions:
- Observables
- Stores
- WebSockets
- Custom event emitters
The leak pattern:
- Component subscribes
- Component unmounts
- Unsubscribe never happens
Now the store holds a reference to the subscriber.
Which holds references to state.
Which holds references to more.
This is closure + event listener + cache, all combined.
How memory problems actually show up in real products
This is important.
Memory leaks rarely announce themselves as "memory leaks."
They show up as:
- Scroll jank after long usage
- Slow typing in inputs
- Increased GC pauses
- Random tab reloads on mobile
Teams often chase rendering or network issues.
The real culprit is heap growth.
Debugging memory leaks (what actually works)
Step 1: Confirm if you have a leak
In Chrome DevTools:
- Open Performance or Memory tab
- Record a heap snapshot
- Interact with the app
- Record another snapshot
If memory keeps growing and doesn't stabilise, You have a leak.
Step 2: Look for retained objects, not allocations
This is where many people get stuck.
Allocations are normal. Retention is the problem.
Focus on:
- Detached DOM nodes
- Listener callbacks
- Large arrays or maps
Ask:
"Why is this object still reachable?"
That question solves most leaks.
Step 3: Follow the retaining path
DevTools will show you:
Object → who references it → who references that
This chain is gold. It almost always points to:
- A global
- A cache
- A long‑lived subscription
Preventing leaks (without paranoia)
You don't need to obsess over memory. You just need a few guardrails.
1. Treat long-lived objects as radioactive
Globals, singletons, stores, caches.
Anything long‑lived should:
- Avoid capturing large objects
- Have clear lifecycle rules
2. Make cleanup symmetrical
If you:
- addEventListener, also removeEventListener
- subscribe, also unsubscribe
- start, also stop
Symmetry catches leaks early.
3. Be suspicious of "just in case" caches
If there's no eviction strategy, it's not a cache. It's a leak waiting for traffic.
4. Measure long sessions, not cold loads
Memory bugs hide in long sessions.
Test apps the way users use them:
- Navigate repeatedly
- Open and close panels
- Leave tabs open
This is where leaks surface.
Wrapping Up
Junior bugs break features. Senior bugs degrade systems over time. Memory leaks live squarely in the second category. They don't mean you're bad at JavaScript. They mean your app lived long enough to matter.
If this post made you think:
"We might be leaking memory somewhere…"
You're probably right. And now you know where to look.