Async/Await and Structured Concurrency
How `await` suspends without blocking, structured vs unstructured tasks, task groups, cooperative cancellation, continuations, and async sequences.
13 questions
JuniorTheoryVery commonWhat does async/await do, and how does it differ from a completion handler?
What does async/await do, and how does it differ from a completion handler?
An async function suspends and resumes without blocking its thread; await marks where it may pause for a result. Unlike a completion handler, code reads top to bottom, returns values directly, and throws errors normally.
Common mistakes
- ✗Believing
awaitblocks the calling thread while it waits - ✗Thinking every
asynccall hops to a new background thread - ✗Assuming errors must still be delivered through a callback, not
throw
Follow-up questions
- →What thread does an
asyncfunction resume on after anawait? - →How does a throwing
asyncfunction report a failure to its caller?
MiddleCodeVery commonFetch three endpoints with async let, then N items with a TaskGroup
Fetch three endpoints with async let, then N items with a TaskGroup
Bind three known endpoints with async let and read them in one combined await — they run concurrently. For a dynamic N, use a throwing task group: addTask per item, gather with for try await, which rethrows a child error.
Common mistakes
- ✗Awaiting each
async letseparately, serializing the fetches - ✗Thinking a
TaskGrouphandles only a fixed compile-time task list - ✗Writing group results into a shared array instead of returning them
Follow-up questions
- →Why does binding with
async letstart the work before theawait? - →How does a throwing task group propagate one child's error to the parent?
MiddleTheoryCommonasync let vs a TaskGroup vs three sequential awaits — which actually runs in parallel?
async let vs a TaskGroup vs three sequential awaits — which actually runs in parallel?
Three sequential awaits run one after another — no parallelism. async let starts a fixed, known set of child tasks concurrently, awaited together. A TaskGroup runs a dynamic number for N items.
Common mistakes
- ✗Thinking
async letawaits each binding before starting the next - ✗Believing sequential
awaits overlap because each suspends - ✗Swapping which construct handles a fixed vs dynamic task count
Follow-up questions
- →When does an
async letchild task actually start executing? - →Why must a
TaskGroup's child results be collected before the group returns?
MiddleTheoryCommonWhat is a suspension point, and why does an await not block the underlying thread?
What is a suspension point, and why does an await not block the underlying thread?
An await is a potential suspension point — the function saves its state, returns the thread to the pool, and may resume on another thread. The freed thread runs other work, so a suspension blocks no thread.
Common mistakes
- ✗Thinking
awaitparks and blocks the calling thread - ✗Assuming a task always resumes on the same thread it suspended on
- ✗Believing each suspension creates or consumes an OS thread
Follow-up questions
- →What guarantees does Swift give about which thread a task resumes on?
- →How does the cooperative thread pool avoid the thread explosion of
DispatchQueue?
MiddleCodeCommonCancel the in-flight search when the query changes, two ways
Cancel the in-flight search when the query changes, two ways
Store the search in a Task?; on each new query, cancel() it before the next so the stale request stops. In SwiftUI .task(id: query) does the same on a query change. The fetch must still check cancellation to stop.
Common mistakes
- ✗Assuming dropping a
Taskreference cancels it - ✗Thinking
cancel()aborts the network call without a check - ✗Believing
.task(id:)ignores changes to theidvalue
Follow-up questions
- →Why must the search function itself check
Task.isCancelled? - →How does
.task(id:)decide when to restart its async body?
MiddleCodeCommonBridge a completion-handler API to async with a checked continuation
Bridge a completion-handler API to async with a checked continuation
withCheckedThrowingContinuation gives a continuation; resume it exactly once — returning or throwing. Resuming twice traps, since it crashes on the second call; never resuming hangs the task forever at await.
Common mistakes
- ✗Thinking a continuation may be resumed more than once
- ✗Assuming a forgotten
resumeis cleaned up instead of hanging - ✗Confusing checked (traps) with unsafe (undefined) continuations
Follow-up questions
- →What does the checked variant of
withCheckedContinuationverify at runtime? - →How would you guard a continuation that a callback might invoke twice?
MiddleTheoryCommonWhy is Swift task cancellation cooperative, and what breaks if you ignore it?
Why is Swift task cancellation cooperative, and what breaks if you ignore it?
Cancellation only sets a flag — it never stops a task by force. The task must check Task.isCancelled or try Task.checkCancellation() and return early. Ignore it and the work still runs to completion, wasting resources on an unwanted result.
Common mistakes
- ✗Expecting cancellation to preempt and kill the task immediately
- ✗Assuming the runtime stops cancelled work without any checks
- ✗Believing cancelled work is discarded rather than run to completion
Follow-up questions
- →Where should a long compute loop place its cancellation checks?
- →What happens to child tasks when their parent task is cancelled?
MiddleTheoryCommonTask {} vs Task.detached vs a child task — what does each inherit?
Task {} vs Task.detached vs a child task — what does each inherit?
A structured child task inherits priority, actor isolation, and task-locals, scoped to its parent. Unstructured Task {} still inherits priority, actor, and task-locals. Task.detached inherits none of them and runs independently.
Common mistakes
- ✗Thinking
Task {}inherits nothing, likeTask.detached - ✗Believing
Task.detachedcarries over the current actor - ✗Assuming task-local values propagate into a detached task
Follow-up questions
- →Why does
Task.detacheddeliberately drop the enclosing actor context? - →When does an unstructured
Task {}need manual cancellation?
MiddleDebuggingCommonA Task {} in viewDidLoad keeps running after the screen is popped and leaks self
A Task {} in viewDidLoad keeps running after the screen is popped and leaks self
Two bugs. An unstructured Task {} is not tied to the controller — popping never cancels it, so it runs on. And it captures self strongly, blocking deinit. Fix — store the task, cancel() on disappear, capture [weak self].
Common mistakes
- ✗Thinking an unstructured
Task {}is cancelled on deinit - ✗Believing
Task {}is awaited like a structured child task - ✗Expecting
Task.detachedto fix lifetime and capture at once
Follow-up questions
- →Why does a strong
selfcapture keep the controller alive? - →Where should the stored task be cancelled in a UIKit lifecycle?
MiddleTheoryOccasionalHow do you turn a delegate or callback stream into an AsyncStream you can for await over?
How do you turn a delegate or callback stream into an AsyncStream you can for await over?
Wrap the source in AsyncStream { continuation in ... }: yield(value) on each callback, finish() when it ends, onTermination to unsubscribe. Consumers for await value in stream, which yields many values, not one.
Common mistakes
- ✗Reaching for a one-shot continuation instead of
AsyncStream - ✗Forgetting
finish(), so consumersfor awaitforever - ✗Skipping
onTermination, leaking the underlying subscription
Follow-up questions
- →What does
onTerminationlet you clean up when iteration stops? - →How does an
AsyncStream's buffering policy handle a fast producer?
SeniorCodeOccasionalRun at most N downloads at once with a task group (bounded concurrency)
Run at most N downloads at once with a task group (bounded concurrency)
Seed the group with the first N tasks; when a result comes back, add the next item. That keeps N in flight — never more than N children — capping memory and connections. Adding every task up front instead launches all at once.
Open full question →Common mistakes
- ✗Assuming a task group self-limits to the core count
- ✗Blocking child tasks with a semaphore instead of throttling additions
- ✗Thinking you cannot control how many group tasks run at once
Follow-up questions
- →What happens to the remaining queue if one child task throws?
- →How would you preserve input order while capping concurrency at N?
SeniorDesignOccasionalYou inherit a mid-sized app whose networking and data layers are built entirely on the dispatch framework GCD — DispatchQueue hops, completion-handler callbacks, and a few DispatchSemaphore waits — and you must migrate it to async/await incrementally, without a big-bang rewrite and while shipping features every sprint. Describe your migration order: which layer you convert first, how you bridge the not-yet-migrated boundaries in both directions (calling old callback APIs from new async code and calling async from old code), what a withCheckedContinuation wrapper buys you at the seams, and what tends to break first — hidden threading assumptions, @MainActor UI updates, or semaphore deadlocks when an async call is trapped behind a blocking wait. What order keeps the app releasable at every step?
You inherit a mid-sized app whose networking and data layers are built entirely on the dispatch framework GCD — DispatchQueue hops, completion-handler callbacks, and a few DispatchSemaphore waits — and you must migrate it to async/await incrementally, without a big-bang rewrite and while shipping features every sprint. Describe your migration order: which layer you convert first, how you bridge the not-yet-migrated boundaries in both directions (calling old callback APIs from new async code and calling async from old code), what a withCheckedContinuation wrapper buys you at the seams, and what tends to break first — hidden threading assumptions, @MainActor UI updates, or semaphore deadlocks when an async call is trapped behind a blocking wait. What order keeps the app releasable at every step?
Go bottom-up — wrap the lowest callback APIs in withCheckedContinuation so higher layers adopt async behind a stable seam. Bridge both ways: continuations for callback→async, Task {} the reverse. Leaf services first, UI last. Blocking semaphore waits break first, deadlocking the pool.
Common mistakes
- ✗Migrating top-down (UI first) instead of bottom-up from leaf APIs
- ✗Keeping
DispatchSemaphorewaits that block the cooperative pool - ✗Expecting
Task.detachedto bridge callbacks without a continuation
Follow-up questions
- →How do you keep the app releasable during a multi-sprint migration?
- →Why does a blocking wait inside an
asyncfunction risk deadlock?
SeniorTheoryOccasionalWhat problem do task-local values solve, and how do they behave across Task.detached?
What problem do task-local values solve, and how do they behave across Task.detached?
A @TaskLocal carries ambient context — a request id, trace span — down a call tree, not through signatures. Bound by withValue { }, it is inherited by child tasks and Task {}; Task.detached inherits nothing and reads the default.
Common mistakes
- ✗Treating a
@TaskLocalas a mutable global variable - ✗Assuming
Task.detachedinherits the caller's task-locals - ✗Confusing task-local scoping with thread-local storage
Follow-up questions
- →How would you re-establish a trace id inside a
Task.detached? - →Why is
withValuescoping safer than a mutable global for context?