It was 3 AM on a Tuesday. Or maybe Wednesday—the days blur when you’re chasing a ghost.
Our React dashboard, which had run beautifully for weeks, started dying. Slowly at first. A click took an extra second. Then five. Then the tab just… froze. I popped open Chrome DevTools, clicked the Memory tab, took a heap snapshot, and nearly choked. The app was eating 1.2 GB of RAM. For a dashboard that showed, at most, a thousand rows of data.
We didn’t have a bug. We had a memory leak. And it had been there for months, hiding in plain sight.
That night taught me something uncomfortable: You can write perfect‑looking code and still be slowly poisoning your users’ browsers. Memory leaks aren’t crashes—they’re death by a thousand cuts. The tab doesn’t throw an error. It just gets… tired. Sluggish. Then it dies.
Let me walk you through what I learned. Not as a list of bullet points, but as a journey into the invisible sculpture that is your app’s memory.
The Gallery of Forgotten References
Think of your JavaScript app as an art gallery. Every object, every variable, every closure is a painting on the wall. The garbage collector (GC) is the night janitor. He comes in periodically, looks around, and removes any painting that doesn’t have a visitor looking at it.
But the janitor is polite. He only removes something if nobody can reach it. If you still have a reference—a path from the root (like window or a global variable)—he leaves it. Forever.
A memory leak is simply this: you keep a reference to something you no longer need. The janitor sees it, shrugs, and walks away. And that painting stays on the wall, accumulating, until the gallery bursts at the seams.
As senior devs, we know the usual suspects. But knowing them and feeling them are different things. Let’s walk the gallery together.
Suspect 1: The Accidental Global
Remember when we all learned that omitting var, let, or const creates a global? We laughed. We said “I’d never do that.”
Then I found this in production:
function processUser(user) {
result = heavyComputation(user); // forgot 'let'
return result;
}
result became a global. It sat on window (or global in Node) forever. Every call overwrote it, but the previous object was still referenced? Actually, no—assignment overwrites the reference, so the old object is freed. But the real leak came later when a library attached something to window.result and never cleaned up.
The art lesson: Globals are permanent walls in your gallery. The janitor never touches them. If you must use a global, you’d better be ready to set it to null when you’re done.
How to find it: Run your app with 'use strict'. Or use ESLint’s no-undef. And in DevTools, check window for unexpected properties.
Suspect 2: The Clinging Closure
Closures are beautiful. They’re the watercolors of JavaScript—soft, elegant, capturing context. But they can also be a trap.
function createHeavyHandler() {
const largeData = new Array(1000000).fill('*');
return function() {
console.log('handler called');
// largeData is never used here!
};
}
setInterval(createHeavyHandler(), 1000);
Every second, a new closure is created. That closure holds a reference to largeData because the function could use it. The GC can’t tell that you never actually touch largeData. So all those million‑element arrays stay alive. Forever.
I debugged a similar leak in a real app: an event handler that closed over a massive Redux store. The handler only used a single flag, but the entire store was captured. Each new listener added another copy of the store.
The fix: Be explicit. If a closure doesn’t need a variable, don’t let it capture it. Refactor, or use null to break the chain.
How to find it: Take heap snapshots and look at the retaining paths for large objects. You’ll see a closure context holding onto data you thought was gone.
Suspect 3: Forgotten Timers and Event Listeners
This one stung me the worst.
We had a single‑page app with modals. Each modal opened, fetched data, set up a setInterval to refresh that data every 30 seconds, and attached a resize listener to adjust the modal’s position.
When you closed the modal, we removed the DOM elements. But we forgot to call clearInterval and removeEventListener.
Result: Every modal you ever opened was still running its timer. The timer callback still held a reference to the (now detached) DOM nodes and the component’s state. The DOM nodes were gone from the page, but they were still in memory because the timer’s closure kept them alive.
The janitor couldn’t touch them. They were orphaned paintings in a hidden room.
The rule: For every setInterval, setTimeout, addEventListener, or Observer, you must have a cleanup. In React, that’s the useEffect cleanup function. In vanilla JS, it’s a destroy method.
How to find it: Use the Performance panel to record allocation timelines. If you see memory growing in a sawtooth pattern (up, down, but never back to baseline), you’ve got a leak. Then use heap snapshots to see what’s retaining those detached DOM nodes.
Suspect 4: The Ever‑Growing Cache
Caches are supposed to make things faster. But without a size limit or expiration policy, they’re just a slow leak in disguise.
const cache = {};
function fetchUser(id) {
if (cache[id]) return Promise.resolve(cache[id]);
return api.getUser(id).then(user => {
cache[id] = user;
return user;
});
}
This is beautiful—until you’ve fetched ten million unique user IDs. Then cache holds every single one. Forever.
The art: A cache is a sculpture that must be pruned. Use Map with a TTL (time‑to‑live), or implement an LRU (least recently used) cache. Or use WeakMap when the keys are objects that can be garbage‑collected elsewhere.
How to find it: Look for large objects in heap snapshots that you didn’t expect. If you see a giant object with thousands of keys and you never intentionally built it, you’ve found your leak.
The Detective’s Toolkit
Over the years, I’ve built a mental checklist. When a user reports “the tab gets slow after an hour,” I don’t guess. I use:
Chrome DevTools → Memory → Heap snapshot
Take one before and after an action. Compare. The “Comparison” view shows you what’s been added and not freed.Allocation instrumentation on timeline
Records every allocation with a stack trace. Lets you see exactly which function created the leaking object.Performance monitor (under “More tools”)
Watch JS heap size in real time. If it never plateaus, you’re leaking.Detached DOM nodes in heap snapshots
Filter for “Detached” – these are DOM elements no longer in the page but still referenced. A huge red flag.Node.js ––inspect
Same DevTools, but for backend. Useprocess.memoryUsage()as a cheap health check.
Preventing Leaks: The Art of Letting Go
The most important shift in my thinking wasn’t technical. It was emotional. I stopped treating memory as infinite. I started treating every reference as a conscious choice.
Ask yourself, with every variable, every closure, every listener:
“When does this end? What cleans this up?”
If you can’t answer, you’ve painted a picture that will hang in the gallery forever.
-
Use
WeakMapandWeakSetfor metadata attached to objects that you don’t want to keep alive. -
Prefer
letandconstover globals. -
In React, always return a cleanup from
useEffect. - For long‑lived apps (SPAs, Node services), periodically take heap snapshots in CI to detect regressions.
-
Use
AbortControllerto cancel fetch requests and remove event listeners in one go.
The Human Truth
Memory leaks aren’t a mark of shame. They’re a natural consequence of writing dynamic, long‑running applications. Every senior I know has a war story. Mine involved a dashboard and a 3‑AM heap snapshot. Yours might be different.
But the art of memory management is the art of intentional forgetting. It’s about knowing when to hold on and when to let go. It’s a dance between you and the garbage collector—a silent partnership.
The janitor wants to help. He really does. But he needs you to stop pointing at paintings you no longer care about.
So go ahead. Open DevTools. Take a snapshot. See what’s still on your walls. And then, for the sake of your users’ RAM, start letting go.
Top comments (1)
This isn't the case though, as the closure in the example holds no reference to
largeData. If it actually did, then presumably it would actually need the data, making it not a memory leak, but legitimate memory usage.No offense, but that's quite the beginner oversight. When dealing with temporary objects like modals, get into the habit of giving them an
AbortControllerthe moment you open it and immediately attaching an event listener toabortit whenever you're done with that object. Then use the controller's signal for any event or timer. You can even listen for theabortevent on the signal, if you need to call your own cleanup code.This is one reason custom elements are so good: You can create an
AbortControllerin theirconnectedCallbackand abort it in thedisconnectedCallback; store it in a private member and expose the signal via a getter.Then you only need to make sure any reference to itself your component creates on the outside, like event listeners on parents, etc. make use of the signal. This can still be missed when writing code, but it's a lot easier to spot when you're used to seeing it everywhere.
Memory management in JS can get a bit more complicated if you do some really advanced fuckery, but that's usually only relevant for framework work, not for normal applications.