skip to content

Closed chat screens keep receiving pushed messages and memory grows with every open-and-close cycle — how do you diagnose and fix it?

level: seniorimportance: should knowfreq 58%

answer

  1. receiving means still subscribed
  2. the handle was thrown away
  3. source holds callback holds screen
  4. count starts against ends
  5. owner's lifetime cancels it

basics

~20 s

Subscriptions are never cancelled, so each closed screen leaves a live one behind. The source holds the subscriber, the subscriber holds the screen, and nothing can be released. Fix it by tying every subscription's disposal to the lifetime of the owner that created it.

solid answer

~40 s

The symptom names the cause: work that outlives the screen means the subscription outlived the screen. Each time a conversation opens, a subscription starts and its handle is dropped; closing the screen removes it from view but cancels nothing. The reference chain keeps everything alive — the live source holds the subscriber callback, and that callback closes over the screen — so neither is collectable, and after n cycles n subscribers are each doing the work for every message. Diagnose by comparing how many subscriptions were started against how many ended, and by following what still references the destroyed screens. Fix by giving every subscription an owner: collect the handles the screen creates and cancel them all when it is destroyed, so disposal is structural rather than something each call site must remember.

code

pseudocode · 7 lines
pseudocode
function onConversationOpened(conversationId):
    messagesFrom(conversationId).subscribe(function(message) {
        view.append(message)        // handle discarded here
    })

// screen closed: nothing cancels the run, the source still holds this
// callback, and the callback still holds `view`, so neither is released

go deeper

for a junior

Take away the rule: a subscription you start must be stopped by whoever started it. Losing the handle means the work keeps going after the screen is gone.

for a middle

Explain the reference chain — the live source holds the subscriber callback, which captured the screen — and why that makes the screen uncollectable rather than merely idle.

for a senior

Diagnose with cheap evidence first: subscriptions started versus ended per cycle, then the retained-object chain. Fix structurally by scoping disposal to the owner, not by patching one call site.

for a principal

Decide the codebase-wide convention: unowned subscriptions are defects, every owner exposes a disposal scope, and lifetime is reviewed the way an opened resource without a release would be.

## Read the symptom backwards "Closed screens still receive messages" is not a delivery bug; it is a **lifetime** bug stated in delivery terms. Values only arrive at a subscriber that is still subscribed, so if a destroyed screen is receiving them, its subscription is still live. Every other part of the symptom follows from that one fact: memory grows because each cycle adds one more live subscription, and the per-message cost grows because every one of those subscribers does the work again. ## Why nothing gets released The references point the wrong way for collection: - The **source** is long-lived — a conversation feed that keeps running regardless of who is listening. - The source holds the **subscriber** it was given, because it must, in order to push values to it. - The subscriber is a callback that **closes over the screen** (or its view) so it can append messages. So the chain runs source → subscriber → screen. The screen is unreachable from the application's own structures, and entirely reachable from the source. Nothing can be reclaimed. This is why the leak is invisible in review: the screen code looks correct, the pipeline looks correct, and the missing line is the one nobody wrote. ## Diagnosing it Three checks settle it quickly, in order of cost: 1. **Count starts against ends.** Instrument the subscribe path and the ending path with a counter, exercise the open-and-close cycle a dozen times, and compare. A count of live subscriptions that only rises is the diagnosis; no further evidence is needed. 2. **Follow what holds the destroyed objects.** Inspect the retained objects after a cycle and walk the reference chain back. It will end at the source, through a callback that captured the screen. 3. **Count the deliveries per message.** If one incoming message produces n handlings after n cycles, each cycle left exactly one subscriber behind, which also tells you the leak is per-subscription rather than per-message. The distinguishing feature against other resource growth is the **step shape**: each open-and-close cycle adds one increment and nothing ever returns it. ## Fixing it: give every subscription an owner The fix is not "remember to cancel". Remembering is what already failed. Make disposal structural, so that a subscription cannot outlive the thing that created it: - **Collect the handles.** The screen owns a container; every subscription it starts goes in; destroying the screen cancels everything in the container. One line at the end of the screen's life covers every subscription it ever started, including ones added later by other developers. - **Bind to the owner's end-of-life signal.** Express the screen's destruction as a value on a stream of its own and let each pipeline stop itself when that arrives. The pipeline then ends from the inside, without the screen having to hold handles at all. - **Never let a subscription escape without an owner.** A subscribe call whose handle is discarded is the defect to catch in review. Treat an unowned subscription the way you would treat an opened resource with no matching release. | Approach | Where disposal lives | Fails when | |---|---|---| | Cancel by hand at each call site | Every call site | Anyone adds a subscription and forgets | | Handles collected in an owner-scoped container | One place per owner | The container is not emptied on destruction | | End-of-life signal folded into the pipeline | Inside the pipeline | The signal is never emitted | ## What makes this leak specific to subscriptions Two details are worth naming because they explain why the same code is fine elsewhere. First, the source outlives the subscriber by design: it exists to serve whoever is listening, so it is the long-lived end of the reference chain. Second, the callback is a closure, so the capture is implicit — the developer wrote "append to the view", not "hold the view forever", and the retention is invisible at the call site. A source that finishes on its own tears its subscribers down at completion and hides this class of bug entirely, which is why it only appears once a genuinely long-lived feed is involved. In an interview, say the lifetime rule first — **every subscription has an owner whose end cancels it** — then the reference chain that explains the memory, then the cheap counter check that proves it. A candidate who jumps straight to inspecting retained objects without stating the rule usually fixes the one screen in front of them and leaves the pattern in place everywhere else.

  • The same screen code causes no leak on a different source. Why?
    Because that source finishes on its own. A run that completes signals its end, and the chain tears itself down, releasing the subscriber and everything it captured. The leak needs a source that keeps running regardless of listeners, which is exactly what a live conversation feed is.
  • Would delivering values through a weakly held reference to the screen fix it?
    It removes the retention but not the leak. The subscription is still live, still consuming from the source and still doing per-message work for a screen that is gone; you have converted a memory leak into a work leak plus a source that never learns to stop. Cancel instead.
  • Why is one message being handled several times a useful measurement here?
    Because the multiplier counts the abandoned subscriptions directly. After n open-and-close cycles, n handlings per message means exactly n subscribers survived, which both confirms the diagnosis and tells you the leak is one per cycle rather than growing some other way.

saying these in an interview costs you the question

  • Treats it as a rendering bug in the closed screen
  • Says the subscription ends by itself when the screen is destroyed
  • Proposes filtering out late values instead of cancelling
  • Adds a manual cancel at one call site and calls it fixed
  • Blames the source for pushing to subscribers that left
  • Assumes an unreachable screen is collectable while a source holds it