To cancel one timer waiter independently, give each consumer its own AsyncStream and task handle. Share the Foundation timer, then distribute its values to separate streams. Cancelling consumer A can then end A’s wait while consumer B continues.

This tutorial builds a small Mac desktop app with two synthetic number displays and a Cancel A button. Both displays receive values from one timer. The concrete task is to stop A’s asynchronous consumption without stopping B or invalidating the shared timer.

Why can cancelling one waiter affect other consumers?

AsyncSequence describes asynchronous iteration; the cancellation behavior comes from the concrete sequence implementation. For AsyncStream, the current standard-library source documents that cancelling a task suspended in next() terminates the stream. The call may return nil immediately or on subsequent calls. A fresh iterator over that same stream does not establish an independent subscription. Swift standard-library source: AsyncStream.swift.

That makes ownership important. If two tasks consume the same stream, treating cancellation as a private operation on one iterator is the wrong contract for this task. Instead, create two streams with separate continuations and explicitly send each generated value to both. Apple documents AsyncStream as a bridge from callbacks into asynchronous iteration, using yield(_:) to supply elements and finish() to end a stream. AsyncStream.

Task cancellation is also cooperative: calling cancel() marks the task and runs its cancellation handlers, but does not interrupt arbitrary code. The consumer should check cancellation before applying a received value. Cancellation also propagates to child tasks, so cancelling a common parent can have a broader effect than cancelling the intended consumer handle. Task.cancel().

For this app, use the following ownership rules:

Operation Intended effect
Cancel A’s task End A’s consumption and remove A’s subscription
Finish A’s continuation End A’s stream normally
Cancel B’s task End B’s consumption independently
Stop the timer hub Invalidate the timer and finish every remaining stream

What values should the timer consumers receive?

The example uses a deliberately small value contract. Each processed timer callback advances an integer from 1 through 1,000, wrapping back to 1. Each display shows that integer and its square. For example, an illustrative value of 7 produces n=7, square=49. The bound keeps the calculation small throughout repeated cycles.

Each subscription buffers only its newest pending value. This is appropriate for a display whose job is to show the latest synthetic state: a slow display may skip intermediate values. It is unsuitable when every generated event must be processed. Apple documents both bounded buffering and the default unbounded buffer for AsyncStream. AsyncStream buffering.

The counter measures processed callbacks, not elapsed seconds. Foundation timers depend on their run loop, can fire late, and do not replay every missed firing. The two-second interval below makes the cancellation interaction easy to observe; it does not establish a precise clock. Timer.

How do you build the independent subscriptions?

Use a macOS SwiftUI app target in Xcode with a Swift 6 toolchain and a macOS 14 or later deployment target. Add the following three files, replacing the template app entry point and view. Together they form one complete example; they require no external package, network access, or stored data. The code has been inspected but has not been compiled in this research environment.

Create the shared timer hub

The hub owns the timer and a dictionary of subscription continuations. Every call to subscribe() constructs a new stream. Its termination callback removes only the matching identifier. Apple documents that cancelling active stream iteration invokes onTermination and ends iteration with nil. AsyncStream.Continuation.onTermination.

Put this code in TickHub.swift:

import Foundation

@MainActor
final class TickHub {
    private var timer: Timer?
    private var number = 0
    private var continuations:
        [UUID: AsyncStream<Int>.Continuation] = [:]

    func subscribe() -> (id: UUID, stream: AsyncStream<Int>) {
        let id = UUID()
        let pair = AsyncStream<Int>.makeStream(
            bufferingPolicy: .bufferingNewest(1)
        )

        pair.continuation.onTermination = { [weak self] _ in
            Task { @MainActor [weak self] in
                self?.unsubscribe(id)
            }
        }
        continuations[id] = pair.continuation
        return (id, pair.stream)
    }

    func start() {
        guard timer == nil else { return }
        let newTimer = Timer(
            timeInterval: 2,
            repeats: true
        ) { [weak self] _ in
            Task { @MainActor [weak self] in
                self?.emit()
            }
        }
        newTimer.tolerance = 0.2
        timer = newTimer
        RunLoop.main.add(newTimer, forMode: .common)
    }

    private func emit() {
        guard timer != nil else { return }
        number = number == 1_000 ? 1 : number + 1
        for continuation in continuations.values {
            _ = continuation.yield(number)
        }
    }

    func unsubscribe(_ id: UUID) {
        let continuation = continuations.removeValue(forKey: id)
        continuation?.finish()
    }

    func stop() {
        timer?.invalidate()
        timer = nil
        let remaining = Array(continuations.values)
        continuations.removeAll()
        for continuation in remaining {
            continuation.finish()
        }
    }
}

The timer initializer creates an unscheduled timer, so the hub adds it to the main run loop explicitly. The callback enters a main-actor task before touching hub state. The timer != nil check prevents an already queued callback from emitting after shutdown. Timer.init(timeInterval:repeats:block:) and RunLoop.add(_:forMode:).

All hub state is isolated to MainActor, whose executor is equivalent to the main dispatch queue. The termination callback schedules its cleanup there rather than directly accessing the dictionary from the callback’s execution context. MainActor.

Give each consumer a separate task handle

Put the following code in TimerDemoModel.swift. The two calls to subscribe() are essential: they create independent stream lifetimes. The defer blocks also unsubscribe when a consumer exits, covering an explicit loop exit as well as cancellation.

import Combine

@MainActor
final class TimerDemoModel: ObservableObject {
    @Published private(set) var aText = "Waiting"
    @Published private(set) var bText = "Waiting"
    @Published private(set) var aCancelled = false
    @Published private(set) var stopped = false

    private let hub = TickHub()
    private var aTask: Task<Void, Never>?
    private var bTask: Task<Void, Never>?
    private var started = false

    func start() {
        guard !started else { return }
        started = true
        let a = hub.subscribe()
        let b = hub.subscribe()
        let source = hub

        aTask = Task { @MainActor [weak self] in
            defer { source.unsubscribe(a.id) }
            for await number in a.stream {
                guard !Task.isCancelled else { break }
                self?.aText = "n=\(number), square=\(number * number)"
            }
        }

        bTask = Task { @MainActor [weak self] in
            defer { source.unsubscribe(b.id) }
            for await number in b.stream {
                guard !Task.isCancelled else { break }
                self?.bText = "n=\(number), square=\(number * number)"
            }
        }

        hub.start()
    }

    func cancelA() {
        guard !aCancelled, !stopped else { return }
        aCancelled = true
        aTask?.cancel()
    }

    func stopAll() {
        guard !stopped else { return }
        stopped = true
        aTask?.cancel()
        bTask?.cancel()
        hub.stop()
        aTask = nil
        bTask = nil
    }
}

Notice that cancelA() does exactly one cancellation operation. It does not call hub.stop() or touch B’s task. The loop checks Task.isCancelled after receiving a number and before changing the display, following Swift’s documented cooperative cancellation model. The Swift Programming Language — Concurrency.

The two cleanup paths can both attempt to remove A: the termination callback and the task’s defer. Here that is intentional. unsubscribe(_:) first removes the entry and then finishes the returned continuation. A later removal finds nothing, leaving other subscriptions untouched.

Add the desktop controls

Put this code in TimerWaiterDemoApp.swift. Keep only one @main app declaration in the target.

import SwiftUI

@main
@MainActor
struct TimerWaiterDemoApp: App {
    var body: some Scene {
        WindowGroup {
            TimerDemoView()
        }
    }
}

@MainActor
struct TimerDemoView: View {
    @StateObject private var model = TimerDemoModel()

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            Text("Independent timer consumers").font(.headline)
            Text("A: \(model.aText)")
            Text(model.aCancelled ? "A cancelled" : "A active")
            Text("B: \(model.bText)")
            HStack {
                Button("Cancel A") { model.cancelA() }
                    .disabled(model.aCancelled || model.stopped)
                Button("Stop all") { model.stopAll() }
                    .disabled(model.stopped)
            }
        }
        .padding(24)
        .frame(minWidth: 420)
        .onAppear { model.start() }
        .onDisappear { model.stopAll() }
    }
}

@StateObject gives the model stable storage during the view container’s lifetime, and published changes update dependent text views. The disappearance action provides view-level cleanup; its precise timing depends on the view. The explicit Stop all button provides a direct shutdown operation too. StateObject and onDisappear(perform:).

How do you verify that cancellation stays local?

Run the app and use this acceptance sequence. The numbers below are illustrative observations to look for, not recorded test results.

  1. Wait for both displays to advance. They should normally show the same latest number and square.
  2. Click Cancel A between timer firings. A’s status changes to A cancelled, and its displayed value stays fixed.
  3. Wait for at least two further firings. B should continue advancing while A remains fixed.
  4. Click Stop all. B should stop advancing too.
  5. Relaunch the app and cancel A immediately, before its first value. A should remain at Waiting, while B begins receiving numbers.

This sequence checks cancellation both after consumption has begun and before the first useful value arrives. The main-actor cancellation action and display updates are serialized in this design. An update completed before the button action remains visible; the cancellation check prevents a later resumed A iteration from applying another value.

If the behavior differs, inspect the ownership boundary before changing the timer interval:

Symptom First thing to inspect
B stops when A is cancelled Look for a shared stream or cleanup that calls hub.stop()
The displays receive different subsets of values Confirm that each consumer gets a separate subscribe() result
No values appear Confirm that the timer was started and added to the app’s running main run loop
Values occasionally skip Check whether the newest-value buffering policy matches the display requirement
Work continues after the view disappears Confirm that shutdown reaches both task handles and the hub

What changes for a one-shot waiter or a restart?

For a one-shot waiter, keep its subscription private, process the first acceptable value, and exit the loop. Retain the defer unsubscribe operation: it makes the intended subscription lifetime explicit even when the loop ends through break. A normal one-shot completion should remove that subscription while leaving the shared producer available to B.

To restart A, create a fresh subscription and a fresh task after the old task has completed. Do not try to reset the cancelled task or reuse its terminated stream. In a restartable interface, await the old task’s completion before installing a replacement, or otherwise distinguish successive subscription lifetimes so that old cleanup cannot remove new state.

The sample deliberately offers one run per view instance. Global shutdown invalidates its repeating timer on the main actor, where it was installed. Apple documents that invalidated timers cannot be reused; a restart must construct another timer. Timer lifecycle.

Independent cancellation follows directly from independent subscription ownership. Keep the timer in the hub, give each consumer a separate stream and task handle, and remove only that consumer’s subscription when it finishes. That lets a desktop control stop one waiting display while the remaining display continues receiving the same synthetic calculation.

Sources