Combine in iOS: Publishers, sink, debounce and When to Use async

Advanced iOS Development: Concurrency, Architecture, Security and Release · Advanced Swift · 6 min read · Updated

A Combine pipe

  1. A publisher produces values over time, such as text typed in a field.
  2. The debounce operator waits until the typing pauses.
  3. The filter keeps only values that pass your test.
  4. The sink subscriber receives what is left, and runs your closure. Keep its cancellable, or it stops.

Combine is Apple's framework for handling values that change over time, such as text typed into a field, a stream of network results, or several inputs that must be checked together. It arrived in 2019, before async/await. Today, new code often uses async/await and @Observable instead, but Combine is still all over existing code bases, and the job posts that list *Combine* mean it. This lesson teaches you to read and write it, and to know when not to.

The three words: publisher, operator, subscriber

  • A publisher produces values over time, and may finish or fail.
  • An operator changes the values on their way: map, filter, debounce, removeDuplicates.
  • A subscriber receives the values. The simplest is sink, which runs a closure for each value.

Think of a pipe: a source, some filters in the middle, and a tap at the end. Nothing flows until a subscriber attaches.

import Combine

let subject = PassthroughSubject<String, Never>()
var cancellables = Set<AnyCancellable>()

subject
    .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
    .removeDuplicates()
    .filter { $0.count >= 2 }
    .sink { text in
        print("search for", text)
    }
    .store(in: &cancellables)

subject.send("s")        // ignored: too short
subject.send("sw")
subject.send("swi")      // only the last value after 300 ms is delivered

PassthroughSubject is a publisher you can push values into by hand with send. The two type words say *the values are Strings* and *it never fails* (Never). debounce waits until values stop for a moment, which is the classic way to avoid a search call on every key press. sink returns an AnyCancellable. If you let it go, the subscription ends, so you keep it in a set with store(in:).

@Published and ObservableObject

Before the @Observable macro, SwiftUI view models were classes that conformed to ObservableObject, with @Published properties. Every @Published property is a Combine publisher. You reach it with a dollar sign, and you can build pipes on top of it.

import Combine

@MainActor
final class ExpenseFormModel: ObservableObject {
    @Published var title = ""
    @Published var amountText = ""
    @Published private(set) var isValid = false

    init() {
        Publishers.CombineLatest($title, $amountText)
            .map { title, amountText in
                !title.trimmingCharacters(in: .whitespaces).isEmpty
                    && (Double(amountText) ?? 0) > 0
            }
            .assign(to: &$isValid)
    }
}

CombineLatest waits until each publisher has a value, then emits a new pair each time either one changes. The map turns each pair into one Bool, and assign(to: &$isValid) feeds it into the published property. The form is always up to date, with no manual code that runs on each change. In a view you use @StateObject private var model = ExpenseFormModel() to own such a class, where the newer @Observable classes use @State.

Networking with Combine

import Combine
import Foundation

func weatherPublisher(url: URL) -> AnyPublisher<Weather, Error> {
    URLSession.shared.dataTaskPublisher(for: url)
        .map(\.data)
        .decode(type: Weather.self, decoder: JSONDecoder())
        .receive(on: DispatchQueue.main)
        .eraseToAnyPublisher()
}

var bag = Set<AnyCancellable>()

weatherPublisher(url: someURL)
    .sink(receiveCompletion: { completion in
        if case .failure(let error) = completion { print("failed:", error) }
    }, receiveValue: { weather in
        print(weather.current.temperature)
    })
    .store(in: &bag)
  • receive(on:) moves delivery to the main queue, which you need before touching the screen.
  • eraseToAnyPublisher() hides the long real type behind one simple name, so you can return it from a function.
  • A publisher can fail, and the failure arrives in receiveCompletion. After a failure the pipe is finished.

Combine or async/await?

Both can describe asynchronous work, and the choice is about the shape of the problem.

  • One answer, later (a network call, a database read): use async/await. It reads like normal code, and errors use try and catch.
  • Many values over time, to be combined or timed (typing with a pause, several inputs merged): Combine operators are strong here, and the same job in async code often needs more hand-written work. Swift also has AsyncSequence for streams, but fewer ready-made operators.
  • Existing code: if the project uses Combine, follow it. Rewriting working code for fashion is a risk with little gain.
  • You can move between them. A publisher has a .values property, so for await value in subject.values { } works, and AsyncStream can feed a publisher.

Common mistakes

  • A sink that is not stored. The subscription ends at once, and nothing happens.
  • Updating the screen without receive(on: DispatchQueue.main).
  • A sink that uses self and is stored in self's cancellables, without [weak self]. This is a retain cycle from lesson 3.
  • Very long chains of operators that nobody can read. Split them and name the steps.

Your task

  1. Type the debounce example in a Playground or a small app and check which values get through.
  2. Build ExpenseFormModel and show a Save button that is disabled while isValid is false.
  3. Write one Publisher for a URL as above, and also the same call with async/await. Compare the two in a few sentences.
  4. Use .values to read a PassthroughSubject with for await.

How to check it worked

  • Only the last value after a pause reaches the sink.
  • The Save button turns on and off as you type.
  • You can say when you would pick async/await and when Combine.

Frequently asked questions

What is Combine in iOS?

Apple's framework for handling values that change over time, built from publishers, operators and subscribers such as sink. It is common in existing code bases and has been joined by async/await and Observation.

Why must I store the result of sink?

sink returns an AnyCancellable. If it is released, the subscription ends. Storing it, usually in a Set<AnyCancellable> with store(in:), keeps the pipe alive.

Should I learn Combine in 2026 or only async/await?

Learn async/await first. Then learn enough Combine to read and maintain existing code, since many job posts and older projects still use it.