Home

Publishers and subscribers

The contract underneath every operator

A Publisher promises to deliver values of one type and to fail with one error type. A Subscriber promises to accept them and to say how many it can take.

protocol Publisher<Output, Failure> {
    associatedtype Output
    associatedtype Failure: Error
    func receive<S: Subscriber>(subscriber: S)
        where S.Input == Output, S.Failure == Failure
}

Two associated types, and both matter. Output is what flows; Failure is how it can stop badly. A publisher that cannot fail declares Failure == Never, and the compiler then knows the error path does not exist — which is why sink { } with a single closure compiles for some publishers and not others.

The part people miss is demand. A subscriber does not passively receive; it requests a number of values, and the publisher may not send more than that. This is Combine’s backpressure, and it is why sink — which requests .unlimited — is the wrong tool the moment the producer is faster than the consumer.

Lifetime

A subscription lives exactly as long as the AnyCancellable you hold:

final class SearchModel {
    private var cancellables: Set<AnyCancellable> = []

    func start() {
        publisher.sink { … }.store(in: &cancellables)
    }
}

Drop the reference and the stream stops mid-flight — a silent bug, because nothing errors and nothing logs.

What the three chapters cover

The contract goes through the handshake step by step: what actually happens between subscribe and the first value, what a Subscription is, and why writing a custom Subscriber — which almost nobody does — is the fastest way to stop finding the framework mysterious.

Subjects are the bridge from imperative code into a stream. PassthroughSubject and CurrentValueSubject are how a delegate callback, a button action or a legacy API becomes something operators can work on, and they are also the most over-used part of Combine.

Demand and backpressure is the concept that distinguishes Combine from every other reactive framework Apple could have shipped, and the one that explains why some pipelines quietly buffer until memory runs out.

A map of the built-in publishers

Worth having in mind before the detail:

Publisher Emits
Just(value) One value, then finishes. Failure == Never
Empty() Nothing, then finishes immediately
Fail(error:) Nothing, then fails
Future { promise in … } Exactly one value or one error, from a closure
Deferred { … } Builds its real publisher fresh for each subscriber
PassthroughSubject Whatever you send it, to current subscribers only
CurrentValueSubject Its current value on subscribe, then whatever you send
URLSession.dataTaskPublisher One (Data, URLResponse) or a URLError
Timer.publish Dates, forever, once connected
NotificationCenter.publisher Notifications, forever
@Published Its wrapped value on subscribe, then every change

Warning

Future runs its closure immediately on creation, not on subscription, and caches the result. That makes it wrong for anything you meant to be lazy or repeatable — a Future that performs a network request fires that request whether or not anybody subscribes, and every later subscriber gets the same cached answer. Wrap it in Deferred when you want per-subscriber behaviour, which is usually.

Cold and hot

The distinction runs through everything that follows.

Cold publishers do their work per subscriber. URLSession.dataTaskPublisher subscribed to twice makes two requests; Just delivers to each subscriber separately. Nothing happens until somebody subscribes, and each subscriber gets its own run.

Hot publishers emit whether anybody is listening or not, and late subscribers miss what already happened. Subjects, NotificationCenter, and any ConnectablePublisher after connect() behave this way.

The practical consequence is that a chain built on a cold publisher and subscribed to from three places does its work three times. share() turns one into a hot publisher with a single upstream subscription; multicast gives you control over the subject it shares through. Reaching for either without knowing which kind you started with is how a login request ends up firing twice.