Fix: Timer.TimerPublisher Won’t Restart After connect().cancel()

Fix: Timer.TimerPublisher Won't Restart After connect().cancel()

Written by

in

Quick answer: connect() returns a one-shot Cancellable: cancelling it stops the underlying Timer but leaves your sink subscription alive, and calling connect() again will not restart it. Store the connection in a Set (or wrap it in AnyCancellable) so it is cancelled automatically, and build a fresh Timer.publish(…) whenever you need the timer running again.

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 .sink or .assign, represented by an AnyCancellable. It controls who receives values.
  • The connection. Created by connect(), represented by a plain Cancellable. It controls whether the underlying Foundation.Timer is 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 .common keeps the timer firing during scrolling and tracking; .default pauses it. Pick deliberately.
  • If a timer keeps firing after a screen closes, check Xcode’s Debug Memory Graph or Instruments for a live Timer before 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.

Sources & credits

Parts of this guide are adapted from content licensed under CC BY-SA 4.0; this article is shared under the same license.