Chasing a Retain Cycle: The Video Call Screen That Leaked Memory
The app that got heavier with every call
Doctors at Jio Health don't do one video consultation and go home. During a busy morning a physician might see eight or ten patients back to back, each a WebRTC video call inside our iOS app. Around the fourth or fifth call, the app would die. Sometimes it vanished between calls, and a couple of times it died during a consultation, dropping the doctor and the patient.
The crash reports showed iOS jetsamming us for using too much memory. That was the symptom. Something in the video call screen was holding on to memory it should have released, one call at a time, until we crossed the limit.
The tell: memory never came back down. A call pushed us up by 40 or 50 MB, the call ended, the screen was dismissed, and memory stayed up. Next call, up again. A staircase that only went one direction.
Watching the staircase in Instruments
I started with Xcode Instruments, Allocations template, on a real device because WebRTC and the camera behave differently in the simulator.
Generation marks made it legible. You drop a mark, do a thing, drop another mark, and see what was allocated between them that's still alive. My loop: mark, start a call, end the call, fully dismiss the screen, mark.
That generation should have been nearly empty. Instead each one held an entire view controller, WebRTC peer connection objects, video track objects, native renderer views, and buffers. A full call's worth of machinery, once per call.
Leaks caught some of it: cycles it could prove were unreachable but still alive. But Leaks only finds islands that are fully detached. Some of my objects were still reachable from something that outlived the screen, which is worse because the tool won't flag it. For those I switched to the Memory Graph Debugger: pause the app, capture the graph, select the video call view controller, and look at what's pointing at it. That "who retains this?" arrow list is where the culprits were.
Four ways the same screen refused to die
A closure that captured self strongly
The call view controller set up a handler for connection state changes and referenced self inside it to update the UI:
client.onConnectionChange = { state in
self.updateStatus(for: state) // strong capture of self
}The controller held the client. The client held the closure. The closure held the controller. ARC will never break that, because from its point of view everyone is legitimately still referenced. The screen could be dismissed and the controller would sit there fully alive, holding its entire video stack. Adding [weak self] here fixed the most for the least code of anything in the investigation.
WebRTC delegates and renderers that were never released
The peer connection had a delegate, and that relationship formed a cycle with the controller that owned it. The native video renderer views (the RTCMTLVideoView surfaces showing the doctor and patient) held onto the video tracks, and nothing tore them down. Even after breaking the closure cycle, the Memory Graph still showed peer connection and track objects alive, anchored by renderers that were anchored by a controller that wouldn't die.
NotificationCenter observers and a Timer
The controller registered for NotificationCenter notifications (app lifecycle and audio session route changes) using the older API that keeps the observer alive on your behalf. And a Timer ticked once a second to update the call duration on screen. A repeating Timer retains its target strongly, and until you invalidate() it, it keeps your controller alive. The runloop and the notification center live as long as the app does, and anything they point at inherits that lifetime.
The peer connection nobody closed
We never explicitly closed the WebRTC peer connection; we assumed letting the controller deallocate would cascade cleanup. But the peer connection owns real native resources (encoder sessions, capture pipelines, buffers) and WebRTC expects an explicit close(). Leaving it to ARC meant those resources lingered, and because the connection was tangled in the cycles above, ARC never got the chance anyway.
Teardown, done deliberately
I stopped assuming deallocation would clean up and wrote an explicit teardown that ran when the call ended, before the screen went away.
- Break the closure cycle: `[weak self]` on the connection state handler and every other closure the client held, with a `guard let self` inside.
- Tear down WebRTC by hand: stop and remove the video tracks from the renderers, null out the renderer surfaces, remove the delegate, and explicitly call `close()` on the peer connection.
- Unregister everything that outlives the screen: remove the `NotificationCenter` observers and `invalidate()` the duration timer in teardown, not in `deinit`, because with the cycles present `deinit` was never called.
- Break the delegate cycle: set the delegate back to `nil` during teardown so the peer connection and controller stop retaining each other.
The placement matters. I first put cleanup in deinit, which is the natural instinct, but deinit only runs when the object is about to be freed, and it never was. Cleanup that depends on deinit to break the cycle preventing deinit never runs. Teardown had to be triggered by the call ending, which then let deinit fire as confirmation.
Watching it return to baseline
Back to the generation mark loop: mark, call, end, dismiss, mark. This time the generation was nearly empty. The view controller, the peer connection, the tracks, the renderers, the observers, all gone.
Memory now sawtoothed: up during a call, back down to the same baseline after it, call after call. I ran fifteen consecutive test calls, something that used to kill the app around five, and the baseline didn't budge. The Memory Graph, captured back at the call list, no longer had a video call view controller in it at all.
The doctors stopped getting dropped, and I came away trusting the Memory Graph Debugger's "who retains this?" arrows.
Key Takeaways
- Memory that never returns to baseline is a leak: a staircase across repeated flows, ending in a jetsam kill, means retained objects are accumulating one cycle at a time.
- Generation marks and the Memory Graph are the real tools: mark before and after each cycle to see what survives, and use the retain arrows to answer "who is still holding this?" when the Leaks instrument stays quiet.
- Closures are the usual suspect: a closure stored on an object that outlives the screen and captures `self` strongly is a textbook ARC cycle; `[weak self]` in the right place fixes more leaks than anything else.
- Native SDK resources need explicit teardown: WebRTC peer connections, video tracks, and renderers, plus `NotificationCenter` observers and `Timer`s, must be closed, removed, and invalidated by hand; don't trust ARC to cascade through objects that are keeping each other alive.
- Do not put cycle breaking cleanup in deinit: if a retain cycle is preventing deallocation, `deinit` never runs, so trigger teardown on the lifecycle event and let `deinit` fire afterward as confirmation the object is truly gone.