Mastering AsyncStream in Swift

Published on · Updated · 16 min

Wlad
Wlad
Founder & Swift Tech Lead

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:

AspectAsyncSequenceAsyncStream

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

PolicyUse 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:

CombineAsyncStream + 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

  1. Built into the language: no external framework

  2. Structured concurrency: automatic cancellation

  3. Simpler: no AnyCancellable to manage

  4. Better integration: works naturally with async/await

When to Keep Combine?

  1. Highly specialized operators not available in Async Algorithms

  2. Existing code heavily based on Combine

  3. 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.

Official Resources