Tutorials › Launch Lab Notes › Chapter 12

Two Debounces, One Tape: Combine and async/await Wait for the Same Quiet

LabChapter 12 of the Launch Lab Notes22 minAugust 19, 2026Intermediate

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.

Six frames of the Parcel Wire script: RAW climbs to 11 emits while Combine and async stay at 2; the tape shows KEY floods with CMB/ASY pairs after each pause.

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.

SETTLED: TYPE Parcel Wire. RAW 11 emits, COMBINE 2 emits, ASYNC 2 emits. Tape shows CMB Parcel at 804 ms, ASY at 837 ms, then CMB/ASY Parcel Wire at 1925/1958 ms.
ListenerEmitsLast value
RAW (control)11Parcel Wire
Combine debounce2Parcel Wire
Async sleep+cancel2Parcel Wire

The tape is the argument

From the settled still, after the first pause:

TagmsText
KEY…403Parcel
CMB804Parcel
ASY837Parcel

And after the second:

TagmsText
KEY…1524Parcel Wire
CMB1925Parcel Wire
ASY1958Parcel 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.

RAW is not a strawman — it is the default

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"

TYPING Parcel Wi: RAW shows Parcel Wi with 9 emits; COMBINE and ASYNC still show Parcel with 1 emit each. Tape ends at KEY 1364 ms Parcel Wi after CMB/ASY Parcel around 804/833 ms.

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.

Same quiet, two runtimes

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

  1. Replace the generation token with a stored Task you cancel() on each key — photograph whether ASY timestamps move closer to CMB.
  2. Add Combine throttle(for:scheduler:latest:) as a fourth card and show how it differs from debounce on this same script.
  3. Bridge the PassthroughSubject into an AsyncStream and drive one debounce only from async — delete the duplicate scheduler path.
  4. Fire a fake network counter on each emit and show RAW billing 11 credits vs 2 for the debounced lanes.
  5. Type a third burst into the script (Parcel Wire Pro) and confirm both debouncers emit 3 times while RAW emits N keys.

Key Points

Next up: persistence — the same edit written three ways (UserDefaults, JSON file, SwiftData) with a kill-and-relaunch that shows which survive.

SwiftUI
SwiftUI tutorials for building native app screens, layouts, navigation, and state-driven interfaces.
Swift
Swift fundamentals for app developers who want to understand the language behind real iOS and macOS apps.
Ship iOS
Shipping workflows for iOS apps.
📚 Go deeper with LIPAI WANG’s hands-on Udemy bootcampsBrowse all courses →
← Ch 11: The Wrong TotalCh 13: What Survives a Relaunch→
SwiftUIUltimate SwiftUI SeriesSwiftUI tutorials for building native app screens, layouts, navigation, and state-driven interfaces.SwiftUltimate Swift SeriesSwift fundamentals for app developers who want to understand the language behind real iOS and macOS apps.Ship iOSShip iOS Apps SeriesShipping workflows for iOS apps: signing, TestFlight, App Store Connect, CI, and release hygiene.

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
5 free articles remainingSubscribe for unlimited access