Home

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.