Async/Await and Structured Concurrency
How `await` suspends without blocking, structured vs unstructured tasks, task groups, cooperative cancellation, continuations, and async sequences.
14 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?
The function pauses at await without holding up other work and resumes with the result; unlike a completion handler, it returns that result with return and reports errors with throw.
Common mistakes
- ✗Expecting the result to come into a closure, as with a completion handler: an async function hands it back with
returnand reports a failure withthrow. - ✗Thinking
awaitstops the whole program until the result comes: only the function that waits pauses, and other work can go on meanwhile.
Follow-up questions
- →What thread does an
asyncfunction resume on after anawait? - →How does an
async throwsfunction 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?
JuniorTheoryCommonWhat does the program print (lines separated by spaces)?
What does the program print (lines separated by spaces)?
end
Open full question →Common mistakes
- ✗Expecting
end inside: thinking the program waits for its tasks. It ends after the last line ofmain.swift, and a task nobody waited for withawait task.valuenever runs. - ✗Expecting
inside end: readingTask { }as a call that runs its braces at once. The task runs separately, and the lines below it do not wait for it.
Follow-up questions
- →How do you change the program so that it prints
insideas well? - →Why does the same task run in an app that keeps working after this code?
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?