Mastering AsyncStream in Swift
Published on · Updated · 16 min
AsyncStream is the ideal tool for creating custom asynchronous sequences that produce values over time. Whether you're bridging a callback API, a delegate, or creating a real-time data stream, AsyncStream greatly simplifies the work. This guide covers everything from basic creation to advanced patterns with Swift Async Algorithms.
AsyncSequence vs AsyncStream
Before diving into AsyncStream, let's understand the difference with AsyncSequence:
| Aspect | AsyncSequence | AsyncStream |
|---|---|---|
Nature | Protocol | Concrete type |
Implementation | Requires an AsyncIterator | Ready to use |
Use case | Custom types | Bridging existing APIs |
Complexity | More complex | Simpler |
AsyncStream is a concrete implementation of AsyncSequence. It saves you from writing your own iterator.
Simple Creation with Unfolding
The simplest way to create an AsyncStream uses the unfolding closure:
This approach works when you can produce and return a value directly. The closure is called each time a new value is expected.
Limitation of Unfolding
Unfolding doesn't work well with callback or delegate APIs because you don't control when values arrive. For these cases, use the continuation approach.
Creation with Continuation
The continuation allows producing values from any context:
makeStream: The Modern API (Swift 5.9+)
Since Swift 5.9 (SE-0388), makeStream simplifies the separation between producer and consumer:
This API is backdeployed to Swift 5.1, so you can use it even with older deployment targets.
Why Prefer makeStream?
The old closure-based approach had a problem: the continuation was only available inside the creation closure. With makeStream, you get both separately:
BufferingPolicy: Controlling the Buffer
The buffer determines how to handle values when the consumer is slower than the producer:
Choosing the Right Policy
| Policy | Use case |
|---|---|
.unbounded | When every value matters (logs, analytics) |
.bufferingOldest(n) | Sequential processing required |
.bufferingNewest(n) | Only recent value matters (GPS position, price) |
.bufferingNewest(1) | Always the latest value available |
.bufferingOldest(0) | Ignore if no active consumer |
Practical Example: GPS Position
AsyncThrowingStream: Handling Errors
When your stream can fail, use AsyncThrowingStream:
makeStream with Errors
Bridging Existing APIs
AsyncStream excels at modernizing callback or delegate APIs:
NotificationCenter
Timer
WebSocket
Swift Async Algorithms
The Swift Async Algorithms package extends AsyncSequence with powerful operators. Add it to your project:
debounce: Waiting for a Pause
Emits a value only after a period of silence:
throttle: Limiting Frequency
Emits at most one value per interval:
merge: Combining Multiple Streams
Merges multiple streams into one:
combineLatest: Latest Values from Each Stream
Emits a tuple on each new value from any stream:
zip: Pairing by Position
Waits for a value from each stream before emitting:
chain: Concatenating Sequences
Concatenates multiple sequences:
Combine vs AsyncStream
If you're coming from Combine, here are the equivalences:
| Combine | AsyncStream + Async Algorithms |
|---|---|
PassthroughSubject | AsyncStream with continuation |
CurrentValueSubject | AsyncStream with .bufferingNewest(1) |
publisher.debounce() | stream.debounce(for:) |
publisher.throttle() | stream.throttle(for:) |
Publishers.Merge | merge() |
Publishers.CombineLatest | combineLatest() |
Publishers.Zip | zip() |
publisher.sink() | for await in |
Advantages of AsyncStream over Combine
Built into the language: no external framework
Structured concurrency: automatic cancellation
Simpler: no AnyCancellable to manage
Better integration: works naturally with async/await
When to Keep Combine?
Highly specialized operators not available in Async Algorithms
Existing code heavily based on Combine
SwiftUI integration with @Published (though @Observable is preferable)
Cancellation and Cleanup
Cancellation handling is crucial to avoid resource leaks:
Advanced Patterns
Retry with Exponential Backoff
Shared Stream (Multicast)
Buffer with Timeout
Best Practices
1. Always Call finish()
2. Use onTermination for Cleanup
3. Choose the Right BufferingPolicy
4. Handle Backpressure
Common Pitfalls
1. Forgetting @Sendable in onTermination
2. Yield After finish()
3. Infinite Stream Without Exit Condition
Going Further
AsyncStream is a fundamental tool for reactive programming in Swift Concurrency. Combined with Swift Async Algorithms, it offers a modern and integrated alternative to Combine.