Why this happens
Timer.publish(every:on:in:) returns a Timer.TimerPublisher, which is a connectable publisher. The publisher object on its own does nothing. It does not schedule a timer, it does not fire, it just waits. You have to call connect() before any values are produced.
That gives you two separate lifetimes, and this is where most of the confusion comes from:
- The subscription. Created by
.sinkor.assign, represented by anAnyCancellable. It controls who receives values. - The connection. Created by
connect(), represented by a plainCancellable. It controls whether the underlyingFoundation.Timeris running.
Cancelling the subscription stops delivery to that subscriber, but the timer keeps firing as long as the connection is alive. Cancelling the connection stops the timer, but the subscriber object still exists — it simply never receives another value, and no finished completion is sent, because the stream did not end, it just stopped being fed.
The connection also has only two states: connected or cancelled. There is no restart. Calling connect() again on the same publisher does not create a new timer and does not revive the cancelled one. If you want the timer running again, you need a new Timer.publish(...).
Finally, the Cancellable returned by connect() is not an AnyCancellable. A bare Cancellable is not cancelled when its last reference goes away. Drop it and you leak a running timer that keeps firing into a run loop forever.
How to fix it
1. Store the connection next to the subscription
Keep both cancellables in the same owner, so they die together. store(in:) wraps the connection in an AnyCancellable, which cancels itself on deinit.
import Combine
import Foundation
final class Ticker {
private var cancellables = Set<AnyCancellable>()
func start() {
stop()
let publisher = Timer.publish(every: 1, on: .main, in: .common)
publisher
.sink { date in
print("Value:", date)
}
.store(in: &cancellables)
publisher
.connect()
.store(in: &cancellables)
}
func stop() {
cancellables.removeAll()
}
}
cancellables.removeAll() cancels the connection and the subscription in one step. When Ticker is deallocated, the set is released and everything is cancelled automatically.
2. Wrap the connection in AnyCancellable yourself
If you want a named property instead of a set, wrap it explicitly. This is the key difference: AnyCancellable cancels on deinit, a bare Cancellable does not.
final class Ticker {
private var subscription: AnyCancellable?
private var connection: AnyCancellable?
func start() {
let publisher = Timer.publish(every: 1, on: .main, in: .common)
subscription = publisher.sink { date in
print("Value:", date)
}
connection = AnyCancellable(publisher.connect())
}
func stop() {
connection?.cancel()
connection = nil
subscription?.cancel()
subscription = nil
}
}
If you skip the AnyCancellable wrapper and just write let connection = publisher.connect(), the timer survives the end of the scope. That is the leak people hit after dismissing a view controller or a SwiftUI screen.
3. Use autoconnect() when you don’t need manual control
If the timer should run exactly while something is subscribed, autoconnect() removes the whole problem. It connects on the first subscription and disconnects when the last subscriber cancels.
let cancellable = Timer.publish(every: 1, on: .main, in: .common)
.autoconnect()
.sink { date in
print("Value:", date)
}
In SwiftUI this is the usual pattern with .onReceive:
@State private var now = Date()
private let timer = Timer.publish(every: 1, on: .main, in: .common)
.autoconnect()
var body: some View {
Text(now, style: .time)
.onReceive(timer) { now = $0 }
}
The trade-off: with autoconnect() you cannot keep the timer running while nothing is subscribed. If you need that — for example, a background sampler that must keep ticking while a detail view is off screen — use manual connect() and manage the connection yourself.
4. To restart, build a new publisher
Because the connection is one-shot, the restart path is a fresh publisher, not a second connect() call.
final class Ticker {
private var cancellables = Set<AnyCancellable>()
func restart() {
cancellables.removeAll()
let publisher = Timer.publish(every: 1, on: .main, in: .common)
publisher
.sink { date in print("Value:", date) }
.store(in: &cancellables)
publisher
.connect()
.store(in: &cancellables)
}
}
Note that the old subscribers are gone too. A new publisher means new subscriptions, so anything that held the previous AnyCancellable must be re-wired.
5. Use receiveCancel for cleanup, not for completion
If you need to know when a subscriber walked away, handleEvents(receiveCancel:) fires on cancellation. Do not expect a finished completion — cancelling a subscription is not the end of the broadcast.
publisher
.handleEvents(receiveCancel: {
print("Subscriber cancelled")
})
.sink { date in print("Value:", date) }
.store(in: &cancellables)
How to prevent it
- Never call
connect()without storing the result somewhere that outlives the call. - Prefer
autoconnect()unless you specifically need the timer to run with zero subscribers. - Keep the publisher, the subscription and the connection under one owner so they are cancelled together.
- Remember that
.commonkeeps the timer firing during scrolling and tracking;.defaultpauses it. Pick deliberately. - If a timer keeps firing after a screen closes, check Xcode’s Debug Memory Graph or Instruments for a live
Timerbefore hunting anywhere else.
FAQ
Does cancelling the sink cancellable stop the timer?
No. It stops delivery to that subscriber only. The underlying Timer keeps firing while the connection returned by connect() is alive, which is exactly why the connection needs its own cancellation.
Why doesn’t calling connect() again restart the timer?
A connection is either connected or cancelled, with no restart state. Once cancelled, the publisher will not schedule a new timer. Create a new Timer.publish(every:on:in:) and connect that instead.
Is autoconnect() a drop-in replacement for manual connect()?
For most UI cases, yes: it connects on the first subscription and disconnects when the last subscriber cancels. You lose the ability to keep the timer running independently of subscribers, so use manual connect() when that matters.
Parts of this guide are adapted from content licensed under CC BY-SA 4.0; this article is shared under the same license.
