Overview › Timer & NotificationCenter retain cycles — the silent leak class

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:

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

  1. 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.
  2. Day 2-3 — sweep the 21 NotificationCenter observers. Convert to Combine + cancellables where the file already imports Combine; otherwise use observer tokens.
  3. 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

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.

Costco iOS · Code Review Report · Generated 2026-05-11 · 88 machine-curated findings