← All shorts
59Swift 5.5+2 min read

AsyncSequence and AsyncStream

AsyncSequence turns a stream of future values into something you can loop over with for-await, and AsyncStream builds one from callbacks.

Delegate callbacks and Combine publishers both model a value that arrives over time, but neither reads as plain sequential code. AsyncSequence lets you consume a stream of values with a for await loop, using the same control flow as any other collection.

#The new way

struct LocationUpdate {
    let latitude: Double
    let longitude: Double
}

final class LocationTracker {
    private var continuation: AsyncStream<LocationUpdate>.Continuation?

    lazy var updates: AsyncStream<LocationUpdate> = AsyncStream { continuation in
        self.continuation = continuation
        continuation.onTermination = { [weak self] _ in
            self?.stopTracking()
        }
    }

    func startTracking() {
        continuation?.yield(LocationUpdate(latitude: 37.33, longitude: -122.03))
    }

    func stopTracking() {
        continuation?.finish()
    }
}

func printUpdates(from tracker: LocationTracker) async {
    for await update in tracker.updates {
        print("lat: \(update.latitude), lon: \(update.longitude)")
    }
    print("stream finished")
}

#Why it matters

  • for await composes with break, early return, and error handling exactly like a synchronous loop
  • continuation.finish() terminates the sequence cleanly so the consuming loop exits instead of hanging
  • AsyncStream bridges any push-based API — delegates, notification observers, socket callbacks — into structured concurrency without Combine as a dependency
  • Standard sequence operators (map, filter, prefix) work on async sequences the same way they do on Sequence

#Gotcha

Walking away from a for await loop early — via break, return, or the enclosing task being cancelled — does not automatically call onTermination unless the stream's producer is actually torn down; if continuation.yield keeps getting called after nothing is consuming, values are silently dropped once the buffer policy (default .unbounded) is exceeded, or memory grows unbounded if it isn't. Set an explicit bufferingPolicy and rely on onTermination rather than assuming the consumer side cleans up the producer for you.

swiftconcurrency

Related shorts