Modern apps are no longer single-device experiences.
Users expect their data to stay perfectly in sync across iPhone, iPad, and Mac, with updates appearing instantly—no refresh button, no manual sync. This expectation becomes even stronger in productivity apps like invoicing, accounting, or note-taking, where consistency and correctness matter.
In my invocing app Reckord, users can sign in on multiple devices and continue their work seamlessly. To support this, I needed a real-time data synchronization system that:
async/await modelTo achieve this, I built the entire real-time sync layer using AsyncStream and AsyncThrowingStream.
This article explains:
AsyncStream isAsyncThrowingStream isMost real-time systems—including Firebase Firestore—are built around callbacks.
A typical Firestore listener looks like this:
listener = query.addSnapshotListener { snapshot, error in
// Called every time data changes
}While functional, this approach has drawbacks in modern SwiftUI apps:
What we want is something like this:
for await update in updates {
apply(update)
}This is exactly what AsyncSequence enables—and AsyncStream is how we create one.
AsyncSequence: The FoundationBoth AsyncStream and AsyncThrowingStream conform to AsyncSequence.
An AsyncSequence:
for awaitTask and SwiftUI lifecyclesYou can think of it as:
The
async/awaitequivalent of a data stream.
AsyncStream?AsyncStream is a Swift type that allows you to create an asynchronous sequence of values that are produced over time, rather than all at once.
In simple terms:
AsyncStreamlets you manually feed values into an asyncfor awaitloop.
AsyncStream ExistsBefore Swift Concurrency, when values arrived over time, we usually relied on:
These approaches don’t integrate naturally with async/await.
AsyncStream exists to bridge non-async, callback-based APIs into Swift’s structured concurrency world.
AsyncStream Works (Conceptually)An AsyncStream has two sides:
Continuation.for await.The producer and consumer can live in completely different parts of your app.
AsyncStreamAsyncStream<Element> { continuation in
continuation.yield(value)
continuation.finish()
}Element → type of values emittedcontinuation.yield(_:) → sends a value to consumerscontinuation.finish() → ends the streamOnce finished, no more values can be emitted.
Let’s say you want to emit numbers over time.
func numberStream() -> AsyncStream<Int> {
AsyncStream { continuation in
Task {
for i in 1...5 {
continuation.yield(i)
try await Task.sleep(for: .seconds(1))
}
continuation.finish()
}
}
}Consume it like this:
Task {
for await number in numberStream() {
print(number)
}
}Output:
1
2
3
4
5This is not a loop returning values — it’s a stream pushing values asynchronously.
AsyncStreamasync/awaitBecause it cannot fail, AsyncStream is best suited for:
AsyncThrowingStream?AsyncThrowingStream is the error-capable sibling of AsyncStream.
It represents an async sequence that:
In other words:
AsyncThrowingStreamisAsyncStream+ error handling.
AsyncThrowingStream ExistsMost real-world asynchronous systems can fail:
Using AsyncStream for such systems would mean:
AsyncThrowingStream solves this cleanly.
AsyncThrowingStreamAsyncThrowingStream<Element, Error> { continuation in
continuation.yield(value)
continuation.finish(throwing: error)
}Key differences:
finish(throwing:)for try awaitImagine a stream that emits random numbers but fails sometimes.
enum RandomError: Error {
case unlucky
}
func randomNumberStream() -> AsyncThrowingStream<Int, Error> {
AsyncThrowingStream { continuation in
Task {
for _ in 1...10 {
let number = Int.random(in: 1...10)
if number == 7 {
continuation.finish(throwing: RandomError.unlucky)
return
}
continuation.yield(number)
try await Task.sleep(for: .seconds(1))
}
continuation.finish()
}
}
}Consume it like this:
Task {
do {
for try await number in randomNumberStream() {
print(number)
}
} catch {
print("Stream failed:", error)
}
}Output:
3
2
4
Stream failed: unluckyIt generates random numbers between 1 and 10 and prints them. If the generated number is 7, it stops generating numbers and throws an error.
AsyncThrowingStreamThis makes it perfect for real-time databases like Firestore.
AsyncStream vs AsyncThrowingStream (Mental Model)| Question | AsyncStream | AsyncThrowingStream |
|---|---|---|
| Can this operation fail? | ❌ No | ✅ Yes |
Needs try to consume? |
❌ No | ✅ Yes |
| UI / in-memory events | ✅ | ⚠️ |
| Network / database | ❌ | ✅ |
If you’re unsure, ask yourself:
Can this realistically fail at runtime?
If yes → useAsyncThrowingStream.
In Reckord, users can sign in on multiple devices and expect their data to stay perfectly in sync.
This means:
Firestore already provides real-time updates via snapshot listeners, but those APIs are callback-based.
My goal was to:
async/awaitAsyncThrowingStreamFirestore snapshot listeners:
That maps exactly to AsyncThrowingStream.
func invoicesStream(userId: String) -> AsyncThrowingStream<[Invoice], Error> {
AsyncThrowingStream { continuation in
let listener = firestore
.collection("invoices")
.whereField("userId", isEqualTo: userId)
.addSnapshotListener { snapshot, error in
if let error {
continuation.finish(throwing: error)
return
}
guard let snapshot else {
continuation.finish(throwing: FirestoreServiceError.invalidPath)
return
}
do {
var invoices: [Invoice] = []
for document in snapshot.documents {
let parsedInvoice = try FirestoreParser.parse(document.data(), type: Invoice.self)
invoices.append(parsedInvoice)
}
continuation.yield(invoices)
} catch {
continuation.finish(throwing: FirestoreServiceError.parseError)
}
}
continuation.onTermination = { _ in
listener.remove()
}
}
}This single function:
@MainActor
func observeInvoices() async {
do {
for try await invoices in invoicesStream(userId: userId) {
self.invoices = invoices
}
} catch {
self.errorMessage = error.localizedDescription
}
}And attach it to the view lifecycle:
.task {
await observeInvoices()
}Now the flow looks like this:
AsyncThrowingStream yields new valuesNo polling.
No manual refresh.
No Combine.
Just structured concurrency.
AsyncStream Fits in ReckordWhile Firestore uses AsyncThrowingStream, I use AsyncStream internally for:
This keeps error handling where it belongs and avoids overusing throwing streams.
AsyncStream and AsyncThrowingStream are foundational tools for modern Swift development.
They allow you to:
They form the backbone of Reckord ’s real-time sync system—and once you adopt them, callback-heavy designs start to feel outdated.
Thank you for reading. If you have any questions, feel free to follow me on X and send me a DM. If you enjoyed this article and would like to support my work, Buy me a coffee ☕️