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.publishinto asinkdoing real work per tick- A
PassthroughSubjectfed 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—.keepFullrequests eagerly to stay topped up;.byRequestonly 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.