Timing
debounce, throttle, and the scheduler they both need
Every timing operator takes a scheduler, and the scheduler is not a formality — it decides both when the operator’s clock runs and which thread the value arrives on.
debounce against throttle
The two get confused constantly, and they solve opposite problems.
debounce waits for silence. It emits a value only after the source has been quiet for the
given interval. Values arriving during the wait reset the timer.
$searchText
.debounce(for: .milliseconds(300), scheduler: RunLoop.main)
.sink { search($0) }
Type continuously for ten seconds and nothing is emitted at all until you pause. That is exactly right for a search field — you want the query the user finished typing, not the ones they passed through.
throttle samples at a rate. It emits at most one value per interval, regardless of how many
arrive.
scrollOffset
.throttle(for: .milliseconds(100), scheduler: RunLoop.main, latest: true)
.sink { updateHeader($0) }
Values keep coming out during a continuous stream, at a steady rate. Right for scroll positions, sensor readings, progress updates — anything where you want regular updates but not all of them.
The distinguishing question: during a continuous burst, do you want output? Throttle yes, debounce no.
throttle’s latest: parameter chooses which value in each interval survives — true for the most
recent (usually what you want for a position), false for the first (better when the first value in
a burst is the meaningful one, like a button tap).
Warning
debounce on a stream that never goes quiet emits nothing, forever. If the source is a timer or
a sensor, debounce is the wrong operator and the failure mode is silence rather than an error.
The other timing operators
publisher.delay(for: .seconds(1), scheduler: DispatchQueue.main)
publisher.timeout(.seconds(10), scheduler: DispatchQueue.main, customError: { .timedOut })
publisher.collect(.byTime(DispatchQueue.main, .seconds(1)))
publisher.measureInterval(using: DispatchQueue.main)
timeout is worth calling out because its default behaviour surprises people: without
customError:, a timeout completes the stream successfully rather than failing it. A pipeline
that silently finishes instead of reporting a stalled request is worse than one that errors, so pass
the error.
collect(.byTime(_:_:)) batches — useful for turning a stream of individual analytics events into
one request per second, which is the difference between a reasonable API and a rate limit.
Which scheduler
The choice changes behaviour, not just threading:
| Scheduler | Notes |
|---|---|
DispatchQueue.main |
Delivers on the main queue. Standard for UI |
RunLoop.main |
Main run loop. Pauses during scroll tracking |
DispatchQueue.global() |
Background work |
ImmediateScheduler.shared |
No delay at all — testing |
RunLoop.main and DispatchQueue.main are not interchangeable, and this catches people. While the
user is dragging a scroll view, the main run loop is in tracking mode and a RunLoop.main-scheduled
timer does not fire. A debounce(scheduler: RunLoop.main) on a search field inside a scrolling list
appears frozen until the finger lifts.
For anything user-facing, prefer DispatchQueue.main.
receive(on:) against subscribe(on:)
Different operators solving different problems, and mixing them up is the most common Combine threading bug.
receive(on:) changes where values are delivered — everything downstream of it runs on that
scheduler.
subscribe(on:) changes where the subscription happens — where the publisher’s own work
starts, which is upstream.
expensivePublisher
.subscribe(on: DispatchQueue.global(qos: .userInitiated)) // work starts here
.map { transform($0) } // still on the global queue
.receive(on: DispatchQueue.main) // delivery moves to main
.sink { updateUI($0) } // main
Position matters for both. receive(on:) affects only what comes after it, so putting it at the top
of a chain and then doing expensive map work below moves that work onto the main queue — the exact
opposite of the intent.
subscribe(on:) is needed less often than people think. URLSession.dataTaskPublisher already does
its work off the main thread; adding subscribe(on:) to it achieves nothing. Reach for it when the
publisher does synchronous work on subscription — reading a file, a large Sequence.publisher, an
expensive Deferred — and reach for receive(on:) in essentially every pipeline that ends in UI.
Tip
One receive(on: DispatchQueue.main) immediately before the sink is the right default. Put it
as late as possible so that everything above it stays off the main queue.