← All lessonsLesson 40 of 57 · Part 11: Swift concurrency
Lesson 40

City suggestions as you type

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.

Step 1

Open your project

  1. Open your Trips project as lesson 38 left it: double-click Trips.xcodeproj in Finder, or use File ▸ Open… (⌘O) in Xcode.

  2. 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.

  3. Run it (⌘R) to check that it builds. You should see where lesson 38 ended:

    Trips, as it stood at the end of lesson 38 — screenshot
    Trips, as it stood at the end of lesson 38Screenshot · iPhone 17, iOS 27.0
Step 2

A delegate, asked again and again: an async method and a while let

MapKit 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:

New filePlaceCompleter.swift — the whole file
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:

In TripEditor.swift, replace the TripEditor struct with this
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:

“Portl”, and the places MapKit suggests — screenshot
“Portl”, and the places MapKit suggestsScreenshot · iPhone 17, iOS 27.0

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

City and Country filled in from the suggestion — screenshot
City and Country filled in from the suggestionScreenshot · iPhone 17, iOS 27.0

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:

Portland, with Portland's weather — screenshot
Portland, with Portland's weatherScreenshot · iPhone 17, iOS 27.0

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.

Step 3

Every await is a new function

Lesson 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.

Countdown.swift, outside Trips — no protocol
/// 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
    }
}
main.swift, outside Trips — the loop
var iterator = Countdown(left: 3)
while let number = await iterator.next() {
    print(number)
}
print("Liftoff")

What it printed

Console
3
2
1
Liftoff

Here 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:

main.swift, outside Trips — the loop, written out by hand, for comparison only
/// 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

Console
3
2
1
Liftoff

start 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:

main.swift, outside Trips — the functions the compiler made of main
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.

Step 4

for await: the loop the compiler writes for you

for 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:

In PlaceCompleter.swift, replace the PlaceCompleter class with this
/// 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:

In TripEditor.swift, replace the TripEditor struct with this
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:

The same suggestions, from a for await — screenshot
The same suggestions, from a for awaitScreenshot · iPhone 17, iOS 27.0

Nothing 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:

main.swift, outside Trips — for await, rewritten, for comparison only
// `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

Console
3
2
1
Liftoff

Three 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:

From the Swift compiler's source, lib/Sema/TypeCheckStmt.cpp (shortened) — C++, not code to type
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:

main.swift, outside Trips — the compiler's SIL for the for await countdown, the 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 bb5

makeAsyncIterator() 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.

Where this is

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:)

Step 5

AsyncStream: the same, ready-made

Step 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:

main.swift, outside Trips — the countdown as an AsyncStream
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

Console
3
2
1
Liftoff

What 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:

In PlaceCompleter.swift, replace the PlaceCompleter class with this
/// 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:

In TripEditor.swift, replace the TripEditor struct with this
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:

The same suggestions, from an AsyncStream — screenshot
The same suggestions, from an AsyncStreamScreenshot · iPhone 17, iOS 27.0

Three versions, one screen. The one to keep is this one: it is less code, and it ends — which is the next step.

Step 6

When a stream ends

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:

In PlaceCompleter.swift, replace the PlaceCompleter class with this
/// 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:

Cancel: Seattle as it was, and the stream at its end — screenshot
Cancel: Seattle as it was, and the stream at its endScreenshot · iPhone 17, iOS 27.0

And the console, from the moment of the tap:

Console
Suggestions ended: cancelled

The 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.

Step 7

One resume, and only one: a continuation that cannot be copied

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:

_Concurrency.swiftinterface — from the iOS 27 SDK (shortened), not code to type
@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:

main.swift, outside Trips — a continuation that nobody resumes
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.

Ownership, all the way in

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.

What you built

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.

The finished project

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.

Flash cards

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.

Syntax · AsyncStreamMake an async sequence that your own code feeds, and loop over it.
let (stream, continuation) = AsyncStream.makeStream(of: String.self)
Task {
    for city in ["Seattle", "Vancouver", "Bergen"] {
        continuation.yield(city)
    }
    continuation.finish()
}
for await city in stream {
    print(city)
}
print("the loop ended")

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".

Syntax · AsyncStreamStop the work that feeds a stream when nobody is listening any more.
let (stream, continuation) = AsyncStream.makeStream(of: Int.self)
continuation.onTermination = { reason in
    print("terminated:", reason)
}
let listener = Task { for await _ in stream {} }
try await Task.sleep(for: .milliseconds(100))
listener.cancel()
await listener.value

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.

Syntax · AsyncThrowingStreamEnd a stream with an error.
struct Lost: Error {}
let (stream, continuation) = AsyncThrowingStream.makeStream(of: String.self)
continuation.yield("Seattle")
continuation.finish(throwing: Lost())
do {
    for try await city in stream { print("got", city) }
} catch {
    print("the loop threw", error)
}

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()".

Syntax · ContinuationsTurn a function with a completion handler into an async one.
/// An old API: it answers once, through a closure.
func fetchCity(_ done: @escaping @Sendable (String) -> Void) {
    done("Seattle")
}

func city() async -> String {
    await withCheckedContinuation { continuation in
        fetchCity { answer in
            continuation.resume(returning: answer)
        }
    }
}

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.

Syntax · AsyncStreamKeep only the newest value when the consumer is slower than the producer.
let (stream, continuation) = AsyncStream.makeStream(of: Int.self, bufferingPolicy: .bufferingNewest(1))
for n in 1...5 { continuation.yield(n) }
continuation.finish()
var got: [Int] = []
for await n in stream { got.append(n) }
print(got)

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].

Syntax · AsyncSequenceFilter, transform and limit the values of an async sequence, as you would an array's.
let (stream, continuation) = AsyncStream.makeStream(of: Int.self)
for n in 1...10 { continuation.yield(n) }
continuation.finish()
for await n in stream.filter({ $0.isMultiple(of: 3) }).map({ $0 * 10 }).prefix(2) {
    print(n)
}

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.

Syntax · AsyncSequenceRead a file a line at a time, without loading it all first.
let file = URL.temporaryDirectory.appending(path: "cities.txt")
try "Seattle\nVancouver\nBergen\n".write(to: file, atomically: true, encoding: .utf8)
for try await line in file.lines {
    print("line:", line)
}

url.lines — "The URL’s resource data, as an asynchronous sequence of lines of text." It printed line: Seattle first.

Syntax · AsyncSequenceReact to a notification for as long as a view is on the screen.
.task {
    for await _ in NotificationCenter.default.notifications(named: UIApplication.didBecomeActiveNotification) {
        comebacks += 1
        print("became active:", comebacks)
    }
}
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

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.

Syntax · AsyncStreamFeed a stream from a callback that arrives on another thread.
let (stream, continuation) = AsyncStream.makeStream(of: Int.self)
DispatchQueue.global().async {
    for n in 1...3 { continuation.yield(n) }
    continuation.finish()
}
var got: [Int] = []
for await n in stream { got.append(n) }
print("from another thread:", got)

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].

Syntax · OwnershipDeclare a type that cannot be copied, with a method that uses it up — and a deinit that runs only if nothing ever does.
struct Handle: ~Copyable {
    let fd: Int

    deinit {
        print("deinit: closing \(fd) for you")
    }

    consuming func close() {
        print("close(): closing \(fd)")
        discard self
    }
}

~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.

Syntax · AsyncSequenceLoop over values that an async method of your own hands out one at a time — with no protocol in sight.
var iterator = Countdown(left: 3)
while let number = await iterator.next() {
    print(number)
}
print("Liftoff")

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).

Syntax · AsyncSequenceMake a type of your own usable with for await.
for await number in Countdown(left: 3) {
    print(number)
}
print("Liftoff")

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.

Trick question · AsyncStreamValues are yielded before anyone has started a 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.

Trick question · AsyncStreamA 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.

Trick question · ContinuationsCan Swift 6.4's new noncopyable 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.

Trick question · AsyncStreamWhat does 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.

Trick question · AsyncStreamA stream keeps only its oldest value: .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.

Trick question · AsyncStreamWith the default buffering policy, how much room does a stream say it has left?

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.

Trick question · AsyncStreamWhen does 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.

Trick question · OwnershipA Swift 6.4 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.

Trick question · OwnershipCan a struct have a deinit?
struct Handle {
    let fd: Int

    deinit {
        print("closing \(fd)")
    }
}

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.

Trick question · AsyncSequenceThe countdown struct has an 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.

Trick question · AsyncSequenceA @MainActor class conforms to AsyncSequence and AsyncIteratorProtocol, so that a .task can loop over it. In Swift 6 mode, does the conformance compile?
@MainActor
final class Suggestions: AsyncSequence, AsyncIteratorProtocol {
    private var left = ["Portland", "Portsmouth"]

    func makeAsyncIterator() -> Suggestions {
        self
    }

    func next() async -> String? {
        left.isEmpty ? nil : left.removeFirst()
    }
}

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.

Trick question · AsyncSequenceA loop on the main actor reads a sequence whose 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".

Edge cases and tricky questions

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.

Lesson 40 · question 1 of 11 · two loops, one stream

Two tasks loop over the same AsyncStream, and ten values are yielded. Does each loop get all ten?

let (stream, continuation) = AsyncStream.makeStream(of: Int.self)
let a = Task {
    var got: [Int] = []
    for await n in stream { got.append(n) }
    return got
}
let b = Task {
    var got: [Int] = []
    for await n in stream { got.append(n) }
    return got
}
try await Task.sleep(for: .milliseconds(100))
for n in 1...10 { continuation.yield(n) }
continuation.finish()
let first = await a.value, second = await b.value
print("first loop:", first.count, "values; second loop:", second.count, "values; every value once:", (first + second).sorted() == Array(1...10))
Show the answer

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.

Lesson 40 · question 2 of 11 · a continuation replaced

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?

@MainActor
final class Source {
    private var continuation: AsyncStream<Int>.Continuation?

    func values() -> AsyncStream<Int> {
        let (stream, continuation) = AsyncStream.makeStream(of: Int.self)
        self.continuation = continuation
        return stream
    }

    func send(_ n: Int) {
        continuation?.yield(n)
    }
    func stop() {
        continuation?.finish()
    }
}

let source = Source()
let first = Task {
    var got: [Int] = []
    for await n in source.values() { got.append(n) }
    print("the first loop ended, with", got)
}
try await Task.sleep(for: .milliseconds(100))
let second = Task {
    var got: [Int] = []
    for await n in source.values() { got.append(n) }
    print("the second loop ended, with", got)
}
try await Task.sleep(for: .milliseconds(100))
source.send(1)
source.send(2)
source.stop()
await second.value
try await Task.sleep(for: .milliseconds(500))
print("half a second after stop()")
first.cancel()
await first.value
Show the answer

It waits for ever — until its task is cancelled:

What it printed

Console
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.

Lesson 40 · question 3 of 11 · break out of the loop

A loop over a stream ends with break after two values. Does the producer find out — is the stream finished?

let (stream, continuation) = AsyncStream.makeStream(of: Int.self)
continuation.onTermination = { reason in print("terminated:", reason) }
for n in 1...5 { continuation.yield(n) }
for await n in stream {
    print("got", n)
    if n == 2 { break }
}
print("after the loop; a yield now gives", continuation.yield(6))
Show the answer

Not yet:

What it printed

Console
got 1
got 2
after the loop; a yield now gives enqueued(remaining: 9223372036854775807)
terminated: cancelled

After 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.

Lesson 40 · question 4 of 11 · a Timer as a stream, in Swift 6

A function wraps Timer.scheduledTimer in an AsyncStream and invalidates the timer in onTermination. In Swift 6 mode, does it compile?

import SwiftUI

/// A tick every second, from a Timer, as an async sequence.
func everySecond() -> AsyncStream<Date> {
    AsyncStream { continuation in
        let timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
            continuation.yield(.now)
        }
        continuation.onTermination = { _ in
            timer.invalidate()
        }
    }
}

struct Ticker: View {
    @State private var ticks = 0

    var body: some View {
        Text("Ticks: \(ticks)")
            .task {
                for await _ in everySecond() {
                    ticks += 1
                }
            }
    }
}

struct ContentView: View {
    @State private var showing = true

    var body: some View {
        VStack(spacing: 20) {
            if showing { Ticker() }
            Button("Stop") { showing = false }
        }
    }
}
Show the answer

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:

import SwiftUI

/// Holds a timer. `@MainActor`, so it may be sent anywhere: only the main actor touches the timer.
@MainActor
final class TimerHolder {
    let timer: Timer
    init(_ timer: Timer) {
        self.timer = timer
    }
}

/// A tick every second, from a Timer, as an async sequence. `@MainActor`: a timer runs on the run loop of the
/// thread that schedules it — the main one.
@MainActor
func everySecond() -> AsyncStream<Date> {
    AsyncStream { continuation in
        let holder = TimerHolder(Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
            continuation.yield(.now)
        })
        continuation.onTermination = { _ in
            Task { @MainActor in
                holder.timer.invalidate()
                print("the timer stopped")
            }
        }
    }
}
Screenshot of this code running
Ticking, until StopScreenshot · iPhone 17, iOS 27.0

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.

Lesson 40 · question 5 of 11 · resumed twice

An old API calls its completion handler twice — a known bug. It is wrapped with withCheckedContinuation. What happens?

func oldAPI(_ done: @escaping @Sendable (String) -> Void) {
    done("first")
    done("second")
}

let answer = await withCheckedContinuation { (continuation: CheckedContinuation<String, Never>) in
    oldAPI { value in continuation.resume(returning: value) }
}
print("got", answer)
Show the answer

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 trap

A 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.

Lesson 40 · question 6 of 11 · resumed twice, caught at compile time

Swift 6.4's withContinuation(of:) gives a noncopyable Continuation. The same mistake — two resumes — in its closure?

let answer = await withContinuation(of: String.self) { continuation in
    continuation.resume(returning: "first")
    continuation.resume(returning: "second")
}
print("got", answer)
Show the answer

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).

Lesson 40 · question 7 of 11 · never resumed

A withCheckedContinuation closure forgets to resume. What happens to the task that awaits it?

let task = Task {
    await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
        print("never resumed")
    }
    print("never reached")
}
try await Task.sleep(for: .seconds(1))
print("a second later, still waiting")
_ = task
Show the answer

It waits for ever. The program printed:

What it printed

Console
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.

Lesson 40 · question 8 of 11 · a new continuation dropped

The same mistake with Swift 6.4's Continuation: the closure lets it go without resuming. Compile error, warning, or crash?

let answer = await withContinuation(of: String.self) { continuation in
    print("forgot to resume")
    _ = consume continuation
}
print("got", answer)
Show the answer

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 trap

The 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).

Lesson 40 · question 9 of 11 · the line after an endless loop

A .task loops over NotificationCenter.default.notifications(named:) and has a print after the loop. When does that line run?

import SwiftUI

struct Listener: View {
    var body: some View {
        Text("Listening")
            .task {
                print("the loop begins")
                for await _ in NotificationCenter.default.notifications(named: UIApplication.didBecomeActiveNotification) {
                    print("became active")
                }
                print("after the loop")
            }
    }
}

struct ContentView: View {
    @State private var showing = true

    var body: some View {
        VStack(spacing: 20) {
            if showing { Listener() }
            Button("Remove") { showing = false }
        }
    }
}
Show the answer
Screenshot of this code running
After RemoveScreenshot · iPhone 17, iOS 27.0

Only when the view goes away. While it was on the screen:

Console
the loop begins
became active

and after the tap on Remove:

Console
after the loop

A 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.

Lesson 40 · question 10 of 11 · a mutable counter in a stream's closure

AsyncStream(unfolding:) makes values on demand. Its closure counts with a captured var. Swift 6 mode?

var next = 1
let stream = AsyncStream<Int> {
    defer { next += 1 }
    return next <= 3 ? next : nil
}
for await n in stream { print("got", n) }
Show the answer

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.

Lesson 40 · question 11 of 11 · a while let in a cancelled task

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?

struct Countdown {
    var left: Int

    /// Two tenths of a second for each number. A cancelled `Task.sleep` throws; `try?` ignores that.
    mutating func next() async -> Int? {
        try? await Task.sleep(for: .milliseconds(200))
        guard left > 0 else {
            return nil
        }
        left -= 1
        return left + 1
    }
}

let loop = Task {
    var iterator = Countdown(left: 3)
    while let number = await iterator.next() {
        print("got \(number) — cancelled: \(Task.isCancelled)")
    }
    print("the loop ended")
}
try await Task.sleep(for: .milliseconds(500))
loop.cancel()
await loop.value
Show the answer

Not by itself:

What it printed

Console
got 3 — cancelled: false
got 2 — cancelled: false
got 1 — cancelled: true
the loop ended

Cancelling 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():

struct Countdown {
    var left: Int

    /// Two tenths of a second for each number — and `nil`, the end, if the task was cancelled while it waited.
    mutating func next() async -> Int? {
        do {
            try await Task.sleep(for: .milliseconds(200))
        } catch {
            return nil
        }
        guard left > 0 else {
            return nil
        }
        left -= 1
        return left + 1
    }
}

let loop = Task {
    var iterator = Countdown(left: 3)
    while let number = await iterator.next() {
        print("got \(number) — cancelled: \(Task.isCancelled)")
    }
    print("the loop ended")
}
try await Task.sleep(for: .milliseconds(500))
loop.cancel()
await loop.value
Console
got 3 — cancelled: false
got 2 — cancelled: false
the loop ended

The 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.

Further reading