How many searches does typing "Parcel Wire" launch?
If you wire the text field straight to the network, the answer is one per key — 11 in this script. If you wait for a beat of quiet first, the answer collapses to 2: once after Parcel, once after Parcel Wire.
This bench runs that experiment three ways at once and prints every event on one tape.
One script, three listeners
private static let script: [(TimeInterval, String)] = [
(0.00, "P"), (0.08, "Pa"), (0.16, "Par"), (0.24, "Parc"),
(0.32, "Parce"), (0.40, "Parcel"),
(1.20, "Parcel "), (1.28, "Parcel W"), (1.36, "Parcel Wi"),
(1.44, "Parcel Wir"), (1.52, "Parcel Wire"),
]Eleven commits. A 400 ms quiet window after each burst. The rose column is the control: it has no window.
| Listener | Emits | Last value |
|---|---|---|
| RAW (control) | 11 | Parcel Wire |
Combine debounce | 2 | Parcel Wire |
| Async sleep+cancel | 2 | Parcel Wire |
The tape is the argument
From the settled still, after the first pause:
| Tag | ms | Text |
|---|---|---|
| KEY… | 403 | Parcel |
| CMB | 804 | Parcel |
| ASY | 837 | Parcel |
And after the second:
| Tag | ms | Text |
|---|---|---|
| KEY… | 1524 | Parcel Wire |
| CMB | 1925 | Parcel Wire |
| ASY | 1958 | Parcel Wire |
Combine and async agree on what. They are about 33 ms apart on when — same 400 ms debounce, different scheduling. That gap is real and small enough that a search UI should not care; the RAW column is the one that hurts.
Bind a TextField to an @Published query and fire /search in onChange and you have shipped the rose column. Eleven emits for one human thought. Debounce is not style; it is refusing to bill the network for every letter.
Mid-type: the control still looks "more up to date"
Caught mid-burst: RAW already shows Parcel Wi (9 emits). Combine and async still display Parcel (1 emit each) because the 400 ms quiet has not arrived. The control looks faster. It is also wrong about what the user meant — they were not done.
Combine: debounce(for:scheduler:)
subject
.debounce(for: .seconds(0.4), scheduler: DispatchQueue.main)
.sink { value in
combineResult = value
appendTape(tag: "CMB", text: value)
}The Time Manipulation Operators chapter in the Combine book uses the same idea with a one-second window and a Hello World typing script. This bench keeps the operator, swaps the domain to a shipment search, and puts the competing async implementation in the next column instead of a playground console.
Async: sleep, then check the generation
asyncGeneration += 1
let generation = asyncGeneration
Task { @MainActor in
try? await Task.sleep(nanoseconds: 400_000_000)
guard generation == asyncGeneration else { return } // a newer key won
asyncResult = text
appendTape(tag: "ASY", text: text)
}Each key cancels the previous wait by bumping the generation — the unstructured twin of debounce. Chapter 10's lesson applies: this Task is only safe because we ignore stale generations on purpose. Prefer .task / structured cancellation when the wait is tied to a view lifetime.
11 → 2 on both debounced lanes. The operator graph and the generation token are different spellings of "wait until the keystrokes stop." Pick Combine when you already live in publishers; pick async when the surrounding code is async/await. Do not mix them in one pipeline without a bridge — that is a later bench.
Mini-exercise
Change debounceSeconds to 0.05 and re-run. Watch Combine and async emit counts climb toward RAW. Then set it to 1.0 and watch the mid-type still sit on — even longer. The number is a product decision, not a constant of nature.
Challenges
- Replace the generation token with a stored
Taskyoucancel()on each key — photograph whether ASY timestamps move closer to CMB. - Add Combine
throttle(for:scheduler:latest:)as a fourth card and show how it differs from debounce on this same script. - Bridge the
PassthroughSubjectinto anAsyncStreamand drive one debounce only from async — delete the duplicate scheduler path. - Fire a fake network counter on each emit and show RAW billing 11 credits vs 2 for the debounced lanes.
- Type a third burst into the script (
Parcel Wire Pro) and confirm both debouncers emit 3 times while RAW emits N keys.
Key Points
- Debounce waits for quiet; this bench uses 400 ms.
- RAW control: 11 emits for
Parcel Wire. Combine and async: 2 each. - First emission: CMB 804 ms / ASY 837 ms (
Parcel); second: CMB 1925 / ASY 1958 (Parcel Wire). - ~33 ms separates the two debouncers — same answer, different schedulers.
- Mid-type, RAW looks ahead (
Parcel Wi) while debounce still shows the last committed search. - Combine's
debounce(for:scheduler:)is the publisher spelling; sleep+generation is the async spelling. - The tape makes the flood visible in a still — many KEY lines, two CMB/ASY pairs.
- Pick the runtime that matches the surrounding code; the product rule is identical: do not search on every key.
Next up: persistence — the same edit written three ways (UserDefaults, JSON file, SwiftData) with a kill-and-relaunch that shows which survive.
Read next
Ship your apps faster
When you're ready to publish your Swift app to the App Store, Simple App Shipper handles metadata, screenshots, TestFlight, and submissions — all in one place.
Try Simple App Shipper