Concurrency Deep Dive

Timer Subscriptions and Memory

February 24, 2026
5 min read
Featured image for blog post: Timer Subscriptions and Memory

Combine Retain Cycles showed how sink closures can leak. Timer publishers are especially tricky—they never complete on their own.

Follow along with the code: iOS-Practice on GitHub

The Timer Trap

class SessionManager: ObservableObject {
    @Published var lastActivity: Date?
    private var activityTimer: AnyCancellable?

    func startActivityTracking() {
        activityTimer = Timer.publish(every: 5.0, on: .main, in: .common)
            .autoconnect()
            .sink { [self] date in  // 💀 This is strong capture!
                self.lastActivity = date
                self.checkSessionTimeout()
            }
    }

    deinit {
        print("SessionManager deallocated")  // Never prints
    }
}

Two problems here:

  1. [self] is not weak capture—it's explicit strong capture
  2. Timer publishers never complete, so the subscription lives forever

The timer fires every 5 seconds, holding self alive indefinitely.

Why Timers Are Different

Most Combine publishers complete eventually:

  • URLSession.dataTaskPublisher completes when the request finishes
  • Just completes immediately after emitting
  • Future completes after the promise resolves

But Timer.publish emits forever. It only stops when:

  • You cancel the subscription
  • The app terminates

If you create a retain cycle with a timer, it's permanent.

The Fix

func startActivityTracking() {
    activityTimer = Timer.publish(every: 5.0, on: .main, in: .common)
        .autoconnect()
        .sink { [weak self] date in
            guard let self else { return }
            self.lastActivity = date
            self.checkSessionTimeout()
        }
}

func stopActivityTracking() {
    activityTimer?.cancel()
    activityTimer = nil
}

deinit {
    activityTimer?.cancel()  // Belt and suspenders
    print("SessionManager deallocated")
}

Key changes:

  1. [weak self] breaks the retain cycle
  2. guard let self unwraps safely (Swift 5.7+ syntax)
  3. Explicit cancel() in stopActivityTracking()
  4. Defensive cancel() in deinit

Lifecycle Management

For UI-bound timers, tie them to view lifecycle:

class DashboardViewModel: ObservableObject {
    @Published var refreshDate: Date?
    private var refreshTimer: AnyCancellable?

    func startRefreshing() {
        refreshTimer = Timer.publish(every: 30.0, on: .main, in: .common)
            .autoconnect()
            .sink { [weak self] _ in
                self?.refresh()
            }
    }

    func stopRefreshing() {
        refreshTimer?.cancel()
        refreshTimer = nil
    }
}

struct DashboardView: View {
    @StateObject private var viewModel = DashboardViewModel()

    var body: some View {
        ContentView()
            .onAppear { viewModel.startRefreshing() }
            .onDisappear { viewModel.stopRefreshing() }
    }
}

When the view disappears, the timer stops. No leaked timers running in the background.

SwiftUI's .onReceive

For simple cases, SwiftUI has a cleaner pattern:

struct RefreshingView: View {
    @State private var lastRefresh = Date()

    let timer = Timer.publish(every: 30, on: .main, in: .common).autoconnect()

    var body: some View {
        Text("Last refresh: \(lastRefresh.formatted())")
            .onReceive(timer) { date in
                lastRefresh = date
                // No self capture issues in SwiftUI views
            }
    }
}

SwiftUI manages the subscription lifecycle. When the view is removed, the subscription is cleaned up.

Multiple Timers

If you have multiple timer subscriptions, track them all:

class MonitoringService: ObservableObject {
    private var cancellables = Set<AnyCancellable>()

    func startMonitoring() {
        // Health check every 10 seconds
        Timer.publish(every: 10, on: .main, in: .common)
            .autoconnect()
            .sink { [weak self] _ in self?.checkHealth() }
            .store(in: &cancellables)

        // Metrics every 60 seconds
        Timer.publish(every: 60, on: .main, in: .common)
            .autoconnect()
            .sink { [weak self] _ in self?.reportMetrics() }
            .store(in: &cancellables)
    }

    func stopMonitoring() {
        cancellables.removeAll()  // Cancels all subscriptions
    }
}

cancellables.removeAll() cancels every subscription in the set.

The Checklist

When using Timer.publish:

  1. ✅ Use [weak self] in the sink closure
  2. ✅ Store the AnyCancellable
  3. ✅ Cancel on deinit or when stopping
  4. ✅ Consider view lifecycle (onAppear/onDisappear)
  5. ✅ Test that deinit actually fires

The Rule

Timer publishers never complete. They hold your closure alive forever unless cancelled. Always use [weak self], always store the cancellable, always have a cancellation strategy. One forgotten timer can keep an entire object graph alive indefinitely.

Originally published on pixelper.com

© 2026 Christopher Moore / Dead Pixel Studio

Let's work together

Professional discovery, design, and complete technical coverage for your ideas

Get in touch