Create the AsyncStream Before the Producer Can Publish

I had a StoreKit listener.
I had an AsyncStream.
I had a SwiftUI .task consuming that stream.
I also had a transaction result that vanished between all three.
Nothing crashed.
No continuation threw an error.
The purchase was settled.
The stream consumer was about to begin.
It was just not there yet.
That last word did all the damage.
The bug was not “the listener never started.”
The bug was that registering the stream was deferred until the listener task started.
Task scheduling left a small interval in which the producer could publish with no subscriber registered.
The durable fix was one line in a model initializer:
outcomeStream = store.outcomes
That line does not consume a single value.
It establishes the lifecycle boundary early enough for later values to have somewhere to wait.
The direct purchase result was only half the protocol
The tip jar already handled the result returned directly by Product.purchase().
A successful verified transaction became a thank-you state.
An unverified or failed transaction became a failure note.
A cancellation left no note.
A pending purchase displayed an awaiting-approval state.
That last case is the interesting one.
StoreKit may return .pending because the purchase needs approval.
The eventual transaction then arrives outside the original purchase call through Transaction.updates.
The app-owned StoreKit listener was already settling those updates and finishing the transactions that should be removed from the queue.
But it discarded the mapped UI outcome:
for await verification in Transaction.updates {
_ = await settle(verification)
}
The payment lifecycle completed.
The open About sheet never heard the conclusion.
An awaiting-approval message could remain even after StoreKit had delivered the answer.
The first repair made delayed outcomes observable.
TipJarStore gained a subscriber stream, and the listener published the result after settlement:
var outcomes: AsyncStream<TipJarOutcome> { get }
func listenForTransactions() async {
for await verification in Transaction.updates {
let signal = await settle(verification)
publish(TipJarPolicy.outcome(for: signal))
}
}
That closed the obvious hole.
It left a smaller one.
A computed stream property is a subscription factory
The store's outcomes property is computed.
Reading it creates a new AsyncStream, stores its continuation under a fresh UUID, and returns the stream to that subscriber.
Conceptually:
var outcomes: AsyncStream<TipJarOutcome> {
let subscriptionID = UUID()
let pair = AsyncStream<TipJarOutcome>.makeStream()
outcomeContinuations[subscriptionID] = pair.continuation
return pair.stream
}
This is not a passive getter.
It performs registration.
Before the property is read, the continuation dictionary has no entry for that model.
After it is read, the producer can yield into that subscriber's buffer.
That makes the timing of the property access part of correctness.
The initial model implementation read the property only inside its long-lived listener method:
func listenForOutcomes() async {
for await outcome in store.outcomes {
apply(outcome)
}
}
The SwiftUI sheet started that method with .task.
It looks reasonable.
The sheet appears, the task starts, the loop reads the stream, and the model receives outcomes.
Except “the task starts” is not an inline function call.
Creating the view and scheduling the task do not synchronously execute the first line of the task body.
The app's StoreKit listener is another task.
If it settles and publishes a transaction before the sheet task evaluates store.outcomes, the store has no continuation for the sheet.
The value is not dropped by AsyncStream.
It never reaches an AsyncStream at all.
Buffering begins after construction, not before existence
By default, AsyncStream.makeStream() uses an unbounded buffering policy.
Values yielded to its continuation can wait until iteration consumes them.
Apple's makeStream(of:bufferingPolicy:) documentation shows .unbounded as the default, and SE-0314 describes yielded elements being buffered until consumption.
That contract is exactly what the fix relies on.
It is also easy to overread.
Buffering protects this order:
create stream and retain continuation
producer yields value
consumer starts iterating later
consumer receives buffered value
It cannot protect this order:
producer tries to publish
no continuation exists
consumer creates stream later
The second sequence has no buffer yet.
Asking how many values the nonexistent stream can retain is a surprisingly philosophical way to debug an app.
The answer remains zero.
Register in model initialization
The corrected model captures its stream when the model is initialized:
@MainActor
@Observable
final class TipJarModel {
private let store: TipJarStore
private let outcomeStream: AsyncStream<TipJarOutcome>
init(store: TipJarStore) {
self.store = store
outcomeStream = store.outcomes
}
func listenForOutcomes() async {
for await outcome in outcomeStream {
apply(outcome)
}
}
}
The model now has two distinct lifecycle events.
Initialization registers the subscription.
The task later consumes it.
If the producer publishes between those events, the stream already exists and the default buffer holds the outcome.
This is a small change with a large semantic effect.
The source of truth is not “a listener task will probably begin soon.”
It is “this model owns an already-registered stream.”
The task can be delayed by scheduling, view work, or test choreography without reopening the registration race.
Do not fix the race with a sleep
The first version looked healthy under a friendly fake.
The fake's stream existed early enough that the test never exercised the gap.
That is a classic concurrency-test favor.
The test and implementation accidentally agree on a polite schedule.
The regression test was changed to be rude on purpose.
For each possible delayed outcome, it performs this order:
let store = FakeTipJarStore()
store.listenerOutcome = outcome
let model = TipJarModel(store: store)
await store.listenForTransactions()
await model.listenForOutcomes()
#expect(model.purchaseNote == expectedNote)
The producer publishes before the model begins iteration.
There is no Task.sleep.
There is no race to see which task wins today.
The test deliberately fixes the most hostile legal order.
Before the model retained the stream in init, this test missed four delayed outcome mappings.
After the change, the values were buffered for the already-registered subscriber and the test passed.
In a current focused simulator run at the implementation commit, all 11 tests in TipJarModelTests passed, including the delayed-listener case.
I first tried a method-level xcodebuild -only-testing filter that reported success while executing zero Swift tests.
That run is not evidence.
The suite-level filter is the useful result because its output explicitly reports 11 tests in one suite.
A green command with zero readers has a lot in common with a published value with zero subscribers.
Both are technically calm and practically useless.
Subscriber-scoped streams need cleanup
The store does not expose one stream instance shared forever.
Each read of outcomes creates an independent stream and continuation.
That fits a sheet that can close and reopen.
A newly created model gets its own subscription.
The store publishes each delayed outcome to the currently registered continuations.
Per-subscriber streams create another lifecycle responsibility: removal.
The implementation attaches an onTermination closure that returns to MainActor and removes the continuation by subscription ID.
Without cleanup, every closed sheet would leave another continuation in the dictionary.
Future outcomes would be yielded into abandoned subscriptions, and the store would retain a small museum of UI lifetimes.
The design therefore pairs two events:
stream creation -> register continuation
stream termination -> remove continuation
Registration and cleanup belong to the same abstraction.
If a computed property creates the subscription, it should also arrange its retirement before returning.
One mapping for immediate and delayed outcomes
The lifecycle fix could still have produced two behavioral paths.
The direct purchase() result might map .pending one way, while a later transaction update maps it another way.
Instead, the model uses one apply(_:) method for both sources:
func tip(_ tier: TipJarTier) async {
apply(TipJarPolicy.outcome(for: await store.purchase(tier)))
}
func listenForOutcomes() async {
for await outcome in outcomeStream {
apply(outcome)
}
}
The UI state transition is shared.
A thanked outcome shows thanks.
A failed or discarded outcome shows failure.
A pending outcome shows awaiting approval.
A cancelled outcome clears the note.
The producer path is different.
The presentation policy is not.
That matters because delayed StoreKit updates are not a special category of user meaning.
They are the late arrival of the same purchase outcome.
The stream is not the owner of the StoreKit listener
The model-level subscription solves delivery to an open sheet.
It does not mean the sheet should own Transaction.updates.
The StoreKit listener needs to live with the app process so transactions can be settled whether or not the About sheet is visible.
The application therefore owns one StoreKitTipJarStore, starts its transaction listener from the app hierarchy, and injects that same store instance down to the sheet.
The sheet owns a subscription.
The app owns the producer.
Confusing those lifetimes would trade one lost-result bug for a listener that disappears whenever the presentation closes.
The ownership chain is:
App lifetime
-> StoreKitTipJarStore
-> Transaction.updates listener
-> subscriber continuations
Sheet lifetime
-> TipJarModel
-> retained outcome stream
-> consumer task
The long-lived object receives and settles external events.
The short-lived model registers early, consumes while visible, and terminates its subscription when it goes away.
The practical rule
When an AsyncStream property creates a subscription, do not hide that creation inside a consumer task that may start later.
Decide which object owns the subscription.
Create and retain the stream at that object's initialization boundary.
Let buffering cover the gap between registration and consumption.
Give every subscriber an explicit termination path.
Test the hostile order directly: publish after registration but before iteration.
And when a test command says green, verify that the intended test actually ran.
The difficult part of an asynchronous stream is not always iteration.
Sometimes it is making sure the stream exists before the first value needs it.
Get the next post.
If you made it to the end, meet the next post in your inbox or RSS reader.