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.
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
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:
- The publisher — where values come from.
$queryis@Published's projected value (property-wrapper machinery you already know:$= projection, here a publisher instead of a binding). - The operators — transformations:
map,filter,debounce,removeDuplicates,combineLatest. - The subscriber — where values land:
sink { }runs a closure;assign(to:)writes a property. - 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: self → cancellables → 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:
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:
| Combine | Modern replacement |
|---|---|
ObservableObject + @Published | @Observable (Observation lesson) |
.sink { } + cancellables | for await in a .task — lifetime handled by the view |
Just(value) / Future | a plain async func returning the value |
publisher.values | the bridge: any publisher as an AsyncSequence |
debounce / removeDuplicates | .task(id: query) + Task.sleep (below), or AsyncAlgorithms' debounce |
combineLatest | async 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.
A view model's search silently never fires. The pipeline ends with .sink { … } and no .store(in:). What happened?
Implement debounce semantics
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).
removeDuplicates, precisely
Implement the operator's real contract — consecutive suppression, not global dedupe — and prove the difference.
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.
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,
.valuesbridge, leaves first
Next up: the operators Combine veterans miss most, reborn for async sequences — AsyncAlgorithms.