If you're working with async/await in iOS and transitioning from Combine, there's a critical difference that might silently break your app: AsyncStream doesn't automatically complete like Combine publishers do.
The Problem in 30 Seconds
When iOS developers move from Combine to AsyncStream, they often assume both work the same way. They don't. Combine publishers typically complete on their own when done producing values. AsyncStream waits forever unless you explicitly tell it to stop. Miss this detail, and your app will hang with tasks waiting indefinitely.
Quick: What Are These Things?
Combine Publishers: Apple's reactive framework components that emit values over time to subscribers. Think of them as a conveyor belt that automatically stops when all items are delivered.
AsyncStream: Swift's modern async/await way to handle sequences of values over time. Think of it as a pipe that stays open until you explicitly close the valve.
The Critical Difference
Combine: Auto-Complete Behavior
// This automatically completes after emitting all values
[1, 2, 3, 4, 5].publisher
.sink(
receiveCompletion: { _ in
print("I complete automatically!")
},
receiveValue: { print($0) }
)
// Output: 1, 2, 3, 4, 5, "I complete automatically!"
The publisher knows it has 5 items. After emitting them, it sends a completion event. Your code moves on.
AsyncStream: The Waiting Game
// WARNING: This will hang forever
let stream = AsyncStream<Int> { continuation in
for i in 1...5 {
continuation.yield(i)
}
// Missing: continuation.finish()
}
Task {
for await value in stream {
print(value)
}
print("This line will NEVER execute")
}
The stream emits 1 through 5, then... nothing. The for await
loop sits there, waiting for more values that will never come. Your task is stuck.
Why This Design Exists
AsyncStream was built for scenarios where you don't know when data will stop arriving:
- WebSocket connections
- Location updates
- Sensor data streams
- Server-sent events
Combine publishers were primarily designed for finite sequences with a known end point.
The Fix: Always Close Your Streams
Here's the corrected version:
let stream = AsyncStream<Int> { continuation in
for i in 1...5 {
continuation.yield(i)
}
continuation.finish() // This is essential!
}
Real-World Trap: Timer-Based Streams
This pattern is particularly dangerous with timers:
// Memory leak and hanging task
func brokenHeartbeatStream() -> AsyncStream<Date> {
AsyncStream { continuation in
Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
continuation.yield(Date())
}
// Timer runs forever, stream never ends
}
}
// Proper implementation
func workingHeartbeatStream(maxBeats: Int) -> AsyncStream<Date> {
AsyncStream { continuation in
var count = 0
let timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { timer in
count += 1
continuation.yield(Date())
if count >= maxBeats {
timer.invalidate()
continuation.finish() // Close the stream!
}
}
continuation.onTermination = { _ in
timer.invalidate() // Clean up on cancellation
}
}
}
The Golden Rules
-
Every AsyncStream needs a finish strategy - Either call
finish()
explicitly or handle cancellation - Use onTermination for cleanup - Always clean up resources like timers, observers, or network connections
- When migrating from Combine, audit every stream - Don't assume auto-completion behavior
Quick Detection Checklist
Your AsyncStream might be hanging if:
- Tasks using the stream never complete
- Memory usage grows over time
- Timers or observers aren't being cleaned up
- Your async functions never return
Conclusion
Unlike Combine publishers that politely excuse themselves when done, AsyncStream will wait at your door indefinitely until you tell it goodbye.
Remember: If you open a stream, you must close it.
Top comments (1)
AsyncStream: Swift's modern async/await way to handle sequences of values over time. Think of it as a pipe that stays open until you explicitly close the valve.