There's a thing that tends to happen with applications that run for a long time: they start out fine, respond fast, and then, without anyone quite noticing why, they get slower and slower until they crash. When that happens in the middle of the night, with no specific alerts, no obvious error in the logs, chances are you're dealing with a memory leak. And the first thing worth saying is that it's not some unsolvable mystery, even though it looks scary the first time you see it climbing on a Grafana graph and never coming back down. A memory leak, at its core, is memory that shouldn't be in use anymore but is still referenced somewhere in your code. The garbage collector, whether it's Node's, the JVM's, or whatever else, can't free something that still has an active reference, even if that reference makes no sense anymore. That's the heart of the problem: it's not the language that has a bug, it's you who, without meaning to, is holding onto things that should already be dead. It's worth separating this right away from "normal" high memory usage. An app using 2GB of RAM because it has a large cache doesn't necessarily have a leak, it's expected behavior. The signal that tells the two apart is the trend over time. If you leave the app running under the same load for hours and memory keeps climbing without ever coming down, even after the garbage collector runs, that's when you actually have a problem. Spikes that go up and down are normal. A line that only goes up is the one that should worry you. ### Symptoms worth paying attention to Before rushing to profiling tools, it's worth looking at the symptoms first. An app with a leak tends to get progressively slower, not suddenly, but gradually, over hours or days, because the garbage collector starts running more often and burning more CPU time just trying to free up space. In some cases you'll see that reflected in latency that creeps up slowly. In others, the process simply dies with an out-of-memory error, usually after a fairly predictable amount of time, which is already a useful clue: if the app always falls over after about six hours, that tells you a lot about the leak's growth rate. ### Where they tend to hide, depending on the stack The causes vary quite a bit depending on the stack, so it doesn't make much sense to treat this as one generic problem. In JavaScript and Node, the usual suspects are closures holding onto references they don't need, event listeners that get registered and never removed, `setInterval` or `setTimeout` calls left hanging, and unbounded caches that just keep growing. If you work with React or Vue, the classic case is a component that subscribes to something in `useEffect` or `created` and never cleans up when it unmounts — the subscription stays alive, tied to a component that no longer exists in the DOM but still exists in memory. In Java, the most common pattern tends to be static collections that grow forever, listeners nobody ever removed, or careless use of `ThreadLocal` in reused thread pools. In Python, it usually shows up as circular references combined with `__del__` methods, which confuse the garbage collector, plus poorly managed global caches. None of these causes are exotic. Most of the time it's perfectly reasonable-looking code that someone wrote without thinking through the object's full lifecycle. ### Tools for investigating properly This is where most posts on this topic become useless, because they stay theoretical. In practice, you need to take heap snapshots and compare them. In Chrome DevTools, the Memory tab lets you take a snapshot, let the app run under load for a few minutes, take another snapshot, and then compare the two to see what grew. It's that comparison, not the isolated snapshot, that actually shows you the problem — staring at a single snapshot with nothing to compare it against is usually a waste of time, because everything looks big. In Node, besides DevTools, `clinic.js` and the `heapdump` module help a lot, especially in production, where you can't always open the inspector directly. In Java, VisualVM and Eclipse Memory Analyzer (MAT) are still the go-to tools, and `jmap` gives you a dump you can then analyze at your own pace. For ongoing monitoring, it's worth tracking RSS and heap metrics in Prometheus or something similar, because that's what will tell you, with real data, whether the growth is constant or eventually levels off. ### A concrete example Let me show you a typical case, because without code this stays too abstract. Picture a React component that subscribes to scroll events on a page: ```javascript useEffect(() => { window.addEventListener('scroll', handleScroll); }, []); ``` Looks harmless, but the cleanup is missing. Every time this component mounts and unmounts, a new listener stays attached to `window`, and none of them ever get removed. If the user navigates between pages several times, each visit adds another listener, each one holding a reference to the old component, which should have been freed long ago. In a heap snapshot, this shows up as a growing number of instances of a component that shouldn't exist anymore, and the retainer path leads you straight to `window` as the root. The fix is simple: ```javascript useEffect(() => { window.addEventListener('scroll', handleScroll); return () => window.removeEventListener('scroll', handleScroll); }, []); ``` One line. But finding this without knowing how to read a heap snapshot can take you days, because the symptom, memory climbing, doesn't point directly to the cause. That's why the investigation process matters more than memorizing a list of common causes. ### How to investigate without losing your mind The process that tends to work is pretty much always the same: first you try to reproduce the leak in isolation, under a repeated and controlled load, because trying to hunt this down directly in production, with real and unpredictable traffic, is a lot harder. Then you take snapshots at regular intervals and compare what grows between them, ignoring what stays stable. From there you follow the retainer path, meaning whoever is holding onto that reference, until you reach the root of the problem. And before touching the whole codebase, it's worth isolating the hypothesis in a minimal test, just to confirm it's actually that before you start changing code at random. ### Preventing is cheaper than hunting Once you've fixed a leak, there's always the temptation to move on and forget about it, but it's worth building a few habits. Whenever you register a listener, a timer, or a subscription, ask yourself right away where it's going to be removed. In JavaScript, `WeakMap` and `WeakRef` exist exactly for cases where you need to hold a reference without stopping the garbage collector from reclaiming it. And if your app has automated tests, a test that runs the same operation hundreds of times and checks that memory stabilizes, instead of climbing forever, catches a lot of these issues before they ever reach production. Not everything is your fault, by the way. Sometimes the leak lives in a third-party dependency, and the investigation looks similar but the destination is different: you isolate the problem, confirm it with a minimal reproducible case, and report it to the library's maintainer with that case attached, because without it most maintainers can't do much. At the end of the day, a memory leak isn't much of a mystery, it's just memory that nobody told the garbage collector to let go of. The tedious part is finding out who's holding onto it, but once you've done that once or twice, you start recognizing the patterns with a speed that surprises even yourself.

