async/await and structured concurrency · advanced

Combine, and migrating away from it

The reactive framework in every pre-2023 codebase — enough to read it fluently, fix its classic bugs, and migrate it deliberately.

12 min read12 min practice0/2 exercises5 recall cards

By the end you will be able to

  • Read a Combine pipeline — publisher, operators, sink, cancellable — and say what it does
  • Explain the debounce/removeDuplicates search pattern and implement its semantics
  • Map each Combine construct to its async/await replacement for migration
Guess firstAnswering before you read makes the explanation stick — even when you get it wrong.

Combine has received no significant updates since async/await arrived, and new Apple APIs ship async-first. Why does it still deserve a lesson in 2026?

The model: publishers, operators, subscribers

A Combine pipeline is a value assembly line:

import Combine

final class SearchModel: ObservableObject {
    @Published var query = ""                       // publisher of query changes
    @Published private(set) var results: [City] = []

    private var cancellables = Set<AnyCancellable>()

    init(searcher: CitySearching) {
        $query                                      // Publisher<String, Never>
            .debounce(for: .milliseconds(300), scheduler: RunLoop.main)
            .removeDuplicates()
            .sink { [weak self] text in             // subscriber
                self?.performSearch(text)
            }
            .store(in: &cancellables)               // keep the subscription alive
    }
}

Reading order for any pipeline you meet:

  1. The publisher — where values come from. $query is @Published's projected value (property-wrapper machinery you already know: $ = projection, here a publisher instead of a binding).
  2. The operators — transformations: map, filter, debounce, removeDuplicates, combineLatest.
  3. The subscriber — where values land: sink { } runs a closure; assign(to:) writes a property.
  4. The cancellable — the subscription's lifetime. This is where Combine's two classic bugs live.

The two bugs you will actually fix

The dropped cancellable. sink returns an AnyCancellable; when it deallocates, the subscription cancels. Forget .store(in: &cancellables) and the pipeline silently dies at the end of init — the search field that "just doesn't search" with no error anywhere. When you meet mysteriously-dead reactive code, check the cancellable's lifetime first.

The retain cycle. sink's closure is stored (via the cancellable, on self) and captures — this is exactly the stored-closure cycle from the ARC lesson: selfcancellables → closure → self. Hence the reflexive [weak self] in sinks. A Combine-era codebase's leaks live disproportionately in its sinks.

Why debounce earns its keep

The search pipeline above encodes a real product behaviour worth having in any framework: don't hit the API on every keystroke; wait for the user to pause, and skip repeats. That semantics transfers — implement it once and you understand it in Combine, async, or any UI framework:

Debounce + removeDuplicates over a keystroke timeline — which searches actually fire?

Nine keystrokes became three API calls — and note the third: "swift" fired again even though "swift" had fired before, because removeDuplicates only suppresses consecutive duplicates ("swi" fired in between). That subtlety is the operator's actual contract, and misremembering it as "dedupe all history" is a real interview stumble.

The migration map

Combine → async is mechanical once you see the pairs:

CombineModern replacement
ObservableObject + @Published@Observable (Observation lesson)
.sink { } + cancellablesfor await in a .task — lifetime handled by the view
Just(value) / Futurea plain async func returning the value
publisher.valuesthe bridge: any publisher as an AsyncSequence
debounce / removeDuplicates.task(id: query) + Task.sleep (below), or AsyncAlgorithms' debounce
combineLatestasync let for one-shots; AsyncAlgorithms for streams

Two of these deserve the concrete shape. The bridge — when old code must feed new:

for await value in model.$query.values {    // .values: Publisher → AsyncSequence
    await handle(value)
}

Debounced search without Combine — the modern idiom is disarmingly small:

.task(id: query) {                          // restarts (cancelling the old task) on every change
    try? await Task.sleep(for: .milliseconds(300))   // cancelled sleep = keystroke arrived
    guard !Task.isCancelled else { return }
    await search(query)
}

Each keystroke changes id:, cancelling the previous task mid-sleep; only a task whose sleep survives 300ms searches. Debounce falls out of structured cancellation — no operator library required. That comparison is the honest summary of the whole migration: Combine solved values-over-time with an operator algebra; modern Swift solves most of the same cases with language-level tasks and cancellation.

Check yourself

A view model's search silently never fires. The pipeline ends with .sink { … } and no .store(in:). What happened?

Implement debounce semantics

Write the code

Write debounced(events:windowMs:): an event fires only if the gap to the next event is at least the window (the last event always has a full gap).

Solution unlocks after 3 attempts

removeDuplicates, precisely

Write the code

Implement the operator's real contract — consecutive suppression, not global dedupe — and prove the difference.

Solution unlocks after 3 attempts
Explain itSaved on this device. Never graded.

A 2021-era codebase you have joined uses Combine everywhere; a teammate proposes a two-sprint full rewrite to async. Write the reply you would post: what migrates first and why, what the .values bridge buys, which pipelines you would deliberately leave, and the risk the rewrite plan underweights.

Checkpoint

You can now:

  • Read any Combine pipeline in the four-part order and predict its behaviour
  • Diagnose the dropped cancellable and the sink cycle on sight
  • Implement debounce and removeDuplicates semantics from their contracts
  • Migrate deliberately: pairs table, .values bridge, leaves first

Next up: the operators Combine veterans miss most, reborn for async sequences — AsyncAlgorithms.

How well do you know this now? Rating yourself honestly, then being tested on it, is how you find out where your intuition is wrong.