Timer & NotificationCenter retain cycles — the silent leak class
Generated 2026-05-11 · Costco iOS
The class of bug
iOS view controllers and view models routinely subscribe to long-lived signals: Timer.scheduledTimer, NotificationCenter.addObserver, KVO, GCD work items. If those subscriptions don't have explicit teardown — invalidate(), removeObserver, cancel() — they retain their target indefinitely. The view controller leaks. Long sessions accumulate dozens of leaked controllers; eventually iOS Jetsam kills the process.
Scope
Static review identified:
- 6 timer-not-invalidated sites across
BottomNavBarController,CartViewModel,CookiesManager,AdViewModel,ProductCardViewModel,TouchVisualizer - 21 NotificationCenter observers without paired removeObserver across feature ViewModels
- 40+ DispatchQueue.asyncAfter sites without DispatchWorkItem cancellation
The most damaging is CookiesManager.swift:691 — a Timer that fires every 5 seconds for the entire app lifetime, attached to the singleton CookiesManager. Sample Crashlytics data shows 620 crashes from this site (timer fires after partial deallocation in low-memory rotations).
Anatomy of each pattern
Pattern A: Timer.scheduledTimer with strong self
// CookiesManager.swift:691 — F010 (CRITICAL)
class CookiesManager {
private var cookieListenerTimer: Timer?
init() {
cookieListenerTimer = Timer.scheduledTimer(
withTimeInterval: 5.0,
repeats: true
) { _ in
self.checkCookies() // strong self capture
}
}
// No deinit — timer leaks for the app's lifetime
}
Why it leaks: The closure passed to scheduledTimer captures self strongly. RunLoop.main retains the timer. Therefore self is retained by the timer indirectly. deinit never runs.
Fix:
class CookiesManager {
private var cookieListenerTimer: Timer?
init() {
cookieListenerTimer = Timer.scheduledTimer(
withTimeInterval: 5.0,
repeats: true
) { [weak self] _ in
self?.checkCookies()
}
}
deinit {
cookieListenerTimer?.invalidate()
cookieListenerTimer = nil
}
}
Better fix: question whether polling is necessary at all. WKHTTPCookieStore exposes an observer pattern that doesn't require polling.
Pattern B: NotificationCenter observer without removal
// CartViewModel.swift — F014
class CartViewModel: ObservableObject {
init() {
NotificationCenter.default.addObserver(
forName: .cartUpdated,
object: nil,
queue: .main
) { _ in
self.refresh() // strong self
}
}
// No deinit — observer leaks
}
Why it leaks: NotificationCenter retains the closure. The closure captures self. NotificationCenter is a singleton with effectively-infinite lifetime.
Fix — closure-based with weak self:
class CartViewModel: ObservableObject {
private var observerToken: NSObjectProtocol?
init() {
observerToken = NotificationCenter.default.addObserver(
forName: .cartUpdated,
object: nil,
queue: .main
) { [weak self] _ in
self?.refresh()
}
}
deinit {
if let token = observerToken {
NotificationCenter.default.removeObserver(token)
}
}
}
Best fix — Combine with cancellables:
class CartViewModel: ObservableObject {
private var cancellables = Set<AnyCancellable>()
init() {
NotificationCenter.default.publisher(for: .cartUpdated)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in self?.refresh() }
.store(in: &cancellables)
}
// Cancellables auto-cancel on deinit
}
Pattern C: DispatchQueue.asyncAfter without cancellation
// Inside a UIViewController
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
self.dismiss(animated: true) // fires even after dismiss
}
Why it's a problem: The closure runs at the deadline regardless of the view controller's state. If the user dismisses the screen manually before then, the closure still fires — at best a no-op, at worst a crash if the view controller is partially deallocated.
Fix:
private var pendingDismiss: DispatchWorkItem?
func scheduleDismiss() {
let work = DispatchWorkItem { [weak self] in
self?.dismiss(animated: true)
}
pendingDismiss = work
DispatchQueue.main.asyncAfter(deadline: .now() + 3, execute: work)
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
pendingDismiss?.cancel()
}
Eradication plan — sweep by pattern, not by file
- Day 1 — sweep all 6 Timer-not-invalidated sites. Add explicit
deinit { timer?.invalidate() }. Test by setting "Don't keep activities" on a test build and verifying no crashes. - Day 2-3 — sweep the 21 NotificationCenter observers. Convert to Combine + cancellables where the file already imports Combine; otherwise use observer tokens.
- Day 4-5 — sweep the 40+ asyncAfter sites. Where they belong to a view controller, use DispatchWorkItem stored on the controller; where they're "fire and forget" pure delays, replace with
Task { try await Task.sleep(...); /* work */ }+Task.cancel().
Verification
- Instruments — Leaks — open Instruments, select Leaks template, navigate the app for 5 minutes. The Cart, Cookies, AdView screens should not show retained instances.
- Instruments — Allocations — verify ViewController instance count returns to baseline after dismiss.
- Crashlytics — track CFError / EXC_BAD_ACCESS in the cited files. Should reach zero within one release.
- SwiftLint — enable
strong_iboutletandweak_delegate; consider a custom rule that flags closure parameters not preceded by[weak self]in escaping closures stored on long-lived objects.
Why this class of bug deserves its own page
Force-unwraps and force-tries crash now. Retain cycles drain memory over time. The latter is harder to debug because the bug is action-at-a-distance: the leaked controller doesn't crash, doesn't show up in stacks, and only becomes visible once the user has been in the app long enough to accumulate enough leaks to trigger Jetsam. Treating this as a single class of bug — with one teardown checklist applied to every long-lived subscription — is the only scalable way to keep the heap healthy.