Combine vs async/await: Which to Use in 2026
Combine and Swift concurrency both model work that finishes later, and both can model streams of values. For new code they are no longer equal choices, but they are not interchangeable either: each has semantics the other lacks. This guide gives you a default, the criteria that override it, and a plan for running both in one codebase.
Quick answer: For new code, default to Swift concurrency: async/await for single results, AsyncSequence and AsyncStream for streams, tasks for cancellation, and the Observation framework instead of @Published for UI state. Keep Combine where its operator library is the point, such as debounce, combineLatest, and switchToLatest pipelines in code that already depends on it, and bridge between the two with .values. Combine is not deprecated, but it is no longer where Apple adds new API.
Difficulty: Intermediate
You'll understand this better if you know: Combine basics (publishers, operators, AnyCancellable), async/await, and structured concurrency.
1. What You Are Actually Choosing
Combine (iOS 13 and macOS 10.15) is a framework. Publishers push values through chains of operators to subscribers, subscribers signal how many values they want, and schedulers decide which thread runs each step. It is available only on Apple platforms.
Swift concurrency is part of the language: async functions, tasks, actors, and AsyncSequence, introduced in Swift 5.5 (SE-0296). Consumers pull values with for await, cancellation is cooperative, and in the Swift 6 language mode the compiler checks for data races. It runs on Linux and Windows as well as Apple platforms, and it can back-deploy to iOS 13 and macOS 10.15.
For a single result there is little contest. The real decision is about streams: values over time, and how they are combined, timed, buffered, and cancelled.
Note: The Combine interface in the macOS 27 SDK has no deprecation annotations, and its newest public APIs are the values async bridges added in iOS 15 and macOS 12. Newer Apple APIs, such as the Observations sequence in iOS 26, are built on Swift concurrency instead.
2. Decision Criteria at a Glance
| Criterion | Combine | Swift concurrency |
|---|---|---|
| Single async result | A one-value publisher; subscribe and store an AnyCancellable | An async function; try await it |
| Streams | Publishers and subjects | AsyncSequence, AsyncStream, Observations |
| Operators | Large built-in set: debounce, throttle, combineLatest, merge, switchToLatest, share, retry | Sequence-style operators in the standard library; time and multi-input operators from the swift-async-algorithms package |
| Cancellation | Immediate; propagates upstream when the AnyCancellable is cancelled or released | Cooperative; children cancel with their parent, and code observes it at suspension points or by checking |
| Default lifetime | Subscription ends when its AnyCancellable is released | A Task keeps running even if you discard its handle |
| Backpressure | Demand-based; sink requests unlimited values | Pull-based iteration; AsyncStream buffers according to its policy (unbounded by default) |
| Threading and safety | Schedulers (receive(on:)); not checked by the compiler | Actors and @MainActor; data races checked at compile time in Swift 6 mode |
| Errors | Typed Failure; the stream ends at the first error | throws (typed throws since Swift 6); do/catch |
| Multicasting | Subjects, share(), multicast | No built-in broadcast; AsyncStream splits elements between consumers |
| Testing | Subscribe, collect, and wait | Plain async test functions |
| Platforms | Apple only | Apple platforms, Linux, and Windows |
| SwiftUI integration | onReceive, ObservableObject, @Published | .task, .task(id:), @Observable |
3. The Trade-offs in Detail
Single results: async/await wins
Here is the same request written both ways, including the call sites:
import Combine
import Foundation
struct User: Decodable, Sendable {
let id: Int
let name: String
}
let baseURL = URL(string: "https://api.example.com")!
// Combine: a publisher that emits one value (or an error) and completes
func userPublisher(id: Int) -> AnyPublisher<User, Error> {
URLSession.shared
.dataTaskPublisher(for: baseURL.appending(path: "users/\(id)"))
.map(\.data)
.decode(type: User.self, decoder: JSONDecoder())
.eraseToAnyPublisher()
}
// async/await: the same work as a function that returns a value or throws
func fetchUser(id: Int) async throws -> User {
let (data, _) = try await URLSession.shared.data(from: baseURL.appending(path: "users/\(id)"))
return try JSONDecoder().decode(User.self, from: data)
}
// Call sites
var cancellables = Set<AnyCancellable>()
userPublisher(id: 1)
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { completion in
if case .failure(let error) = completion { print("failed:", error) }
},
receiveValue: { user in print(user.name) }
)
.store(in: &cancellables) // drop the AnyCancellable and the request is cancelled
Task {
do {
let user = try await fetchUser(id: 1)
print(user.name)
} catch {
print("failed:", error)
}
}The async version reads top to bottom, uses ordinary do/catch, has no subscription to store, and is cancelled automatically when the calling task is. The Combine version gives you a lazy, composable value (nothing happens until someone subscribes, and retry(_:) re-subscribes), but for one value that flexibility is mostly ceremony.
Cancellation and lifetime
The two models have opposite defaults, and mixing them up causes real bugs:
import Combine
let events = PassthroughSubject<Int, Never>()
var subscriptions = Set<AnyCancellable>()
events
.sink { print("stored subscription got", $0) }
.store(in: &subscriptions)
_ = events.sink { print("discarded subscription got", $0) } // cancelled immediately
_ = Task { // discarding the handle does NOT cancel the task
print("discarded task still ran")
}
events.send(1)
try await Task.sleep(for: .milliseconds(50))- Combine cancels as soon as the AnyCancellable is cancelled or deallocated, and the cancellation travels upstream through every operator to the source. Forgetting to store the cancellable is a silent "nothing happens" bug.
- Swift concurrency cancels only when asked. Task.cancel() sets a flag; APIs such as Task.sleep and URLSession's async methods throw when they see it, and CPU-bound loops must check Task.isCancelled or call try Task.checkCancellation(). Forgetting to cancel a stored task is a "work keeps running" bug.
- Structure helps: child tasks from async let and task groups are cancelled with their parent, and SwiftUI's .task modifier cancels its task when the view disappears (.task(id:) also restarts it when the id changes). Prefer these over storing unstructured Task handles.
- AsyncStream detail: cancelling the consuming task ends the for await loop and runs onTermination, but an element that is already buffered can still be delivered first. Check Task.isCancelled inside the loop if processing must stop immediately.
Backpressure and buffering
Combine is demand-driven: each subscriber tells its upstream how many values it can take. Because sink and assign request an unlimited number, most app code never notices. PassthroughSubject drops values when it has no subscribers or its current demand is zero, and operators such as buffer(size:prefetch:whenFull:) and flatMap(maxPublishers:) give you explicit control.
An AsyncSequence is pull-based: the consumer asks for the next element when it is ready. Sources that push, such as callbacks and delegates, need an AsyncStream, and its yield never suspends, so elements wait in a buffer governed by the buffering policy: .unbounded by default, or .bufferingNewest(n) and .bufferingOldest(n). If the producer itself must wait for the consumer, use AsyncChannel from swift-async-algorithms, whose send suspends until the value is consumed.
The bridge between the two worlds has a trap. .values requests one element at a time, so a fast subject feeding a slow loop loses values unless you add a buffer:
import Combine
@MainActor // Combine subjects are not Sendable; keep producer and consumer on one actor
func deliver(buffered: Bool) async -> [Int] {
let events = PassthroughSubject<Int, Never>()
let upstream = buffered
? events.buffer(size: 100, prefetch: .byRequest, whenFull: .dropOldest).eraseToAnyPublisher()
: events.eraseToAnyPublisher()
let consumer = Task {
var received: [Int] = []
for await value in upstream.values { // requests one value at a time
received.append(value)
try? await Task.sleep(for: .milliseconds(50)) // slow consumer
}
return received
}
try? await Task.sleep(for: .milliseconds(50)) // let the consumer subscribe
for i in 1...10 { // fast producer
events.send(i)
try? await Task.sleep(for: .milliseconds(10))
}
events.send(completion: .finished)
return await consumer.value
}
print(await deliver(buffered: false)) // for example [1, 5, 10]: values sent while the consumer was busy are lost
print(await deliver(buffered: true)) // [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]The exact unbuffered output varies from run to run; the point is that values disappear without any error. Always decide what should happen to values that arrive while the consumer is busy.
Operators and time
Combine's built-in operators are its strongest argument: debounce, throttle, combineLatest, merge, zip, removeDuplicates, switchToLatest, scan, share, and retry all ship with the OS.
The standard library gives AsyncSequence the sequence-style operators (map, filter, compactMap, flatMap, prefix, dropFirst, reduce, and so on) but nothing time-based or multi-input. Apple's open-source swift-async-algorithms package fills most of that gap with debounce, throttle, combineLatest, merge, zip, removeDuplicates, chunking, timers, and AsyncChannel, at the cost of a package dependency. There is no direct switchToLatest; in Swift concurrency you get that behavior by cancelling the previous task, as Scenario 2 shows.
Threading and data-race safety
Combine predates Sendable. Subjects, AnyPublisher, and Future's promise are not Sendable, so in the Swift 6 language mode you keep Combine objects inside one isolation domain, usually the main actor; that is why the examples in this article mark Combine-touching functions @MainActor. Within a pipeline, schedulers pick threads at runtime and the compiler cannot check them.
Swift concurrency moves those rules into the type system: actor isolation, @MainActor, and Sendable are verified at compile time. For teams adopting the Swift 6 language mode, this is the strongest reason to write new asynchronous code with async/await. The Concurrency chapter of The Swift Programming Language covers the model in depth.
Testing
An async function is tested like a synchronous one: mark the test async, await the call, and assert with #expect (Swift Testing) or XCTAssert. A Combine pipeline has to be subscribed to, its values collected, and the test made to wait, typically with XCTestExpectation or by iterating .values. Time-based operators are harder still, because Combine ships no virtual-time test scheduler. On the async side, accepting a Clock parameter (the async-algorithms operators do) lets tests control time.
Platforms and API surface
Combine exists only on Apple platforms, so a package that must build on Linux, Windows, or a server cannot use it. For public APIs, async functions and AsyncSequence are the forward-looking surface; if existing clients need publishers, a thin wrapper is cheap. SwiftUI still accepts publishers through onReceive, and ObservableObject and @Published live in Combine, but @Observable (iOS 17 and macOS 14 or later) does not use Combine at all.
4. Realistic Scenarios
Scenario 1: Loading data for a screen → async/await
A detail screen fetches a record and its related items when it appears. Write the networking as async functions, call them from SwiftUI's .task modifier (or a stored, cancellable Task in UIKit), and load independent pieces in parallel with async let.
Why: there is no stream, so Combine adds only ceremony. The task's lifetime is tied to the view, cancellation is automatic, and errors flow through do/catch.
Scenario 2: Search as you type → .task(id:) in SwiftUI, either model in UIKit
Each keystroke should restart a short delay, and a new query must cancel the search in flight. In Combine that is debounce plus switchToLatest. In Swift concurrency it is a sleep plus cancelling the previous task. Both versions below print only the results for the final query:
import Combine
import Foundation
// A fake search API with latency
func search(_ query: String) async -> [String] {
try? await Task.sleep(for: .milliseconds(100))
return ["\(query) result 1", "\(query) result 2"]
}
func searchPublisher(_ query: String) -> AnyPublisher<[String], Never> {
Just(query)
.delay(for: .milliseconds(100), scheduler: DispatchQueue.main)
.map { ["\($0) result 1", "\($0) result 2"] }
.eraseToAnyPublisher()
}
// Combine: the pipeline is the feature
@MainActor
final class CombineSearchModel {
let query = PassthroughSubject<String, Never>()
private(set) var results: [String] = []
private var cancellables = Set<AnyCancellable>()
init() {
query
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
.removeDuplicates()
.map(searchPublisher)
.switchToLatest() // cancels the previous in-flight search
.sink { [weak self] in
self?.results = $0
print("Combine:", $0)
}
.store(in: &cancellables)
}
}
// async/await: debounce = sleep, switchToLatest = cancel the previous task
@MainActor
final class AsyncSearchModel {
private(set) var results: [String] = []
private var searchTask: Task<Void, Never>?
func queryChanged(_ query: String) {
searchTask?.cancel()
searchTask = Task {
do {
try await Task.sleep(for: .milliseconds(300)) // throws if cancelled
} catch {
return
}
let found = await search(query)
guard !Task.isCancelled else { return }
results = found
print("async:", found)
}
}
}
let combineModel = CombineSearchModel()
let asyncModel = AsyncSearchModel()
for text in ["s", "sw", "swi", "swift"] { // simulated typing, 50 ms apart
combineModel.query.send(text)
asyncModel.queryChanged(text)
try await Task.sleep(for: .milliseconds(50))
}
try await Task.sleep(for: .seconds(1))In SwiftUI, .task(id:) gives you the cancel-and-restart behavior with no stored state at all:
import SwiftUI
func search(_ query: String) async -> [String] {
try? await Task.sleep(for: .milliseconds(100))
return ["\(query) result 1", "\(query) result 2"]
}
struct SearchView: View {
@State private var query = ""
@State private var results: [String] = []
var body: some View {
List(results, id: \.self) { Text($0) }
.searchable(text: $query)
.task(id: query) { // cancels the previous task whenever query changes
do {
try await Task.sleep(for: .milliseconds(300))
} catch {
return // cancelled: the user kept typing
}
results = await search(query)
}
}
}Why: in SwiftUI the task-based version is shorter and has no subscriptions to manage. In UIKit both are reasonable. If the screen already has Combine pipelines, adding debounce is the smallest change; if not, the task-based version avoids a new paradigm for one feature.
Scenario 3: Wrapping a delegate or callback stream → AsyncStream
A sensor, location manager, or socket reports values through callbacks. Wrap it in an AsyncStream and make the two decisions Combine would have hidden from you: what to buffer, and how to stop the source.
// Wrap a push-based source in AsyncStream: buffering and teardown are explicit
func temperatureReadings() -> AsyncStream<Double> {
AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in // keep only the latest value
let sensor = Task { // stands in for a delegate or callback API
var celsius = 20.0
while !Task.isCancelled {
celsius += 0.5
continuation.yield(celsius) // never suspends; the buffering policy applies
try? await Task.sleep(for: .milliseconds(10))
}
}
continuation.onTermination = { reason in // runs on finish(), when iteration stops, or on cancellation
sensor.cancel()
print("sensor stopped:", reason)
}
}
}
func showReadings() async {
for await reading in temperatureReadings().prefix(4) {
print("reading:", reading)
try? await Task.sleep(for: .milliseconds(35)) // slower than the sensor: stale values are skipped
}
}
await showReadings()
print("done")reading: 20.5 reading: 21.5 reading: 23.0 reading: 24.5 sensor stopped: cancelled done
Why: consumers get a plain for await loop, .bufferingNewest(1) keeps a slow consumer on the latest reading instead of a growing backlog, and onTermination stops the hardware when nobody is listening. The readings printed vary between runs because intermediate values are skipped.
Scenario 4: An established Combine codebase → keep it, bridge it, migrate gradually
An app built on ObservableObject view models and dozens of working pipelines does not need a rewrite. Write new features with async/await, bridge at the boundaries, and move view models to @Observable when your deployment target allows it. For code that used $property.sink to react to model changes, the Observations sequence (SE-0475, Swift 6.2) is the async replacement on iOS 26 and macOS 26 or later:
import Observation
@MainActor
@Observable
final class Player {
var score = 0
}
let player = Player()
let scoreboard = Task {
for await score in Observations({ player.score }) { // iOS 26, macOS 26 and later
print("score:", score)
if score >= 3 { break }
}
}
for _ in 1...3 {
try await Task.sleep(for: .milliseconds(20))
player.score += 1
}
await scoreboard.valueWhy: rewriting working pipelines costs time and adds risk without changing behavior. Note that Observations is transactional: it delivers consistent snapshots and can skip intermediate values, so it is not a log of every change.
5. Decision Checklist
Go through the questions in order and stop at the first "yes":
- Does the work produce a single result? Use an async function.
- Must the code build outside Apple platforms? Use Swift concurrency; Combine is not available.
- Is the work tied to a SwiftUI view's lifetime or to an input value? Use .task or .task(id:).
- Are you reacting to changes in an @Observable model outside SwiftUI? Use Observations on iOS 26 and macOS 26 or later, or withObservationTracking on earlier versions.
- Do you need time-based or multi-input operators such as debounce, throttle, or combineLatest? Use Combine if the codebase already depends on it or you cannot add packages; otherwise use swift-async-algorithms.
- Must one source broadcast to many subscribers? Use a Combine subject or share(), or keep the state in one owner (an actor or an @Observable model) that consumers read.
- Does an API you consume hand you a publisher? Use Combine operators on it, or bridge it with .values and decide on buffering.
- None of the above? Use async/await and AsyncSequence.
6. Wrong Reasons to Pick One
- "Combine is deprecated." It is not. It is stable, supported, and still a sound choice inside code that already uses it.
- "async/await cannot do streams." AsyncSequence and AsyncStream model streams; what the standard library lacks is time-based operators.
- "We must rewrite everything to async/await now." Bridging in both directions works. Migrate code when you touch it for other reasons.
- ".values is a drop-in replacement for sink." It changes demand from unlimited to one at a time, which can drop values, as shown in section 3.
- "A Task handle works like an AnyCancellable." The defaults are opposite: releasing an AnyCancellable cancels, while releasing a Task handle does not.
- "Combine moves work to a background thread." Values are delivered on whatever thread the upstream emits on, unless you use subscribe(on:) or receive(on:).
- "SwiftUI needs Combine." Since iOS 17, @Observable models drive SwiftUI without it.
- "One of them is faster." For app-level work, the cost of either is small next to the work being done. Choose on semantics and safety, and profile if a hot path says otherwise.
7. When to Mix Both
Most production apps in 2026 contain both, and that is fine if the boundary is deliberate. Keep the core of new features in Swift concurrency and treat Combine as an adapter layer for APIs that produce publishers and for existing pipelines.
The two bridges look like this:
import Combine
// Combine -> async: consume any publisher with for-await through .values
let progress = CurrentValueSubject<Double, Never>(0)
let watcher = Task {
for await value in progress.values {
print("progress:", value)
if value >= 1 { break }
}
}
for step in [0.25, 0.5, 1.0] {
try await Task.sleep(for: .milliseconds(20))
progress.send(step)
}
await watcher.value
// async -> Combine: expose an async function to publisher-based code
func loadGreeting() async -> String { "hello" }
@MainActor // keeps the non-Sendable promise on one actor under Swift 6 checking
func greetingPublisher() -> AnyPublisher<String, Never> {
Future { promise in // Future runs this closure immediately, not on subscription
Task {
promise(.success(await loadGreeting()))
}
}
.eraseToAnyPublisher()
}
for await greeting in greetingPublisher().values {
print(greeting)
}- Combine to async: values (iOS 15 and macOS 12 or later) turns any publisher into an AsyncSequence; it is AsyncThrowingPublisher when the publisher can fail. Add buffer if the publisher can outpace the loop.
- Async to Combine: wrap the call in a Future that starts a Task. Two caveats: Future runs its closure as soon as it is created, so the work starts before anyone subscribes; and the promise is not Sendable, so without the @MainActor annotation the Swift 6 compiler rejects capturing it in the Task ("risks causing data races").
- Own the state once: when both worlds need the same data, store it in one place (an @Observable model or an actor) and expose it to each side, instead of mirroring it in a subject and a stream.
8. Key Points
- Default to async/await for single results and to AsyncSequence for new streams.
- Combine's remaining advantage is its built-in operator library; swift-async-algorithms covers most of it for async code.
- Cancellation defaults are opposite: an AnyCancellable cancels when released, a Task does not.
- Combine backpressure is demand-based and usually invisible; AsyncStream buffering is explicit and unbounded by default.
- .values requests one value at a time and can drop values from a fast publisher; add a buffer when that matters.
- Combine types are not Sendable; in Swift 6 mode, keep them inside one actor.
- Combine is not deprecated; keep working pipelines and migrate incrementally.
- Use .task(id:) in SwiftUI and Observations (iOS 26+) where you once used debounce chains and $property.sink.
9. Final Summary
In 2026 the default for new asynchronous Swift code is Swift concurrency. Async functions make single results trivial, tasks give cancellation a structure, actors and Sendable let the compiler catch data races, and AsyncSequence, AsyncStream, and Observations cover most streaming needs. Combine still earns its place for operator-heavy event pipelines and in codebases built around it, and it remains a supported framework.
The costly mistakes come from assuming the two behave the same: forgetting that a released Task keeps running, that .values changes demand, or that AsyncStream buffers without limit by default. Pick by the checklist, make buffering and cancellation decisions explicit, and keep the boundary between the two worlds narrow and deliberate.