Everything You Subscribe To, You Must Unsubscribe From
What a slow memory leak in a long-running chat application taught me about lifecycles, ownership, and the bugs that only appear after hours of use.
Some bugs announce themselves. The page crashes, the API returns an error, a test fails. You fix them and move on.
Other bugs are patient. Everything works in development. Everything works in QA. Everything works in the demo. Then, after a support agent has had the application open for a full shift, it starts to feel sluggish. Typing lags. Scrolling stutters. Eventually the browser tab is using an absurd amount of memory and the only fix anyone knows is “refresh the page.”
We had one of these in the agent-facing chat application for MSG91 Hello — a single-page Angular app that support teams keep open all day while handling conversations. Investigating it changed how I think about writing long-running software, and the lessons apply far beyond Angular.
Why long-running apps are different
Most web applications are designed, implicitly, for short sessions. A user opens a page, does something, and navigates away. Each navigation clears a lot of state, and small inefficiencies never have time to add up.
A support console or chat client is different. It is open for eight hours or more. Agents switch between hundreds of conversations. Messages stream in continuously over WebSockets. Components are created and destroyed constantly as the agent moves around.
In that environment, a leak of a few kilobytes per conversation switch is invisible in a five-minute test and very visible after a few hundred switches. Time multiplies small mistakes.
How to find it
When an app gets slower over time, it is tempting to guess: “it must be the message list,” “it must be images,” “it must be that new feature.” Guessing wastes time. Measuring is faster.
The process I recommended to the team:
- Reproduce the long session quickly. Script or manually repeat the action that real users do many times — in our case, opening and closing conversations. Do it a hundred times instead of waiting eight hours.
- Take heap snapshots in the browser developer tools: one at the start, one after many repetitions, one after forcing garbage collection.
- Compare the snapshots. Look for objects whose count keeps growing with every repetition and never goes back down. Those are your suspects.
- Follow the retainers. For each suspect, the developer tools show what is still holding a reference to it. That chain of references usually points straight at the cause.
For a leak like ours, this is exactly what you would expect to see: component instances that should have been destroyed when an agent left a conversation, still alive, with a chain of references leading back to something that lives for the whole session.
The root cause: listeners that outlived their owners
Every time a conversation view was opened, it registered event listeners on the shared socket connection — for new messages, typing indicators, status updates. That is normal.
The problem was that some of those listeners were never removed when the view was destroyed. The socket connection lived for the whole session, and it kept a reference to each listener. Each listener was a closure that referenced the component. So the component — with its data, its DOM references and everything it pointed to — could never be garbage collected.
One conversation switch leaked one component. A few hundred switches leaked a few hundred. Worse, listeners like these can keep running, doing work for views nobody can see anymore — so the app wastes CPU as well as memory.
This pattern is extremely common, and not just in Angular. It happens with event emitters in Node.js, observers in mobile apps, subscriptions in React effects, and listeners on window or document in plain JavaScript. Whenever a short-lived object subscribes to a long-lived one, the long-lived one keeps it alive until it explicitly unsubscribes.
The fixes
These are the changes I recommended, and they apply to almost any app with the same shape.
1. Tie every subscription to a lifecycle
In Angular, the standard pattern is to have a component-level “destroy” signal and complete every subscription when it fires — for example with a destroy$ subject and takeUntil(destroy$), or the newer built-in equivalents. The principle is framework-independent: every subscription should have an owner, and when the owner dies, the subscription dies with it.
The practical rule I now give teams: if you write the word “subscribe,” “on,” or “addEventListener,” you must be able to point to the line that undoes it. If you cannot, it is probably a leak.
2. Do not let the UI hold the whole history
Even without leaks, a chat view that keeps every message in memory and in the DOM will slow down in a long conversation. The answer is message windowing: render only what is near the viewport and load older messages on demand. It keeps memory and DOM size roughly constant regardless of how long a conversation is.
3. Centralise long-lived connections
Instead of letting many components attach listeners directly to the socket, a single service can own the socket and expose streams that components subscribe to — with proper cleanup. That gives you one place to reason about the connection’s lifecycle, and it makes leaks much easier to spot.
4. Make leak checks part of testing
Most test suites check that features work. Very few check that the application can run for a long time. A simple repeated-action test with a memory check at the end catches this whole category of bugs before users do.
What I took away
The technical fix for a leak like this is usually small — a few lines in the right places. The lessons are bigger.
Ownership is the real question. Most memory leaks are not about memory at all. They are about unclear ownership: nobody decided which object is responsible for ending a relationship. Clear ownership of lifecycles solves leaks, and it usually makes code easier to understand too.
Test the way your users actually use the product. Our users kept the app open for an entire shift. Our tests and demos never did. The gap between “how we test” and “how they use it” is where this kind of bug lives.
Measure before you guess. Heap snapshots turn a vague complaint — “it gets slow after a while” — into a precise cause. Guessing can take weeks and produce the wrong fix.
Write the checklist down. At the end of this investigation, I wrote up a short checklist and sent it to the team: how to reproduce, how to take and compare snapshots, what patterns to look for, how to verify the fix. The next time someone hits a similar issue, they will not start from zero.
If you build anything that people keep open for hours — dashboards, chat tools, editors, monitoring screens — assume this bug is already in your code somewhere. It probably is. The good news is that it is very findable, once you know where to look.