Why does a discarded component stay in memory after it registered a handler with a long-lived event hub?
answer
- the hub outlives the subscriber
- the reference runs hub to component
- the handler captures its owner
- no removal on the teardown path
- one entry per create, never removed
basics
~20 sThe hub outlives the component and still holds its handler. The handler captures the component, so a live path runs hub to handler to component, and reclamation keeps the whole object graph hanging off it.
solid answer
~50 sRegistration is a reference in the wrong direction. The component points at the hub to subscribe, but the hub also points back - it stores the handler in a list it keeps for the life of the process, and that handler captures the component in order to call back into it. When the component is discarded, dropping your own reference to it does nothing, because the hub's list still gives a path to it from a live starting point. Worse, the leak is not one object: the handler retains the component, which retains its fields, its buffers and anything they reference, so one forgotten registration can hold a large subgraph. The size of the leak tracks components created and discarded over the process's life. The fix is symmetry - every `register` needs an `unregister` on an unconditional teardown path, ideally owned by whatever controls the component's lifetime rather than by the component itself.
code
pseudocode · 16 lineshub.handlers = [] // long-lived, one per process
function Component.start(hub):
self.handler = function(event): self.render(event) // captures self
hub.handlers.append(self.handler)
function Component.stop():
return // nothing removes self.handler
c = new Component()
c.start(hub)
c.stop()
drop(c) // last local reference gone
// still reachable:
// hub -> handlers[i] -> handler -> c -> every field c holdsgo deeper
Remember the direction of the reference: to call you back, the hub must hold your handler, and the handler holds you. Being finished with an object is not the same as nothing pointing at it.
Explain the full retention path and its size - handler, component, fields, buffers - and state the symmetry rule that every registration needs a removal on an unconditional teardown path.
Show the diagnosis: growth that steps with create/destroy cycles rather than with traffic, a handler collection larger than the live component count, and stale callbacks firing on discarded state.
Treat it as an API design obligation: any long-lived registry that accepts registrations from short-lived objects must return a cancellation token, expose its size, and never rely on a caller's memory for correctness.
## Registration points the reference backwards Subscribing looks like the component depending on the hub, and at the level of the code you write it is. At the level of the object graph it is the reverse. To deliver an event, the hub must be able to call you, so it keeps your handler in a collection that lives as long as the hub does - which for an application-wide dispatch hub means as long as the process. The handler itself is rarely a bare function. It captures the component so it can update its state, and that capture is an ordinary strong reference. The resulting path is: > live hub -> its handler collection -> your handler -> the component -> its fields -> everything they reference Every link in that chain is reachable from a live starting point, so a reachability-based collector is obliged to keep all of it. Nothing here is a runtime defect; the runtime is doing precisely what it promised. ## Why the usual reflexes do not help - **Dropping your own reference.** Setting your variable to nothing removes one edge, and the hub's edge is the one that matters. - **Letting the component's owner go out of scope.** The owner is not what keeps it alive. - **Requesting a collection.** The subgraph is reachable, so every collection keeps it. - **Nulling the component's own fields.** This shrinks the leak but leaves the component and its handler retained, and it turns a clean object into a half-initialised one that a late event can still hit. ## What it costs, and how it shows up The distinguishing signature is that growth tracks **components created and discarded**, not concurrent traffic. A surface that is opened and closed repeatedly - a screen, a session-scoped view, a per-tenant worker - subscribes each time it is created, so the hub's collection grows by one entry per open, forever. | symptom | what it points at | |---|---| | Retained memory rises one step per open/close cycle | a registration added on create, never removed on destroy | | The hub's handler collection has far more entries than live components | the same, confirmed directly by counting | | A discarded component's callback still runs and touches stale state | the same leak showing up as a correctness bug first | | Growth flat under load, rising only when components churn | retention keyed to lifecycle, not to traffic | That third row is worth stressing: this shape often announces itself as a *behaviour* bug before it is ever a memory bug. Events keep arriving at objects that were supposed to be gone, and they act on data that no longer means anything. ## Making unregistration certain The engineering answer is not "remember to unsubscribe". It is to make forgetting impossible or visible: 1. **Pair the calls in one owner.** Whatever creates the component and calls `start` is what calls `stop`, on an unconditional cleanup path that runs on the error route as well as the happy one. 2. **Hand back a subscription token.** `register` returns a handle whose only method cancels the registration, so the caller holds one object to release instead of remembering which handler instance it passed in - matching by equality on a captured function is fragile. 3. **Scope the subscription to a lifetime.** Tie it to the enclosing scope so that leaving the scope cancels every subscription made inside it. This is the same discipline as scope-bound resource release, applied to a callback. 4. **Consider a registration that does not retain the subscriber.** Some ecosystems let a hub hold a reference that does not by itself keep the target alive, and others do not offer the option at all; where it exists, it converts a certain leak into an unpredictable disappearance, so it is a fallback for caches of listeners, not a substitute for explicit removal. 5. **Instrument the hub.** Export the size of the handler collection as a metric. A count that only rises is the cheapest leak detector this shape has, and it is visible before memory is. ## The general rule this is an instance of Any long-lived container that accepts registrations from short-lived objects is a retention hazard: dispatch hubs, plugin registries, metric and diagnostic caches, callback tables, dependency caches keyed by instance. The question to ask of each is simply *what removes an entry, and does that path always run?* If the answer is "an explicit call the caller must remember", assume it is sometimes forgotten and design the removal so that it is not the caller's memory that has to be perfect.
- The component sets all of its own fields to nothing before it is discarded. Does that fix the leak?It shrinks it without fixing it. The hub still holds the handler and the handler still holds the component, so the entry never goes away and the collection keeps growing by one per cycle. It also creates a half-emptied object that a later event will still invoke, converting a memory bug into a correctness bug. Remove the registration instead.
- Why is returning a cancellation token from register better than an unregister call that takes the handler back?Because the caller must then reproduce the exact handler it passed, and captured functions rarely compare equal to a freshly written one. Callers end up storing the handler anyway, or unregistering something that does not match and silently leaking. A token is one opaque object with one method, so cancellation cannot miss its target.
- How would you detect this shape before it causes an outage?Export the size of every long-lived handler collection as a metric and alert on monotonic growth. Compare it with the count of live components; a gap that widens each open/close cycle is the leak itself, visible long before retained memory moves enough to notice.
saying these in an interview costs you the question
- Assumes dropping the last local reference to a subscriber unregisters it
- Says the hub only stores a copy of the data, not the component
- Thinks the leak is one small handler object, not the graph behind it
- Expects an explicitly requested collection to clear stale handlers
- Unregisters only on the happy path, never after an error
- Believes growth under load proves it is not a lifecycle leak