DEV Community

ArshTechPro
ArshTechPro

Posted on

AsyncStream vs Combine Publishers: The Hidden miss That Can Hang Your iOS App

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!"
Enter fullscreen mode Exit fullscreen mode

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")
}
Enter fullscreen mode Exit fullscreen mode

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!
}
Enter fullscreen mode Exit fullscreen mode

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
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The Golden Rules

  1. Every AsyncStream needs a finish strategy - Either call finish() explicitly or handle cancellation
  2. Use onTermination for cleanup - Always clean up resources like timers, observers, or network connections
  3. 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)

Collapse
 
arshtechpro profile image
ArshTechPro

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.