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:
[self]is not weak capture—it's explicit strong capture- 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.dataTaskPublishercompletes when the request finishesJustcompletes immediately after emittingFuturecompletes 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:
[weak self]breaks the retain cycleguard let selfunwraps safely (Swift 5.7+ syntax)- Explicit
cancel()instopActivityTracking() - Defensive
cancel()indeinit
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:
- ✅ Use
[weak self]in the sink closure - ✅ Store the
AnyCancellable - ✅ Cancel on
deinitor when stopping - ✅ Consider view lifecycle (
onAppear/onDisappear) - ✅ Test that
deinitactually 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.