Home

Demand and backpressure

The concept most Combine users never learn

Backpressure is what a consumer does when a producer is faster than it. Most reactive frameworks answer “buffer and hope”; Combine builds the answer into the protocol, and that decision explains several of its odd corners.

struct Demand: Equatable {
    static let unlimited: Demand
    static let none: Demand
    static func max(_ value: Int) -> Demand
}

Demand is cumulative and can only go up. A subscriber that has requested .max(3) and then .max(2) may receive five values. There is no way to reduce outstanding demand — you cannot un-ask. The only way to stop is to cancel.

Why you have never had to think about it

Because sink and assign request .unlimited on subscription, and .unlimited disables the mechanism entirely. Every value the publisher can produce is sent as soon as it exists.

For a network response or a button tap, that is correct and free. It stops being free when the producer genuinely outruns the consumer:

  • A file read emitting chunks faster than they can be parsed
  • A sensor or socket delivering at a fixed high rate
  • Timer.publish into a sink doing real work per tick
  • A PassthroughSubject fed in a tight loop

In each case an unlimited sink buffers, and Combine’s buffers are not bounded by default. Memory grows until something is jettisoned.

buffer makes the policy explicit

fastPublisher
    .buffer(size: 100, prefetch: .keepFull, whenFull: .dropOldest)
    .sink { process($0) }
    .store(in: &cancellables)

Three decisions, all of which you were making implicitly before:

  • size — how many values may wait.
  • prefetch — .keepFull requests eagerly to stay topped up; .byRequest only asks when the downstream does.
  • whenFull — .dropOldest, .dropNewest, or .customError.

The value of this operator is less the buffering than the fact that it forces you to answer “what should happen when we fall behind?” — a question every real-time pipeline has, and most answer by accident.

Warning

.customError is the only policy that tells you it happened. Both drop policies discard data silently, which is fine for sensor readings and disastrous for a queue of user actions. Pick deliberately.

Cheaper answers first

Before reaching for buffer, consider whether the values need to arrive at all:

Operator Behaviour
throttle(for:scheduler:latest:) At most one value per interval, first or last
debounce(for:scheduler:) Only after a quiet period — the search-field operator
removeDuplicates() Drop consecutive equal values
collect(.byTime(_:_:)) Batch into arrays per interval
latest via switchToLatest Abandon the in-flight one when a newer arrives

For UI, these are almost always the right answer. A scroll position published at 120 Hz into a view that redraws at 60 needs throttle, not a buffer — dropping intermediate values is not data loss, because a stale scroll position has no value.

Where the abstraction leaks

Subjects ignore demand. send(_:) on a PassthroughSubject delivers immediately regardless of what downstream asked for. Subjects are the imperative door, and imperative code does not negotiate — which is another reason a subject in the middle of a pipeline is a smell. It quietly removes backpressure from everything downstream of it.

receive(on:) introduces an unbounded queue. Values crossing a scheduler boundary are enqueued onto that scheduler, and that queue is not the same as any buffer you configured upstream. A pipeline that is correctly backpressured up to receive(on: DispatchQueue.main) can still flood the main queue.

flatMap has a maxPublishers parameter, and its default is .unlimited. That default is the single most expensive one in the framework:

urls.publisher
    .flatMap { url in fetch(url) }              // every request at once
    .sink { … }

urls.publisher
    .flatMap(maxPublishers: .max(4)) { url in fetch(url) }   // four at a time
    .sink { … }

A thousand URLs through the first version starts a thousand concurrent requests. The second is a concurrency limit written as one argument, and it is the reason to know this chapter exists even if you never write a custom subscriber.