Bridging callbacks with AsyncStream

Concurrency Mid-level Swift 5.7+ iOS 16+ Reviewed By Alok Singh

Predict the output

What does this print?

func ticks(_ count: Int) -> AsyncStream<Int> {
    AsyncStream { continuation in
        Task {
            for i in 1...count {
                try? await Task.sleep(for: .milliseconds(100))
                continuation.yield(i)
            }
        }
    }
}
for await t in ticks(3) { print(t) }
print("finished")
Options
Hint 1 of 1

When does a for-await loop end?

Choose an answer, then open Show answer below to check it.

Show answer

Answer: B 1, 2, 3, then the loop waits forever and 'finished' never prints

Correct. Add continuation.finish() after the loop.

Why not the others?
  • A 1, 2, 3, finished. The loop ends only when the stream finishes, and nothing calls finish().
  • C Nothing, because the stream never starts. The build closure runs immediately and the Task starts yielding.
  • D It doesn't compile until you call finish(throwing:). Plain AsyncStream has only finish(); finish(throwing:) belongs to AsyncThrowingStream.

30-second answer

An AsyncSequence produces values over time that you consume with for await. AsyncStream is the easiest way to build one: yield values from a callback with the continuation, call finish() when done, and use onTermination to stop the source when the consumer goes away. Use AsyncThrowingStream if the source can fail.

Practise saying it · 45 s. No recording, just a timer

Explain how to bridge a callback API with AsyncStream.

Say your answer out loud, then check which of these points you covered:

  • yield from the callback
  • finish() when done
  • onTermination stops the source
  • AsyncThrowingStream for errors

Deep dive

for await loops end only when the stream finishes; forgetting finish() keeps the loop waiting forever.

onTermination runs when the stream finishes or the consuming task is cancelled.

Pick a buffering policy (.bufferingNewest(1) for locations) so slow consumers don't build up memory.

AsyncStream.makeStream() returns the stream and continuation separately.

onTermination is @Sendable, so in Swift 6 it can't capture a non-Sendable object like CLLocationManager; keep the source in a @MainActor owner and hop back to stop it.

Location updates as a stream Type-checked · Swift 6.4
@MainActor
final class LocationFeed: NSObject, CLLocationManagerDelegate {
    private let manager = CLLocationManager()
    private var continuation: AsyncStream<CLLocation>.Continuation?

    func updates() -> AsyncStream<CLLocation> {
        AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in
            self.continuation = continuation
            // onTermination is @Sendable: hop back to the main actor to stop the manager.
            continuation.onTermination = { [weak self] _ in
                Task { @MainActor in self?.stop() }
            }
            manager.delegate = self
            manager.startUpdatingLocation()
        }
    }

    func stop() {
        manager.stopUpdatingLocation()
        continuation?.finish()
        continuation = nil
    }

    nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        guard let latest = locations.last else { return }
        Task { @MainActor in self.continuation?.yield(latest) }
    }
}

Common mistakes

  • Calling finish(throwing:) on a non-throwing AsyncStream (it doesn't exist).
  • Never finishing the stream.
  • Not stopping the underlying source in onTermination.

Senior take & follow-ups

Seniors mention lifetime: who stops the source, and what happens when the consumer is cancelled.

Follow-up questions

Think of your answer first, then open the hint.

  1. How do two consumers share one stream?

    Show hint

    They can't by default; each value goes to one consumer. Broadcast yourself.

  2. How do you cancel a for-await loop?

    Show hint

    Cancel its task; the stream's onTermination fires.

Suggest a fix if something here is wrong or out of date.