Skip to content

09 — Delaying Work

rebeloper edited this page Jul 12, 2026 · 2 revisions

🍽️ Intuition

Not every order comes in "cook this right now." Sometimes a customer calls ahead: "I'll be at the counter in exactly ten minutes — don't start my order until then, so it's hot the second I walk in."

The chef doesn't handle this by standing at the pass, eyes locked on a clock, refusing to blink for ten straight minutes. That would tie up a chef for no reason — a chef who could be cooking three other orders in the meantime. Instead, the ticket goes into the order line marked with a start time, and a kitchen timer gets set. The order line doesn't even look at that ticket again until the timer goes off. When it finally does, the ticket becomes exactly like any other — it's picked up by whichever chef is free and cooked normally.

Nothing about the ten-minute wait blocks anyone. No chef is frozen, no station is idle-but-reserved, no head chef is pacing. The kitchen just keeps running its normal business, and a timer — not a person — is what tracks the delay.

👉 asyncAfter doesn't make a chef wait around — it makes the order line hold the ticket until the timer goes off, then hands it to whichever chef is free, exactly as if it had just been submitted.


🧠 What It Means In Swift

queue.asyncAfter(deadline:) schedules a block to run on queue, but not before a given point in time. It's .async from Chapter 4 with a timer bolted on — fire-and-forget, non-blocking, and the caller moves on immediately without waiting for the delay or the work itself.

A few mechanics worth being precise about:

  • The deadline is a minimum, not a guarantee. asyncAfter promises the block won't run before the deadline — it says nothing about running exactly at it. If the queue is busy, or every chef is occupied with higher-priority orders, the ticket waits in line like any other submission once the timer fires. The timer controls when the ticket becomes eligible, not when a chef is guaranteed to grab it.
  • The deadline itself is built from DispatchTime, most commonly .now() + seconds. DispatchTime.now() reads a monotonic clock — one tied to how long the system has been running (technically, mach_absolute_time under the hood), not to the calendar or the clock on the wall. It only ever moves forward, at a steady rate, and it's completely unaffected by someone changing the system clock, a daylight-saving shift, or an NTP time sync. That makes it exactly right for "run this two seconds from now" — a pure elapsed-interval measurement, like a kitchen timer that counts down ten minutes no matter what time is painted on the wall clock behind it.
  • There's a second, less commonly reached-for type: DispatchWallTime, used with the asyncAfter(wallDeadline:) overload. This one tracks the actual wall clock — real calendar time, the kind a human reads off their watch. It's built to answer a different question: not "how much time from now," but "at this literal real-world moment." Because it's tied to the wall clock, it is affected by clock changes — if the system time gets adjusted, a pending wall-time deadline effectively moves with it.

The distinction matters most when something changes the clock while a delay is pending. A DispatchTime-based deadline doesn't care — it's anchored to elapsed system uptime, so it fires after the true interval has passed regardless of what the calendar says. A DispatchWallTime-based deadline is pinned to a specific calendar instant, so if the wall clock itself jumps — the device syncs to a time server, or someone manually sets the date — the effective wait changes along with it. (Daylight saving doesn't count here: it only shifts the local-time label attached to a UTC instant, not the underlying instant itself, so it doesn't move a pending DispatchWallTime deadline.) Neither is "more correct" than the other — they answer different questions, and picking the wrong one is the kind of bug that only shows up the rare day the system clock actually moves out from under a pending delay.


📊 ASCII Diagram

TIMELINE ⏱️

  now                                         +2s
   │                                           │
   ▼                                           ▼
   ●───────────────────────────────────────────●
   │   ticket sits in the order line,           │
   │   timer running, no chef assigned yet      │
   └─────────────────────────────────────────────┘
                                                │
                                                ▼
                                        🧑‍🍳 first free chef
                                        picks up the ticket,
                                        cooks it like any
                                        normal order

💻 Tiny Swift Example

DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
    print("Runs 2 seconds later ⏰")
}

👉 .now() + 2 builds a DispatchTime two seconds ahead of the current monotonic clock reading. Nothing about submitting this block blocks the caller — the line above returns instantly, and the print statement fires on the main queue once two real seconds of elapsed time have passed, whenever the main queue is free to run it.

Reaching for the wall-clock variant instead uses a different type entirely, not just a different argument label — and it's only interesting when it's pinned to an actual calendar instant, not just "now plus a few seconds":

let nineTonight = Calendar.current.date(bySettingHour: 21, minute: 0, second: 0, of: Date())!
let interval = nineTonight.timeIntervalSince1970
let deadline = DispatchWallTime(timespec: timespec(tv_sec: Int(interval), tv_nsec: 0))

queue.asyncAfter(wallDeadline: deadline) {
    print("Fires at 9:00 PM real-world clock time 🕘")
}

.now() + 2 measures elapsed system time; nineTonight here is a specific point on the calendar. That's the whole reason DispatchWallTime exists — not "a few seconds from whenever this line happens to run," but "this exact real-world moment, whatever time it is right now."


🔄 vs. Swift Concurrency

The async/await equivalent is Task.sleep(for:) (or the older Task.sleep(nanoseconds:)):

Task {
    try await Task.sleep(for: .seconds(2))
    print("Runs 2 seconds later ⏰")
}

On the surface it looks like a direct swap — delay, then run some code. Underneath, the two mechanisms differ in a way that matters a lot in practice: cancelability.

asyncAfter has no concept of the wait being interrupted. Once the ticket is in the order line with a timer attached, the only lever available is canceling the ticket before it fires — and that only works if you went out of your way to wrap the block in a DispatchWorkItem (Chapter 7) instead of submitting a bare closure. There's no way to reach into an in-flight delay and say "actually, stop counting."

Task.sleep is built into the cooperative cancellation system that runs through all of structured concurrency. If the surrounding Task is canceled while it's suspended inside Task.sleep, the sleep doesn't quietly finish counting down and then run the rest of the function anyway — it throws a CancellationError immediately, right there at the await point, and execution never reaches the code after it. The chef assigned to that task doesn't keep a timer running for an order that's already been called off; the moment cancellation is requested, the sleeping task bails out on the spot.

👉 An asyncAfter ticket, once submitted, is a promise the order line will keep — the only way to stop it is to tear up the ticket before the timer even goes off. A sleeping Task is listening the entire time it's waiting — cancel it mid-wait, and it stops being asked to sleep at all.

That difference isn't a minor implementation detail — it's exactly the kind of "long-running work needs to react mid-flight, not just before it starts" gap that Chapter 7 flagged as DispatchWorkItem's biggest limitation. Delays are one more place that gap shows up.


🧸 Memory Sentence

asyncAfter is a kitchen timer, not a stopwatch you can pause.


⚠️ Analogy Limit

A real kitchen timer is a physical object sitting on a shelf. Anyone can walk over and turn it off, reset it, or pause it mid-countdown — a chef changes their mind, the customer calls back and cancels, someone just bumps the dial. It takes one motion.

asyncAfter isn't nearly that forgiving:

  • There's no built-in way to stop a scheduled block from firing once it's been submitted. queue.asyncAfter(deadline:) { ... } with a bare closure gives you nothing to grab onto — no handle, no .cancel(), no way to interrupt the countdown.
  • Getting any cancellation requires the same workaround from Chapter 7 — submit a DispatchWorkItem instead of a plain closure, and call item.cancel() on it. Even then, cancellation only works in the narrow window before the timer fires and a chef picks up the ticket. Once the deadline passes and the block starts running, .cancel() is a no-op, exactly like it is for any other work item.
  • There's no equivalent of walking over and pausing the timer partway through, either. The delay isn't a resource you can inspect or adjust once it's scheduled — it just counts down to zero and fires, or it gets torn up beforehand. There's no middle state.

So "kitchen timer you can walk over and touch" is the part of the analogy that doesn't survive contact with the API. A real timer is always within arm's reach. A scheduled asyncAfter block, by default, is not reachable at all — you have to have planned for cancellation before submitting it, or you don't get it.


✅ Check Your Understanding

You need to schedule a block that fires "in five seconds from right now," and separately, a block that fires "at 9:00 PM local time tonight, no matter how long the device has been asleep before then." Which one calls for DispatchTime, which for DispatchWallTime, and why does the distinction actually matter?

Clone this wiki locally