# Combine vs async/await: Which to Use in 2026

Source: https://devdocs10.com/articles/swift/swift-combine-vs-async-await  
By Murat Bo · Published 2026-10-17 · DevDocs10 (https://devdocs10.com)

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](/articles/swift/swift-combine-basics) (publishers, operators, `AnyCancellable`), [async/await](/articles/swift/swift-async-await), and [structured concurrency](/articles/swift/swift-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:

```swift
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:

```swift
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:

```swift
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](https://github.com/apple/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](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/concurrency/) 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:

```swift
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:

```swift
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.

```swift
// 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")
```

Output (tested with Swift 6.4):

```text
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](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0475-observed.md), Swift 6.2) is the async replacement on iOS 26 and macOS 26 or later:

```swift
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.value
```

**Why:** 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":

1. **Does the work produce a single result?** Use an `async` function.
2. **Must the code build outside Apple platforms?** Use Swift concurrency; Combine is not available.
3. **Is the work tied to a SwiftUI view's lifetime or to an input value?** Use `.task` or `.task(id:)`.
4. **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.
5. **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.
6. **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.
7. **Does an API you consume hand you a publisher?** Use Combine operators on it, or bridge it with `.values` and decide on buffering.
8. **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:

```swift
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`](https://developer.apple.com/documentation/combine/publisher/values-1dm9r) (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.
