AsyncSequence and AsyncStream
You will buildSuggestions under the trip editor's City field, from MapKit — whose search completer answers only through a delegate — built three times, with the editor working the same each time: an async method and a while let; the two protocols and for await, with the loop the compiler writes for you; and an AsyncStream. Then a stream that cancels the search when nobody is listening.
TimeAbout 100 minutes
WhyLesson 34's await waits for one answer. Some things answer many times: a search that refines itself as you type, a location that changes, a notification that keeps arriving. Swift's model for those is the async sequence, read with for await — one loop that suspends between values and ends when the sequence does. Underneath, it is nothing you have not met: an async method that answers one value at a time and nil at the end, and a loop that asks until it gets nil. This lesson builds that first, by hand, and only then lets the compiler and the standard library take it over — so that for await and AsyncStream are things you could have written, not things to believe in. Newer APIs hand you a sequence directly; older ones answer through a delegate or a closure, as most of UIKit did, and AsyncStream turns them into one. Interviewers ask for exactly that: wrap this delegate API.
Open your Trips project as lesson 38 left it: double-click Trips.xcodeproj in Finder, or use File ▸ Open… (⌘O) in Xcode.
If you did not finish lesson 38, or would rather start from a known state, download Trips-lesson-38.zip instead. It is the project exactly as it stood at the end of lesson 38. Unzip it and open Trips.xcodeproj.
Run it (⌘R) to check that it builds. You should see where lesson 38 ended:

async method and a while letMapKit has "A utility object for generating a list of completion strings based on a partial search string that you provide": MKLocalSearchCompleter, which suggests places while someone types. Give it the text typed so far in queryFragment, and it calls its delegate's completerDidUpdateResults(_:) whenever it has new suggestions, which you then read from its results. A delegate, and no async version.
This lesson builds the answer three times, and the editor works the same each time. First with nothing but what lesson 37 taught: an async method that answers the next list of suggestions when MapKit has one, and a while let that asks again and again. No protocol, no stream. Then the two protocols that let for await do the asking. Then AsyncStream, which is the first version ready-made, with the parts it lacks.
next() answers in one of two ways. If a list arrived while nobody was asking — MapKit can answer while the editor is still busy with the last list — it is waiting in newest, and next() returns it at once. Otherwise next() waits: withCheckedContinuation hands it a continuation, which it keeps in waiting, and the await parks the loop until something resumes it. The delegate method is that something: a new list goes to whoever is waiting, or, if nobody is, into newest. Each suggestion is copied out of MapKit's MKLocalSearchCompletion, a class, into a Sendable struct of two strings, so that what next() hands out belongs to nobody else. Make a new Swift File named PlaceCompleter, and replace everything in it:
import MapKit
/// A place MapKit suggests while the person types: "Portland, OR", in "United States".
struct Suggestion: Hashable, Sendable {
let title: String
let subtitle: String
/// The city alone: the title up to its first comma.
var city: String {
String(title.split(separator: ",").first ?? "")
}
/// The country: the last part of the subtitle.
var country: String {
subtitle.split(separator: ", ").last.map(String.init) ?? ""
}
}
/// MapKit's search completer — an API that answers through a delegate — as something the editor can ask, again and
/// again, for the next list of suggestions. `@MainActor`, the class and its conformance: the editor reads it, and MapKit
/// calls the delegate on the main thread.
@MainActor
final class PlaceCompleter: NSObject, @MainActor MKLocalSearchCompleterDelegate {
private let completer = MKLocalSearchCompleter()
/// Whoever is waiting in `next()` for the next list, if anyone is.
private var waiting: CheckedContinuation<[Suggestion]?, Never>?
/// A list that arrived while nobody was waiting, kept for the next call of `next()`.
private var newest: [Suggestion]?
override init() {
super.init()
completer.delegate = self
completer.resultTypes = .address
// towns and cities, not streets
completer.addressFilter = MKAddressFilter(including: .locality)
}
/// The next list of suggestions: at once if one arrived while nobody was asking, otherwise when MapKit next answers.
func next() async -> [Suggestion]? {
if let list = newest {
newest = nil
return list
}
return await withCheckedContinuation { continuation in
waiting = continuation
}
}
/// Asks for suggestions for the text typed so far.
func search(_ text: String) {
completer.queryFragment = text
}
/// MapKit calls this, on its delegate, whenever it has new results: the list goes to whoever is waiting, or is kept.
func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) {
let list = completer.results.map { Suggestion(title: $0.title, subtitle: $0.subtitle) }
if let waiting {
self.waiting = nil
waiting.resume(returning: list)
} else {
newest = list
}
}
}The class says @MainActor, like every model in Trips: the editor reads it. Its conformance to MapKit's protocol says it too — @MainActor MKLocalSearchCompleterDelegate is an isolated conformance. MapKit declares the protocol on no actor, so its methods could in principle be called from any thread; the word promises that this class is only ever used as a delegate on the main actor, and Swift holds it to that: without it, Swift 6 stops with "conformance of 'PlaceCompleter' to protocol 'MKLocalSearchCompleterDelegate' crosses into main actor-isolated code". MapKit does call its delegate on the main thread (measured for this page), so the promise is true. A new Xcode project, whose default actor is the main actor, writes both words for you without showing them.
The editor shows the suggestions under the City field while the field has the keyboard, and a tap on one fills in City and Country. .onChange(of: draft.city) passes each change to the completer; the .task loops for as long as the editor is on the screen — while let asks next() until it answers nil, which so far it never does; step 6 comes back to that. In TripEditor.swift:
struct TripEditor: View { @State private var draft: Trip @State private var places = PlaceCompleter() @State private var suggestions: [Suggestion] = [] let onSave: (Trip) -> Void @Environment(\.dismiss) private var dismiss @FocusState private var focus: Field? /// The fields that can have the keyboard. enum Field { case city, country, price } init(trip: Trip, onSave: @escaping (Trip) -> Void) { _draft = State(initialValue: trip) self.onSave = onSave } var body: some View { NavigationStack { Form { TextField("City", text: $draft.city) .focused($focus, equals: .city) .submitLabel(.next) .onSubmit { focus = .country } if focus == .city { ForEach(suggestions, id: \.self) { suggestion in Button { draft.city = suggestion.city draft.country = suggestion.country focus = nil } label: { VStack(alignment: .leading) { Text(suggestion.title) Text(suggestion.subtitle) .font(.caption) .foregroundStyle(.secondary) } } } } TextField("Country", text: $draft.country) .focused($focus, equals: .country) .submitLabel(.done) Stepper(value: $draft.nights, in: 1...60) { Text("^[\(draft.nights) night](inflect: true)") } TextField("Price", value: $draft.price, format: .currency(code: "USD")) .focused($focus, equals: .price) .keyboardType(.decimalPad) } .onAppear { focus = .city } .onChange(of: draft.city) { places.search(draft.city) } .task { while let list = await places.next() { suggestions = list } } .navigationTitle("Edit trip") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } ToolbarItem(placement: .confirmationAction) { Button("Save") { onSave(draft) dismiss() } } ToolbarItemGroup(placement: .keyboard) { Spacer() Button("Done") { focus = nil } } } } }}struct TripEditor: View {
@State private var draft: Trip
@State private var places = PlaceCompleter()
@State private var suggestions: [Suggestion] = []
let onSave: (Trip) -> Void
@Environment(\.dismiss) private var dismiss
@FocusState private var focus: Field?
/// The fields that can have the keyboard.
enum Field {
case city, country, price
}
init(trip: Trip, onSave: @escaping (Trip) -> Void) {
_draft = State(initialValue: trip)
self.onSave = onSave
}
var body: some View {
NavigationStack {
Form {
TextField("City", text: $draft.city)
.focused($focus, equals: .city)
.submitLabel(.next)
.onSubmit { focus = .country }
if focus == .city {
ForEach(suggestions, id: \.self) { suggestion in
Button {
draft.city = suggestion.city
draft.country = suggestion.country
focus = nil
} label: {
VStack(alignment: .leading) {
Text(suggestion.title)
Text(suggestion.subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
TextField("Country", text: $draft.country)
.focused($focus, equals: .country)
.submitLabel(.done)
Stepper(value: $draft.nights, in: 1...60) {
Text("^[\(draft.nights) night](inflect: true)")
}
TextField("Price", value: $draft.price, format: .currency(code: "USD"))
.focused($focus, equals: .price)
.keyboardType(.decimalPad)
}
.onAppear { focus = .city }
.onChange(of: draft.city) { places.search(draft.city) }
.task {
while let list = await places.next() {
suggestions = list
}
}
.navigationTitle("Edit trip")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
onSave(draft)
dismiss()
}
}
ToolbarItemGroup(placement: .keyboard) {
Spacer()
Button("Done") { focus = nil }
}
}
}
}
}Run the app, open Seattle and tap Edit. Delete Seattle and type Portl:

Each suggestion is a button; VoiceOver reads one as "Portland, OR, United States". Tap Portland, OR:

The city is "Portland" — the title up to its comma — and the country the end of the subtitle. With the focus gone, the list is hidden. Tap Save:

The trip screen's .task(id:) from lesson 34 saw the city change and asked about Portland. That is the whole mechanism of an async sequence, and nothing in it is new: an async method that answers one value at a time and nil at the end, and a loop that asks until it gets nil. The rest of this lesson is the two ways Swift packages it — and first, what the loop itself compiles into.
await is a new functionLesson 37 showed the compiler cutting fly() in two at its await. The editor's loop has its await inside a while, so the cut goes through the loop — and a loop that is cut cannot stay a loop. The smallest version of the same loop, outside Trips, shows what becomes of it: a countdown with an async next() like the completer's and no protocol, and a while let like the editor's.
/// Three, two, one, handed out one at a time. No protocol: an `async` method that answers `nil` when it is done.
struct Countdown {
var left: Int
mutating func next() async -> Int? {
guard left > 0 else {
return nil
}
left -= 1
return left + 1
}
}var iterator = Countdown(left: 3)
while let number = await iterator.next() {
print(number)
}
print("Liftoff")What it printed
3
2
1
LiftoffHere is the loop written as the compiler builds it: plain functions, with no async and no await at all. next cannot return a value it does not have yet, so it takes the function to call when it has one. And the loop is two functions — everything before the await, and everything after it:
/// The countdown as plain functions, with no `async` and no `await`: `next` calls `whereToGoNext` instead of returning.
final class Countdown {
var left: Int
init(left: Int) {
self.left = left
}
func next(thenContinueAt whereToGoNext: (Int?) -> Void) {
guard left > 0 else {
whereToGoNext(nil)
return
}
left -= 1
whereToGoNext(left + 1)
}
}
/// The loop, as the two functions the compiler makes of it: everything before the `await`, and everything after.
final class Loop {
let iterator = Countdown(left: 3)
func start() {
iterator.next(thenContinueAt: gotAnswer)
}
func gotAnswer(_ number: Int?) {
guard let number else {
print("Liftoff")
return
}
print(number)
iterator.next(thenContinueAt: gotAnswer)
}
}It prints the same
3
2
1
Liftoffstart runs once. gotAnswer is the rest of the loop: the nil check, the body, and the next call of next — which is told to call gotAnswer again. There is no while left. Going round again is gotAnswer asking next to come back to gotAnswer; and return in an async function is that call. It does not go back up a stack, because nothing is on the stack: it goes forward, to the function the caller asked for. The line after the loop, print("Liftoff"), needs no function of its own — leaving the loop is the if taking its other branch, not an await. One await, one cut.
The compiler's own version has more pieces than two, because it also writes a hop at each await: when next() has answered, the task comes back to the executor the loop runs on before it goes on. These are the functions it made of main, from the compiled program, with the names the demangler gives them:
static Program.main() async -> ()
(1) suspend resume partial function for static Program.main() async -> ()
(2) await resume partial function for static Program.main() async -> ()
(3) suspend resume partial function for static Program.main() async -> ()The first is main as far as its first hop. A suspend resume partial function is a piece that begins after a hop; an await resume partial function, one that begins when an awaited call has answered — gotAnswer, in the hand-written version. Count the awaits, not the lines: one await, and main is four functions. Each is reached by a jump, never by a call, so that the thread's stack holds nothing of main while the task waits; what the pieces share — iterator — lives in a frame the task owns, not on any thread's stack. That is why a parked loop costs a frame, not a thread, and why the editor's loop can wait for MapKit for as long as it likes.
for await: the loop the compiler writes for youfor await is that while let, written by the compiler. Make the completer something it can loop over, and the editor's loop shrinks to one line. Two protocols carry it: AsyncSequence, with one requirement, makeAsyncIterator(); and AsyncIteratorProtocol, with next(). A type can be both, and the completer is — its iterator is itself. The conformances say @MainActor, as the delegate conformance does: the protocols' methods belong to no actor, and without the word Swift 6 refuses with the same "crosses into main actor-isolated code" error (a trick card shows it). In PlaceCompleter.swift:
/// MapKit's search completer — an API that answers through a delegate — as something the editor can ask, again and/// again, for the next list of suggestions. `@MainActor`, the class and its conformance: the editor reads it, and MapKit/// calls the delegate on the main thread./// calls the delegate on the main thread — and so are its conformances to the two async-sequence protocols.@MainActorfinal class PlaceCompleter: NSObject, @MainActor MKLocalSearchCompleterDelegate {final class PlaceCompleter: NSObject, @MainActor MKLocalSearchCompleterDelegate, @MainActor AsyncSequence, @MainActor AsyncIteratorProtocol { private let completer = MKLocalSearchCompleter() /// Whoever is waiting in `next()` for the next list, if anyone is. private var waiting: CheckedContinuation<[Suggestion]?, Never>? /// A list that arrived while nobody was waiting, kept for the next call of `next()`. private var newest: [Suggestion]? override init() { super.init() completer.delegate = self completer.resultTypes = .address // towns and cities, not streets completer.addressFilter = MKAddressFilter(including: .locality) } /// `for await` asks the sequence for an iterator, then asks the iterator for each value: this class is both. func makeAsyncIterator() -> PlaceCompleter { self } /// The next list of suggestions: at once if one arrived while nobody was asking, otherwise when MapKit next answers. func next() async -> [Suggestion]? { if let list = newest { newest = nil return list } return await withCheckedContinuation { continuation in waiting = continuation } } /// Asks for suggestions for the text typed so far. func search(_ text: String) { completer.queryFragment = text } /// MapKit calls this, on its delegate, whenever it has new results: the list goes to whoever is waiting, or is kept. func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) { let list = completer.results.map { Suggestion(title: $0.title, subtitle: $0.subtitle) } if let waiting { self.waiting = nil waiting.resume(returning: list) } else { newest = list } }}/// MapKit's search completer — an API that answers through a delegate — as something the editor can ask, again and
/// again, for the next list of suggestions. `@MainActor`, the class and its conformance: the editor reads it, and MapKit
/// calls the delegate on the main thread — and so are its conformances to the two async-sequence protocols.
@MainActor
final class PlaceCompleter: NSObject, @MainActor MKLocalSearchCompleterDelegate, @MainActor AsyncSequence, @MainActor AsyncIteratorProtocol {
private let completer = MKLocalSearchCompleter()
/// Whoever is waiting in `next()` for the next list, if anyone is.
private var waiting: CheckedContinuation<[Suggestion]?, Never>?
/// A list that arrived while nobody was waiting, kept for the next call of `next()`.
private var newest: [Suggestion]?
override init() {
super.init()
completer.delegate = self
completer.resultTypes = .address
// towns and cities, not streets
completer.addressFilter = MKAddressFilter(including: .locality)
}
/// `for await` asks the sequence for an iterator, then asks the iterator for each value: this class is both.
func makeAsyncIterator() -> PlaceCompleter {
self
}
/// The next list of suggestions: at once if one arrived while nobody was asking, otherwise when MapKit next answers.
func next() async -> [Suggestion]? {
if let list = newest {
newest = nil
return list
}
return await withCheckedContinuation { continuation in
waiting = continuation
}
}
/// Asks for suggestions for the text typed so far.
func search(_ text: String) {
completer.queryFragment = text
}
/// MapKit calls this, on its delegate, whenever it has new results: the list goes to whoever is waiting, or is kept.
func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) {
let list = completer.results.map { Suggestion(title: $0.title, subtitle: $0.subtitle) }
if let waiting {
self.waiting = nil
waiting.resume(returning: list)
} else {
newest = list
}
}
}And the loop. In TripEditor.swift:
struct TripEditor: View { @State private var draft: Trip @State private var places = PlaceCompleter() @State private var suggestions: [Suggestion] = [] let onSave: (Trip) -> Void @Environment(\.dismiss) private var dismiss @FocusState private var focus: Field? /// The fields that can have the keyboard. enum Field { case city, country, price } init(trip: Trip, onSave: @escaping (Trip) -> Void) { _draft = State(initialValue: trip) self.onSave = onSave } var body: some View { NavigationStack { Form { TextField("City", text: $draft.city) .focused($focus, equals: .city) .submitLabel(.next) .onSubmit { focus = .country } if focus == .city { ForEach(suggestions, id: \.self) { suggestion in Button { draft.city = suggestion.city draft.country = suggestion.country focus = nil } label: { VStack(alignment: .leading) { Text(suggestion.title) Text(suggestion.subtitle) .font(.caption) .foregroundStyle(.secondary) } } } } TextField("Country", text: $draft.country) .focused($focus, equals: .country) .submitLabel(.done) Stepper(value: $draft.nights, in: 1...60) { Text("^[\(draft.nights) night](inflect: true)") } TextField("Price", value: $draft.price, format: .currency(code: "USD")) .focused($focus, equals: .price) .keyboardType(.decimalPad) } .onAppear { focus = .city } .onChange(of: draft.city) { places.search(draft.city) } .task { while let list = await places.next() { for await list in places { suggestions = list } } .navigationTitle("Edit trip") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } ToolbarItem(placement: .confirmationAction) { Button("Save") { onSave(draft) dismiss() } } ToolbarItemGroup(placement: .keyboard) { Spacer() Button("Done") { focus = nil } } } } }}struct TripEditor: View {
@State private var draft: Trip
@State private var places = PlaceCompleter()
@State private var suggestions: [Suggestion] = []
let onSave: (Trip) -> Void
@Environment(\.dismiss) private var dismiss
@FocusState private var focus: Field?
/// The fields that can have the keyboard.
enum Field {
case city, country, price
}
init(trip: Trip, onSave: @escaping (Trip) -> Void) {
_draft = State(initialValue: trip)
self.onSave = onSave
}
var body: some View {
NavigationStack {
Form {
TextField("City", text: $draft.city)
.focused($focus, equals: .city)
.submitLabel(.next)
.onSubmit { focus = .country }
if focus == .city {
ForEach(suggestions, id: \.self) { suggestion in
Button {
draft.city = suggestion.city
draft.country = suggestion.country
focus = nil
} label: {
VStack(alignment: .leading) {
Text(suggestion.title)
Text(suggestion.subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
TextField("Country", text: $draft.country)
.focused($focus, equals: .country)
.submitLabel(.done)
Stepper(value: $draft.nights, in: 1...60) {
Text("^[\(draft.nights) night](inflect: true)")
}
TextField("Price", value: $draft.price, format: .currency(code: "USD"))
.focused($focus, equals: .price)
.keyboardType(.decimalPad)
}
.onAppear { focus = .city }
.onChange(of: draft.city) { places.search(draft.city) }
.task {
for await list in places {
suggestions = list
}
}
.navigationTitle("Edit trip")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
onSave(draft)
dismiss()
}
}
ToolbarItemGroup(placement: .keyboard) {
Spacer()
Button("Done") { focus = nil }
}
}
}
}
}Run it, open Seattle, tap Edit, and type Portl in place of Seattle again:

for awaitScreenshot · iPhone 17, iOS 27.0Nothing changed on the screen, because nothing changed in what runs. Here is what the compiler does with that one line. While it is still checking types, it rewrites every for-in loop — await or not — into a while loop, in a C++ class named DesugarForEachStmt. For the countdown, the rewrite amounts to this, written as Swift you can type:
// `for await number in Countdown(left: 3) { print(number) }`, as the compiler rewrites it — written as Swift you can type.
// The compiler's own names are `$number$generator` and `$element`: the `$` keeps them out of your code's way.
var generator = Countdown(left: 3).makeAsyncIterator()
while let element = await generator.next(isolation: #isolation) {
let number = element
print(number)
}
print("Liftoff")It prints the same
3
2
1
LiftoffThree things in that rewrite answer three questions. Why the protocols. The rewrite calls two methods it has never seen — makeAsyncIterator() on your sequence, and next on whatever that returns — and it finds them by looking up the conformance to AsyncSequence. Without the conformance there is nothing to look up, and the loop fails before it is even built:
for await over the countdown of step 3, which conforms to nothing
for-in loop requires 'Countdown' to conform to 'AsyncSequence'
Where next runs. The rewrite passes #isolation — the actor the loop is on — to next(isolation:), which SE-0421 added. An iterator that implements next(isolation:) runs there, with no hop off the actor and back for every value; one that implements only next() is called through the default next(isolation:), and a nonisolated async next() still runs off the actor, exactly as it does from your own while let — a trick card measures both. The completer's next() is @MainActor, so every version in Trips runs on the main actor. Where the names come from. $number$generator and $element are the compiler's, and the $ keeps them out of your code's way. Here is the C++, shortened to the lines that do those things:
class DesugarForEachStmt {
ForEachStmt *stmt;
DeclContext *dc;
ASTContext &ctx;
bool isAsync;
…
DesugarForEachStmt(ForEachStmt *stmt)
: stmt(stmt), dc(stmt->getDeclContext()),
ctx(stmt->getDeclContext()->getASTContext()),
isAsync(stmt->getAwaitLoc().isValid()) {}
BraceStmt *operator()() {
…
sequenceProto =
isAsync ? ctx.getProtocol(KnownProtocolKind::AsyncSequence)
: (isBorrowing
? ctx.getProtocol(KnownProtocolKind::Iterable)
: ctx.getProtocol(KnownProtocolKind::Sequence));
seqConformanceRef = lookupConformance(seqType, sequenceProto);
…
buildMakeIteratorVar();
…
SmallVector<ASTNode, 2> stmts;
stmts.push_back(buildMakeIterator());
stmts.push_back(buildWhileStmt());
…
}
void buildMakeIteratorVar() {
std::string name;
{
if (auto np = dyn_cast_or_null<NamedPattern>(stmt->getPattern()))
name = "$" + np->getBoundName().str().str();
name += "$generator";
}
…
}
Expr *buildNextCall() {
…
FuncDecl *nextFn = TypeChecker::getForEachIteratorNextFunction(
dc, stmt->getForLoc(), isAsync);
TinyPtrVector<Identifier> labels;
if (nextFn && nextFn->getParameters()->size() == 1)
labels.push_back(ctx.Id_isolation);
…
}
WhileStmt *buildWhileStmt() {
Expr *nextCall = buildNextCall();
auto varName = isBorrowing ? "$span" : "$element";
…
auto *whileStmt = new (ctx)
WhileStmt(LabeledStmtInfo(), stmt->getForLoc(),
buildWhileCond(nextCallVar, nextCall), whileBody, true);
…
}
};Read the names and the shape is the one you typed in step 2. isAsync is whether you wrote await. sequenceProto is AsyncSequence for an await loop and Sequence for a plain one — the same class rewrites both. buildMakeIterator makes the var … = ….makeAsyncIterator() line; buildWhileStmt makes the while let, with buildNextCall as its condition and Id_isolation as the label of the argument it passes. After this rewrite nothing further down the compiler knows that there was ever a for. The SIL it emits for the countdown's for await — the compiler's own intermediate code, one step before LLVM — shows the two calls in main:
// static Program.main()
// function_ref Countdown.makeAsyncIterator()
%12 = function_ref @$s7program9CountdownV17makeAsyncIteratorACyF : $@convention(method) (Countdown) -> Countdown
%13 = apply %12(%11) : $@convention(method) (Countdown) -> Countdown
// function_ref AsyncIteratorProtocol.next(isolation:)
%23 = function_ref @$sScIsE4next9isolation7ElementQzSgScA_pSgYi_tYa7FailureQzYKF : $@convention(method) @async <τ_0_0 where τ_0_0 : AsyncIteratorProtocol> (@sil_isolated @guaranteed Optional<any Actor>, @inout τ_0_0) -> (@out Optional<τ_0_0.Element>, @error_indirect τ_0_0.Failure)
try_apply %23<Countdown>(%16, %24, %21, %22) : $@convention(method) @async <τ_0_0 where τ_0_0 : AsyncIteratorProtocol> (@sil_isolated @guaranteed Optional<any Actor>, @inout τ_0_0) -> (@out Optional<τ_0_0.Element>, @error_indirect τ_0_0.Failure), normal bb2, error bb5makeAsyncIterator() once, and then AsyncIteratorProtocol.next(isolation:) — generic over the iterator, with the actor as its first argument (@sil_isolated) and the iterator passed @inout, so that each call can change it. Your next() runs through it. So for await adds nothing to the machine. It is the while let of step 2 with the protocols doing the naming — which is why the screen did not change. What the protocols add is for everyone else: map, filter, prefix and the rest of AsyncSequence work on the completer now, and so does any function that takes some AsyncSequence.
The rewrite is DesugarForEachStmt in lib/Sema/TypeCheckStmt.cpp of the Swift compiler, linked below at the commit this page quotes; -dump-ast prints its result as a desugared_loop under every for_each_stmt, and the guide await, in full shows that tree, with the whole path from one await to AsyncStream and an actor of your own. SE-0298 is the proposal for AsyncSequence, AsyncIteratorProtocol and for await; SE-0421 is the one that gave next its isolation parameter.
Where this comes from: The Swift compiler's rewrite of every for-in loop, async or not: DesugarForEachStmt in lib/Sema/TypeCheckStmt.cpp · SE-0298: Async/Await: Sequences — AsyncSequence, AsyncIteratorProtocol and for await · SE-0421: Generalize effect polymorphism for AsyncSequence and AsyncIteratorProtocol — next(isolation:)
AsyncStream: the same, ready-madeStep 2's next() keeps one waiting continuation and one newest list, and resumes or keeps as each list arrives. That is an AsyncStream, in miniature. Apple's page names the job: "an asynchronous stream is well-suited to adapt callback- or delegation-based APIs to participate with async-await." AsyncStream.makeStream(of:bufferingPolicy:) gives a sequence and its continuation; each call of the continuation's yield(_:) is a return from the next() you no longer write, and finish() is its nil. The countdown, as a stream:
let (countdown, continuation) = AsyncStream.makeStream(of: Int.self)
for number in [3, 2, 1] {
continuation.yield(number)
}
continuation.finish()
for await number in countdown {
print(number)
}
print("Liftoff")The same, once more
3
2
1
LiftoffWhat the stream has that step 2's class lacks: a buffer, with a policy; an end; and cancellation. The buffering policy says what happens when values arrive faster than the loop takes them. By default nothing is thrown away — Apple: "By default, the buffer limit is Int.max, which means the value is unbounded." For suggestions only the newest list matters: when two more letters have been typed, the old list is wrong. .bufferingNewest(1) — "When the buffer is full, discard the oldest element in the buffer." — is step 2's newest, as a policy. The delegate method yields every new list, and the waiting, the keeping and the resuming are the stream's. In PlaceCompleter.swift:
/// MapKit's search completer — an API that answers through a delegate — as something the editor can ask, again and/// again, for the next list of suggestions. `@MainActor`, the class and its conformance: the editor reads it, and MapKit/// calls the delegate on the main thread — and so are its conformances to the two async-sequence protocols./// MapKit's search completer — an API that answers through a delegate — as an async sequence of suggestions./// `@MainActor`, the class and its conformance: the editor reads it, and MapKit calls the delegate on the main thread.@MainActorfinal class PlaceCompleter: NSObject, @MainActor MKLocalSearchCompleterDelegate, @MainActor AsyncSequence, @MainActor AsyncIteratorProtocol {final class PlaceCompleter: NSObject, @MainActor MKLocalSearchCompleterDelegate { private let completer = MKLocalSearchCompleter() /// Whoever is waiting in `next()` for the next list, if anyone is. private var waiting: CheckedContinuation<[Suggestion]?, Never>? /// A list that arrived while nobody was waiting, kept for the next call of `next()`. private var newest: [Suggestion]? private var continuation: AsyncStream<[Suggestion]>.Continuation? override init() { super.init() completer.delegate = self completer.resultTypes = .address // towns and cities, not streets completer.addressFilter = MKAddressFilter(including: .locality) } /// `for await` asks the sequence for an iterator, then asks the iterator for each value: this class is both. func makeAsyncIterator() -> PlaceCompleter { self } /// The next list of suggestions: at once if one arrived while nobody was asking, otherwise when MapKit next answers. func next() async -> [Suggestion]? { if let list = newest { newest = nil return list } return await withCheckedContinuation { continuation in waiting = continuation } /// The suggestions for whatever `search(_:)` was last given. Only the newest list is kept. func suggestions() -> AsyncStream<[Suggestion]> { let (stream, continuation) = AsyncStream.makeStream(of: [Suggestion].self, bufferingPolicy: .bufferingNewest(1)) self.continuation = continuation return stream } /// Asks for suggestions for the text typed so far. func search(_ text: String) { completer.queryFragment = text } /// MapKit calls this, on its delegate, whenever it has new results: the list goes to whoever is waiting, or is kept. /// MapKit calls this, on its delegate, whenever it has new results. func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) { let list = completer.results.map { Suggestion(title: $0.title, subtitle: $0.subtitle) } if let waiting { self.waiting = nil waiting.resume(returning: list) } else { newest = list } continuation?.yield(completer.results.map { Suggestion(title: $0.title, subtitle: $0.subtitle) }) }}/// MapKit's search completer — an API that answers through a delegate — as an async sequence of suggestions.
/// `@MainActor`, the class and its conformance: the editor reads it, and MapKit calls the delegate on the main thread.
@MainActor
final class PlaceCompleter: NSObject, @MainActor MKLocalSearchCompleterDelegate {
private let completer = MKLocalSearchCompleter()
private var continuation: AsyncStream<[Suggestion]>.Continuation?
override init() {
super.init()
completer.delegate = self
completer.resultTypes = .address
// towns and cities, not streets
completer.addressFilter = MKAddressFilter(including: .locality)
}
/// The suggestions for whatever `search(_:)` was last given. Only the newest list is kept.
func suggestions() -> AsyncStream<[Suggestion]> {
let (stream, continuation) = AsyncStream.makeStream(of: [Suggestion].self, bufferingPolicy: .bufferingNewest(1))
self.continuation = continuation
return stream
}
/// Asks for suggestions for the text typed so far.
func search(_ text: String) {
completer.queryFragment = text
}
/// MapKit calls this, on its delegate, whenever it has new results.
func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) {
continuation?.yield(completer.results.map { Suggestion(title: $0.title, subtitle: $0.subtitle) })
}
}The editor loops over the stream the completer hands out. In TripEditor.swift:
struct TripEditor: View { @State private var draft: Trip @State private var places = PlaceCompleter() @State private var suggestions: [Suggestion] = [] let onSave: (Trip) -> Void @Environment(\.dismiss) private var dismiss @FocusState private var focus: Field? /// The fields that can have the keyboard. enum Field { case city, country, price } init(trip: Trip, onSave: @escaping (Trip) -> Void) { _draft = State(initialValue: trip) self.onSave = onSave } var body: some View { NavigationStack { Form { TextField("City", text: $draft.city) .focused($focus, equals: .city) .submitLabel(.next) .onSubmit { focus = .country } if focus == .city { ForEach(suggestions, id: \.self) { suggestion in Button { draft.city = suggestion.city draft.country = suggestion.country focus = nil } label: { VStack(alignment: .leading) { Text(suggestion.title) Text(suggestion.subtitle) .font(.caption) .foregroundStyle(.secondary) } } } } TextField("Country", text: $draft.country) .focused($focus, equals: .country) .submitLabel(.done) Stepper(value: $draft.nights, in: 1...60) { Text("^[\(draft.nights) night](inflect: true)") } TextField("Price", value: $draft.price, format: .currency(code: "USD")) .focused($focus, equals: .price) .keyboardType(.decimalPad) } .onAppear { focus = .city } .onChange(of: draft.city) { places.search(draft.city) } .task { for await list in places { for await list in places.suggestions() { suggestions = list } } .navigationTitle("Edit trip") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } ToolbarItem(placement: .confirmationAction) { Button("Save") { onSave(draft) dismiss() } } ToolbarItemGroup(placement: .keyboard) { Spacer() Button("Done") { focus = nil } } } } }}struct TripEditor: View {
@State private var draft: Trip
@State private var places = PlaceCompleter()
@State private var suggestions: [Suggestion] = []
let onSave: (Trip) -> Void
@Environment(\.dismiss) private var dismiss
@FocusState private var focus: Field?
/// The fields that can have the keyboard.
enum Field {
case city, country, price
}
init(trip: Trip, onSave: @escaping (Trip) -> Void) {
_draft = State(initialValue: trip)
self.onSave = onSave
}
var body: some View {
NavigationStack {
Form {
TextField("City", text: $draft.city)
.focused($focus, equals: .city)
.submitLabel(.next)
.onSubmit { focus = .country }
if focus == .city {
ForEach(suggestions, id: \.self) { suggestion in
Button {
draft.city = suggestion.city
draft.country = suggestion.country
focus = nil
} label: {
VStack(alignment: .leading) {
Text(suggestion.title)
Text(suggestion.subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
TextField("Country", text: $draft.country)
.focused($focus, equals: .country)
.submitLabel(.done)
Stepper(value: $draft.nights, in: 1...60) {
Text("^[\(draft.nights) night](inflect: true)")
}
TextField("Price", value: $draft.price, format: .currency(code: "USD"))
.focused($focus, equals: .price)
.keyboardType(.decimalPad)
}
.onAppear { focus = .city }
.onChange(of: draft.city) { places.search(draft.city) }
.task {
for await list in places.suggestions() {
suggestions = list
}
}
.navigationTitle("Edit trip")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
onSave(draft)
dismiss()
}
}
ToolbarItemGroup(placement: .keyboard) {
Spacer()
Button("Done") { focus = nil }
}
}
}
}
}Run it and type Portl once more:

AsyncStreamScreenshot · iPhone 17, iOS 27.0Three versions, one screen. The one to keep is this one: it is less code, and it ends — which is the next step.
Step 2's loop had no end: its next() never answered nil, and a task cancelled while waiting in it would have waited on. A for await loop over a stream ends in one of two ways. The producer can call the continuation's finish() — "This causes the sequence iterator to produce a nil, which terminates the sequence." Or the task that runs the loop can be cancelled, which is what happens to the editor's .task when the editor closes: Apple, again, "Canceling an active iteration invokes the onTermination callback first, then resumes by yielding nil." Either way the code after the loop runs.
onTermination is the place to stop whatever was producing values: a timer, an observer, a search. Here, a search can still be on its way when the editor closes; cancelling it saves MapKit the work. The callback is @Sendable and may be called from any thread, so it does not touch the main-actor completer itself — it asks the main actor to, with a Task. It holds self for as long as the stream lives, which is not a leak: "After reaching a terminal state as a result of cancellation, the AsyncStream sets the callback to nil." In PlaceCompleter.swift:
/// MapKit's search completer — an API that answers through a delegate — as an async sequence of suggestions./// `@MainActor`, the class and its conformance: the editor reads it, and MapKit calls the delegate on the main thread.@MainActorfinal class PlaceCompleter: NSObject, @MainActor MKLocalSearchCompleterDelegate { private let completer = MKLocalSearchCompleter() private var continuation: AsyncStream<[Suggestion]>.Continuation? override init() { super.init() completer.delegate = self completer.resultTypes = .address // towns and cities, not streets completer.addressFilter = MKAddressFilter(including: .locality) } /// The suggestions for whatever `search(_:)` was last given. Only the newest list is kept. func suggestions() -> AsyncStream<[Suggestion]> { let (stream, continuation) = AsyncStream.makeStream(of: [Suggestion].self, bufferingPolicy: .bufferingNewest(1)) continuation.onTermination = { reason in print("Suggestions ended: \(reason)") Task { @MainActor in // nobody is listening: stop any search still going self.completer.cancel() } } self.continuation = continuation return stream } /// Asks for suggestions for the text typed so far. func search(_ text: String) { completer.queryFragment = text } /// MapKit calls this, on its delegate, whenever it has new results. func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) { continuation?.yield(completer.results.map { Suggestion(title: $0.title, subtitle: $0.subtitle) }) }}/// MapKit's search completer — an API that answers through a delegate — as an async sequence of suggestions.
/// `@MainActor`, the class and its conformance: the editor reads it, and MapKit calls the delegate on the main thread.
@MainActor
final class PlaceCompleter: NSObject, @MainActor MKLocalSearchCompleterDelegate {
private let completer = MKLocalSearchCompleter()
private var continuation: AsyncStream<[Suggestion]>.Continuation?
override init() {
super.init()
completer.delegate = self
completer.resultTypes = .address
// towns and cities, not streets
completer.addressFilter = MKAddressFilter(including: .locality)
}
/// The suggestions for whatever `search(_:)` was last given. Only the newest list is kept.
func suggestions() -> AsyncStream<[Suggestion]> {
let (stream, continuation) = AsyncStream.makeStream(of: [Suggestion].self, bufferingPolicy: .bufferingNewest(1))
continuation.onTermination = { reason in
print("Suggestions ended: \(reason)")
Task { @MainActor in
// nobody is listening: stop any search still going
self.completer.cancel()
}
}
self.continuation = continuation
return stream
}
/// Asks for suggestions for the text typed so far.
func search(_ text: String) {
completer.queryFragment = text
}
/// MapKit calls this, on its delegate, whenever it has new results.
func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) {
continuation?.yield(completer.results.map { Suggestion(title: $0.title, subtitle: $0.subtitle) })
}
}Run the app, open the editor again, type a few letters, and tap Cancel:

And the console, from the moment of the tap:
Suggestions ended: cancelledThe editor went away, SwiftUI cancelled its .task, the stream ended with cancelled, and the loop over it ended. A stream has one reader at a time: a second for await over the same stream would take some of the values and leave the rest to the first — an edge case shows it. Each editor makes its own PlaceCompleter, so each has its own stream.
A stream's continuation is called many times. The older kind, for an API that answers once, must be called exactly once: withCheckedContinuation hands you one, you call its resume with the answer, and the await that made it carries on. Resume it twice and a checked continuation stops the program; forget to, and the task waits forever — this lesson's edge cases show both. Swift 6.4 adds withContinuation(of:), whose continuation makes both mistakes impossible to miss. Here is its type, as the iOS 27 SDK declares it:
@frozen public struct Continuation<Success, Failure> : ~Copyable, @unchecked Sendable where Failure : Error, Success : ~Copyable {
internal let context: Builtin.RawUnsafeContinuation
…
deinit
…
public consuming func resume(returning value: consuming sending Success) where Failure == Never {
Builtin.resumeNonThrowingContinuationReturning(context, value)
discard self
}
…
public consuming func resume(throwing error: __owned Failure) {
Builtin.resumeThrowingContinuationThrowing(context, error)
discard self
}
…
}Four words carry it. ~Copyable: a Continuation cannot be copied, so there is only ever one, with one owner. consuming, on every resume: calling it uses the continuation up — it takes ownership of self, and when it returns there is no continuation left to call anything on. discard self, at the end of each resume: the continuation ends there without running its deinit. And deinit — which therefore runs only for a continuation that was never resumed.
The first three make a second resume a compile error, before the program ever runs:
Two resumes in one closure
'continuation' consumed more than once
note: consumed here
note: consumed again here
The last makes a forgotten resume stop the program — with a message, every time — instead of leaving a task waiting forever. The continuation is destroyed at the end of the closure; nothing consumed it, so its deinit runs, and that deinit is a trap:
let answer = await withContinuation(of: String.self) { continuation in
print("never resumed")
}
print("got", answer)It compiles, and stops the program
_Concurrency/Continuation.swift:56: Fatal error: Continuation was deinitialized without being resumed.
Child process terminated with signal 5: Trace/BPT trap
There is a limit. The compiler can only enforce "once" where it can see the once. Inside an old API's completion handler — an escaping closure that could run any number of times — it refuses to let you resume at all (a flash card shows the error), and SE-0528 keeps withCheckedContinuation for exactly that case.
What ~Copyable, consuming, deinit and discard self are, with the other two ways to pass a value — borrowing and inout — the consume and copy operators, noncopyable generics, Optional.take(), InlineArray and Span; and underneath, what the compiler does: SIL's @guaranteed and @owned, the copy it inserts when you hand over a value you still need, and the move checker that writes "used after consume" — the SIL it is handed, with a copy in it, and the SIL it hands on, with that copy turned into a move. All of it is in the guide Ownership, in full, every piece run on the simulator.
PlaceCompleter, which turns MapKit's delegate-based search completer into an AsyncStream of suggestions, keeping only the newest list — built first by hand, as an async next() with one waiting continuation and one kept list, then as an AsyncSequence that is its own iterator.for await in a .task; a tap fills in City and Country. The same screen from a while let, from a for await over the completer, and from the stream.while let that for await is rewritten into.onTermination handler that cancels the search when the editor closes, hopping to the main actor to do it.Guide: what await does, from one cut function to for await, AsyncStream and an actor of your own, each with a program that runs, is in await, in full.
Guide: ownership — ~Copyable, borrowing, inout, consuming, deinit and discard self, down to the SIL and the compiler pass that checks them — is in Ownership, in full.
Next, Combine — how to read it in older code, and how to move it to what you have learned since.
Stuck, or want to compare with your own? This is the project as it stands at the end of this lesson, built and run before it was put here.
Trips.xcodeproj in Xcode.24 cards for this lesson, each labelled with its topic. 12 ask how you would write something; the other 12 are trick questions, written to catch a misunderstanding. Answer in your head first, then tap the card.
AsyncStream.makeStream(of:) gives the stream and its continuation; yield sends a value, finish() ends the sequence — and with it the for await loop: "the loop ended".
continuation.onTermination runs when the stream ends — finished, or because the task looping over it was cancelled: "terminated: cancelled". Invalidate the timer, remove the observer, cancel the request there.
AsyncThrowingStream, and finish(throwing:). The loop becomes for try await, and the error comes out of it after the values before it: "got Seattle", then "the loop threw Lost()".
async one.withCheckedContinuation (or withCheckedThrowingContinuation, for an API that can fail): the old API's callback resumes the continuation exactly once. Swift 6.4's new Continuation cannot do this job — a trick card shows why. It printed Seattle.
A buffering policy: .bufferingNewest(1) keeps the last value and drops older ones not yet read — right for "the current suggestions" or "the temperature now". The default keeps everything. Here: [5].
AsyncSequence has filter, map, prefix, first(where:), reduce and more, each giving another async sequence. Debounce, throttle and merge are in the Swift Async Algorithms package. It printed 30 and 60.
url.lines — "The URL’s resource data, as an asynchronous sequence of lines of text." It printed line: Seattle first.

NotificationCenter.default.notifications(named:) in a .task: no observer to remove, because the loop ends when the view goes. Once at launch, once after Home and back: "became active: 2". For the scene's own phase, SwiftUI's scenePhase is simpler.
Nothing special: Apple, "The continuation conforms to Sendable, which permits calling it from concurrent contexts external to the iteration of the AsyncStream." It printed from another thread: [1, 2, 3].
deinit that runs only if nothing ever does.~Copyable makes one owner; consuming lets a method take the value; discard self ends it without its deinit. It printed: close(): closing 3
end of main
deinit: closing 4 for you — a was closed by hand, so its deinit never ran; nothing consumed b, so its deinit ran when main ended. That is the shape of Swift 6.4's Continuation.
async method of your own hands out one at a time — with no protocol in sight.while let asks until next() answers nil: 3, 2, 1, then "Liftoff". Nothing here is an AsyncSequence: a while let needs only an optional, and the await only an async method. The compiler cuts main at the await into four functions (step 3).
for await.Two protocols, one struct: AsyncSequence for makeAsyncIterator(), AsyncIteratorProtocol for next(); Element and Failure are inferred from next(). The same output as the while let — 3, 2, 1, "Liftoff" — because for await is rewritten into one: the SIL in step 4 shows it calling exactly those two methods.
for await loop. Are they lost?No — the stream buffers them until a loop takes them: "Seattle", "Vancouver". With the default policy it buffers without limit; a policy such as .bufferingNewest(1) keeps fewer.
for await loop has read a finished stream to its end. A second loop over the same stream: what does it get?Nothing: "first loop: [1, 2, 3] second loop: []". A stream is not an array: each value is delivered once, and a finished stream stays finished. To read the values again, keep them.
Continuation be resumed from an old API's completion handler?No: "noncopyable 'continuation' cannot be consumed when captured by an escaping closure or borrowed by a non-Escapable type" resume consumes the continuation, and the compiler cannot know that an escaping closure runs exactly once. SE-0528 says as much: such code "will produce compile-time errors", and withCheckedContinuation stays for it.
yield return after the stream has been finished?.terminated — "after finish: terminated" — and the value goes nowhere. yield never fails loudly; check its result when it matters whether anyone is still listening.
.bufferingOldest(1). Nobody is looping yet. What do two yields return?enqueued(remaining: 0), then dropped("Vancouver"). The buffer had room for one; the second value was refused and handed back. .bufferingNewest would have dropped Seattle instead.
Int.max: "enqueued(remaining: 9223372036854775807)". Apple: "By default, the buffer limit is Int.max, which means the value is unbounded." A fast producer and a slow loop can fill memory — choose a policy when that can happen.
onTermination run after finish() — at once, or when the loop has read the last value?At once: "terminated: finished" came before "got 1". The producer is done when it says so; values still in the buffer are delivered afterwards. Clean-up in onTermination must not assume the loop has finished.
Continuation is never resumed. A compile error, a task that waits forever — or something else?It compiles, and stops the program: _Concurrency/Continuation.swift:56: Fatal error: Continuation was deinitialized without being resumed. Nothing consumed the continuation, so it was destroyed at the end of the closure, and its deinit traps. A resumed continuation never gets there: every resume ends with discard self.
deinit?Only one that cannot be copied. deinitializer cannot be declared in struct 'Handle' that conforms to 'Copyable' A copyable struct has no single moment when "it" ends — every copy ends separately. Mark it ~Copyable and the same deinit compiles: one owner, one end, one deinit.
async next() and conforms to nothing. for await number in Countdown(left: 3) — does it compile?No: "for-in loop requires 'Countdown' to conform to 'AsyncSequence'" for await is the compiler rewriting the loop into a while let that calls makeAsyncIterator() and next() — and it finds those two through the protocols. No conformance, nothing to call. The while let you write yourself needs no protocol.
@MainActor class conforms to AsyncSequence and AsyncIteratorProtocol, so that a .task can loop over it. In Swift 6 mode, does the conformance compile?Not as written: "conformance of 'Suggestions' to protocol 'AsyncSequence' crosses into main actor-isolated code and can cause data races" The protocols' methods belong to no actor, and a main-actor method can stand in for them only if the conformance promises that it is used on the main actor alone: @MainActor AsyncSequence, an isolated conformance, as for MapKit's delegate in step 2. With the word on both it compiles, and a loop on the main actor prints "Portland" and "Portsmouth"; a loop anywhere else is an error.
next() prints the thread it runs on — first with while let over next(), then with for await. Where does next() run?Off the main actor, both times: "next() on the main thread: false", four times — twice per loop, for the value and then the nil. A nonisolated async function runs on the pool whoever calls it, and for await changes nothing about that: the default next(isolation:) runs on the loop's actor and then calls your next(), which leaves. Implement next(isolation:) yourself, and its isolated parameter keeps it on the caller's actor: "next(isolation:) on the main thread: true".
11 questions of the kind an interviewer uses to find out whether you really know this — the corners, the traps, and the things that changed recently. Each has its source. Wherever the answer is what some code does, that code was compiled and run, and what you see under “Show the answer” is what it really did. Say your answer aloud before you open it.
Two tasks loop over the same AsyncStream, and ten values are yielded. Does each loop get all ten?
No — they share them: "first loop: 5 values; second loop: 5 values; every value once: true"
Each value goes to one waiting loop, never to both. An AsyncStream is one channel with one reader in mind; it does not broadcast. To give several screens every value, give each its own stream — keep one continuation per listener, and yield to all of them — or use a tool built to share, such as Combine's publishers (the next lesson).
Where this comes from: AsyncStream · SE-0314: AsyncStream and AsyncThrowingStream — Measured in the iOS 27 simulator.
A class keeps one continuation, and values() makes a new stream each time it is called. Two loops call it, one after the other. What happens to the first loop?
It waits for ever — until its task is cancelled:
What it printed
the second loop ended, with [1, 2]
half a second after stop()
the first loop ended, with []The second call overwrote the only reference to the first continuation, so nothing could yield to the first stream or finish it; stop() finished the second. Its loop was stranded, and so would be any screen behind it. Either finish the old continuation before replacing it, keep one per listener, or — as this lesson's PlaceCompleter does — give each listener its own object.
Where this comes from: AsyncStream · AsyncStream.Continuation onTermination — Measured in the iOS 27 simulator.
A loop over a stream ends with break after two values. Does the producer find out — is the stream finished?
Not yet:
What it printed
got 1
got 2
after the loop; a yield now gives enqueued(remaining: 9223372036854775807)
terminated: cancelledAfter the break, the stream still took a value — the code still held the stream, and the stream does not know whether another loop will come. It terminated, as cancelled, only when the program's last reference to it went away. So a producer that must stop when its reader stops should not rely on break: end the task, or let the stream go.
Where this comes from: AsyncStream.Continuation onTermination · SE-0314: AsyncStream and AsyncThrowingStream — Measured in the iOS 27 simulator.
A function wraps Timer.scheduledTimer in an AsyncStream and invalidates the timer in onTermination. In Swift 6 mode, does it compile?
It does not compile. Xcode reports
capture of 'timer' with non-Sendable type 'Timer' in a '@Sendable' closure
No: "capture of 'timer' with non-Sendable type 'Timer' in a '@Sendable' closure"
onTermination is @Sendable — it may run on any thread — and a Timer is not Sendable. Holding the timer in a class that belongs to the main actor makes the holder safe to send, and a Task hops to the main actor to invalidate it:

Stop removed the view, its .task was cancelled, and the stream's end stopped the timer ("the timer stopped"). For a timer alone there is less code to write: Combine's Timer.publish(every:on:in:) has a values async sequence, and SwiftUI's TimelineView redraws on a schedule with no timer at all.
Where this comes from: AsyncStream.Continuation onTermination · SE-0314: AsyncStream and AsyncThrowingStream — Compiled with Xcode 27 in Swift 6 mode, and run in the iOS 27 simulator.
An old API calls its completion handler twice — a known bug. It is wrapped with withCheckedContinuation. What happens?
The program stops:
What the simulator reported
_Concurrency/CheckedContinuation.swift:172: Fatal error: SWIFT TASK CONTINUATION MISUSE: main() tried to resume its continuation more than once, returning second!
Child process terminated with signal 5: Trace/BPT trapA checked continuation checks: a second resume is a crash with a message that names the function, not a silent corruption. withUnsafeContinuation skips the check — and then resuming twice is undefined behavior. Guard a handler that may fire twice with a flag, or fix the API.
Where this comes from: CheckedContinuation · SE-0300: Continuations for interfacing async tasks with synchronous code — Run in the iOS 27 simulator.
Swift 6.4's withContinuation(of:) gives a noncopyable Continuation. The same mistake — two resumes — in its closure?
It does not compile. Xcode reports
'continuation' consumed more than once
It does not compile: "'continuation' consumed more than once"
resume is a consuming method of a noncopyable type: once called, the continuation is gone, and a second use is a compile error rather than a crash. SE-0528: "a noncopyable continuation type that makes double-resume a compile-time error and missing-resume a guaranteed runtime trap". Resumed once, it works — "Seattle: 58°F" — but only where the compiler can see that once, which rules out most completion handlers (a flash card shows the error).
Where this comes from: SE-0528: Continuation — a noncopyable continuation (Swift 6.4) — Compiled and run with Xcode 27 (Swift 6.4).
A withCheckedContinuation closure forgets to resume. What happens to the task that awaits it?
It waits for ever. The program printed:
What it printed
never resumed
a second later, still waiting— and the runtime wrote to the console:
What the runtime reported
SWIFT TASK CONTINUATION MISUSE: main() leaked its continuation without resuming it. This may cause tasks waiting on it to remain suspended forever.A message, not a crash: the awaiting task is stuck, and with it whatever screen was waiting. Every path through the closure — success, failure, an early return — must resume exactly once.
Where this comes from: CheckedContinuation · SE-0300: Continuations for interfacing async tasks with synchronous code — Run in the iOS 27 simulator.
The same mistake with Swift 6.4's Continuation: the closure lets it go without resuming. Compile error, warning, or crash?
A crash, at once:
What the simulator reported
_Concurrency/Continuation.swift:56: Fatal error: Continuation was deinitialized without being resumed.
Child process terminated with signal 5: Trace/BPT trapThe compiler cannot prove that every path resumes, so a dropped Continuation traps when it is destroyed — SE-0528's "missing-resume a guaranteed runtime trap" — instead of leaving a task waiting for ever, as a checked continuation does. (The proposal quotes the message as "The continuation was dropped without resuming."; Xcode 27's runtime words it as above.)
Where this comes from: SE-0528: Continuation — a noncopyable continuation (Swift 6.4) — Run in the iOS 27 simulator (Swift 6.4).
A .task loops over NotificationCenter.default.notifications(named:) and has a print after the loop. When does that line run?

Only when the view goes away. While it was on the screen:
the loop begins
became activeand after the tap on Remove:
after the loopA sequence of notifications never finishes by itself; the loop is the rest of the task's life. So nothing that must happen soon can come after it — put it before, or in another .task. Two loops in one .task would never reach the second.
Where this comes from: NotificationCenter notifications(named:object:) · task(name:priority:file:line:_:) — Measured in the iOS 27 simulator.
AsyncStream(unfolding:) makes values on demand. Its closure counts with a captured var. Swift 6 mode?
It does not compile. Xcode reports
mutation of captured var 'next' in concurrently-executing code
No: "mutation of captured var 'next' in concurrently-executing code"
The closure is @Sendable: the stream may call it from any task, so it may not change a captured local. Keep the state where concurrent access is safe — in an actor, or a Mutex — or produce the values with makeStream and a loop that owns its own counter.
Where this comes from: AsyncStream · SE-0314: AsyncStream and AsyncThrowingStream — Compiled with Xcode 27, in Swift 6 mode.
A while let over your own next() — step 2's kind — runs in a Task, and the task is cancelled after the second value. Does the loop stop?
Not by itself:
What it printed
got 3 — cancelled: false
got 2 — cancelled: false
got 1 — cancelled: true
the loop endedCancelling a task sets a flag; it stops nothing. The loop runs on until next() answers nil, and this next() never looks at the flag — try? even swallows the error that a cancelled Task.sleep throws. A sequence that should end with its task has to say so in next():
got 3 — cancelled: false
got 2 — cancelled: false
the loop endedThe third value never came: the sleep threw, next() answered nil, and the loop ended. That is what AsyncStream does for you — Apple: "Canceling an active iteration invokes the onTermination callback first, then resumes by yielding nil." — and why step 6's stream ends when the editor closes, where step 2's loop would have waited on.
Where this comes from: Task.cancel() · Task.isCancelled · AsyncStream — Run in the iOS 27 simulator.