In short
- Actor isolation prevents data races, not race conditions: any state you read before an await may have changed by the time the await returns.
- The fix for duplicate work inside an actor is to store the in-flight Task, not a flag, so later callers await the same result.
- @MainActor guarantees that synchronous code runs on the main actor; it does not make a method atomic across its suspension points.
- Cancellation in Swift is cooperative: cancelling a parent sets a flag, and a loop that never checks it runs to the end.
- A checked continuation must be resumed exactly once on every path; twice is a crash, never is a caller suspended forever.
Swift concurrency interview questions are not hard to answer. They are hard to defend. Most senior iOS candidates I interview can define an actor, explain @MainActor and name Sendable. That gets them into the room. The score comes from the next question, the one that takes their correct answer and adds a suspension point, a second caller or a cancelled parent.
I have run more than 500 technical interviews from the hiring side, and the pattern in concurrency rounds is stable. The first answer is the entry fee. The follow-up is where I write the score. Below are the six questions I see most often in senior and staff iOS loops, the answer that loses the point, the answer that keeps it, and the follow-up that separates the two. The code compiles under Swift 6 with complete checking.
Why interviewers ask Swift concurrency interview questions
Concurrency is the fastest way to find out whether a candidate has shipped async code under load or only read about it. The vocabulary is easy to memorize. The failure modes are not, because they only show up when two things happen at once. An interviewer does not need a whiteboard system design to see that. One actor and one await are enough.
So the questions are small and the follow-ups are sharp. The grading is about whether you can reason about interleaving out loud, name what the compiler proves and what it does not, and change your design when the constraint changes. If you want the general shape of how that grading works, the follow-up is the interview covers it across topics.
Actor reentrancy: the question that ends most rounds
Actor reentrancy means that while an actor method is suspended at an await, the actor is free to run other calls, so the method can resume into state that changed while it was waiting. I usually ask it with code on the screen.
actor ImageCache {
private var cache: [URL: Data] = [:]
func image(for url: URL) async throws -> Data {
if let cached = cache[url] { return cached }
let (data, _) = try await URLSession.shared.data(from: url)
cache[url] = data
return data
}
}Asked in the loopTen cells ask this cache for the same URL at the same moment. How many network requests go out?
One. It's an actor, so calls are serialized. The first call fills the cache and the other nine hit it.
Up to ten. The actor serializes access to cache, but each call suspends at the await on the network. While the first call is suspended, the actor runs the next call, which also sees a miss. Actors give you mutual exclusion between suspension points, not across them. The fix is to cache the in-flight work, not just the result.
The weak answer is not a knowledge gap. It is a model gap: the candidate thinks of an actor as a lock held for the whole method.
Then I ask for the fix. The answer I hear most often is a Set<URL> of loading URLs. That stops the duplicate request, but the nine other callers now have nothing to wait on. They either return nil or poll. The strong fix stores the Task itself, so every later caller awaits the same work.
actor ImageCache {
private enum Entry {
case inFlight(Task<Data, Error>)
case ready(Data)
}
private var entries: [URL: Entry] = [:]
func image(for url: URL) async throws -> Data {
switch entries[url] {
case .ready(let data):
return data
case .inFlight(let task):
return try await task.value
case nil:
break
}
let task = Task {
let (data, _) = try await URLSession.shared.data(from: url)
return data
}
entries[url] = .inFlight(task)
do {
let data = try await task.value
entries[url] = .ready(data)
return data
} catch {
entries[url] = nil
throw error
}
}
}The important line is entries[url] = .inFlight(task). It runs synchronously, before the first suspension, so no other call can slip in between the miss and the insert. That is the whole trick: do the check and the state change with no await between them.
The follow-up
"The first cell scrolls off screen and its task is cancelled. What happens to the download?" Nothing. The shared work is an unstructured Task, so it does not inherit the caller's cancellation. The first caller stops waiting only when its own await returns; the download keeps going for the other nine. A staff answer names the trade-off: you want the shared work to outlive any one caller, and if you want it cancelled when the last caller leaves, you need a waiter count and an explicit task.cancel(). Candidates who get there unprompted are showing me they have debugged this, not read about it.
What @MainActor guarantees and what it does not
@MainActor is a global actor that isolates declarations to the main actor, whose executor is the main thread. Synchronous code on a @MainActor type always runs there, and the compiler forces callers from elsewhere to await. That much every senior candidate knows.
@MainActor
final class SearchModel {
private(set) var results: [String] = []
private var latestQuery = ""
func search(_ query: String, api: some SearchAPI) async {
latestQuery = query
let found = await api.results(for: query)
guard query == latestQuery else { return }
results = found
}
}Asked in the loopWhy is the guard after the await there? The class is @MainActor.
It's defensive. Since everything runs on the main thread, there's no real race, so the guard is optional.
Without it the UI can show stale results. The user types "sw", then "swift". Both calls suspend on the network. If the "sw" response arrives last, it overwrites the newer results. Everything ran on the main thread and there was no data race, but there was a race condition. @MainActor is reentrant like any actor, so state read before an await has to be revalidated after it.
The follow-up goes the other direction. "api.results(for:) is a nonisolated async method. Where does its body run?" Under the rules most codebases compile with today (SE-0338), a nonisolated async function runs off the main actor, on the global concurrent executor, even when a @MainActor method calls it. A module that enables Swift 6.2's NonisolatedNonsendingByDefault feature changes that: nonisolated async functions then run on the caller's actor unless marked @concurrent. A candidate who knows the default changed, and checks which mode the project uses before answering, reads as someone who has migrated a real codebase.
Sendable and Swift 6 strict concurrency checking
`Sendable` is a marker protocol for types whose values can cross an isolation boundary without creating a data race. In the Swift 6 language mode the compiler enforces it as errors, not warnings. I ask candidates what the compiler actually rejects, because the memorized answer is usually wrong in both directions.
final class Counter {
var value = 0
}
func run() {
let counter = Counter()
Task.detached {
counter.value += 1
}
counter.value += 1 // error in Swift 6
}Asked in the loopCounter is not Sendable. Which line does Swift 6 reject, and would it still reject the code if you deleted the last line?
It rejects capturing a non-Sendable class in a detached task. Deleting the last line doesn't matter; you need to make Counter Sendable or an actor.
It rejects sending counter into the detached task because the caller keeps using it afterward; the diagnostic points at the send and notes the later access. Delete the last line and it compiles. Since Swift 6, region-based isolation lets you transfer a non-Sendable value into another isolation domain as long as the original side never touches it again. Sendable is required for values that are shared, not for values that are handed over.
Then I ask about @unchecked Sendable. The weak answer treats it as the way to make warnings go away. That is the smell. @unchecked tells the compiler to trust you, which means every future edit to that type has to be reviewed by a human who remembers the invariant. When I see it in a candidate's design I ask what protects the state. If the answer is "nothing, it's only used from one place", that is a no hire signal at senior level.
The strong answer shows the alternative first. If the deployment target allows it (iOS 18 and later), Mutex from the Synchronization module makes the type checked Sendable with no escape hatch:
import Synchronization
final class TokenStore: Sendable {
private let token = Mutex<String?>(nil)
func set(_ value: String?) {
token.withLock { $0 = value }
}
func current() -> String? {
token.withLock { $0 }
}
}Below iOS 18, a lock plus @unchecked Sendable is legitimate, provided every stored property is either immutable or guarded by that lock, and the conformance carries a comment saying so. The follow-up I use: "Why not make it an actor?" A good answer says an actor forces every read to be await, which spreads async through synchronous call sites that only need a token string. Picking the lock is a design decision, not a shortcut, and you should be able to say why.
Task cancellation: why the parent stops and the loop does not
Task cancellation in Swift is cooperative: cancel() sets a flag on the task and its child tasks, and nothing stops until code checks that flag or calls an API that does. The question I ask: "A view cancels its task on disappear. The task runs a CPU loop over 50,000 records. When does the loop stop?" The weak answer is "immediately". The correct answer is "when it finishes", unless the loop checks.
func checksum(_ chunks: [Data]) async throws -> UInt32 {
var sum: UInt32 = 0
for chunk in chunks {
try Task.checkCancellation()
for byte in chunk {
sum &+= UInt32(byte)
}
await Task.yield()
}
return sum
}Task.checkCancellation() throws CancellationError if the current task is cancelled. Task.isCancelled lets you return a partial result instead of throwing. Task.yield() gives other work a chance to run on a cooperative pool that has only as many threads as cores. Awaiting a cancellation-aware API like URLSession.data(from:) or Task.sleep also observes the flag, which is why network code often appears to cancel correctly and CPU code does not.
Asked in the loopYou wrap a callback-based SDK in an async function. How do you make it respond to cancellation?
Check Task.isCancelled before calling the SDK.
Checking before the call only catches cancellation that already happened. Once the SDK is running, my code is suspended and cannot check anything. I use withTaskCancellationHandler, whose onCancel closure runs as soon as the task is cancelled, and forward that to the SDK's own cancel method. The handler can run concurrently with the operation, on any thread, and it runs immediately if the task was already cancelled, so whatever it touches must be Sendable and safe to call at any moment.
The follow-up is about ordering. "What if onCancel fires before the SDK has returned its request handle?" A candidate who has written this for real knows the answer: store a cancelled flag in the shared token so that a request started after cancellation is refused. You will see that token in the bridging code below.
Structured vs unstructured tasks: async let, task groups, Task and Task.detached
Structured concurrency means child tasks are scoped to the function that created them: they cannot outlive it, they inherit its priority and task-local values, and cancelling the parent cancels them. async let and task groups are structured. Task {} and Task.detached are not.
func loadScreen(id: String, api: some ScreenAPI) async throws -> Screen {
async let profile = api.profile(id)
async let feed = api.feed(id)
return try await Screen(profile: profile, feed: feed)
}Asked in the loopapi.profile throws. What happens to the feed request?
It finishes in the background and the result is discarded.
The error propagates out of the function, and before the scope exits Swift implicitly cancels and awaits the feed child. It does not leak and it does not outlive loadScreen. Whether it stops early depends on whether api.feed observes cancellation. The same rule makes an unawaited async let a quiet cost: it is cancelled and awaited at scope exit, not skipped.
For a dynamic number of children, use a task group. The follow-up there is almost always "there are 400 URLs". A group that adds 400 child tasks at once will start 400 requests. The strong answer adds a fixed number of children first and adds the next one each time a result comes back, so the width stays bounded. The version below is the unbounded one most candidates write first.
func thumbnails(for urls: [URL]) async throws -> [URL: Data] {
try await withThrowingTaskGroup(of: (URL, Data).self) { group in
for url in urls {
group.addTask {
let (data, _) = try await URLSession.shared.data(from: url)
return (url, data)
}
}
var result: [URL: Data] = [:]
for try await (url, data) in group {
result[url] = data
}
return result
}
}Task versus Task.detached
Task {} inherits the priority, the task-local values and the actor context of the place it is created (for an actor instance, when the closure captures self). Task.detached inherits none of them. Neither inherits cancellation, and neither is cancelled when the creating scope ends. That last point is the one I probe: "You start a Task {} in viewDidAppear. Who cancels it?" If the answer is "it's cancelled when the view controller deallocates", the candidate has confused it with SwiftUI's .task modifier, which does cancel on disappear. A plain Task runs until it finishes unless you keep the handle and cancel it yourself.
Task-local values are the quiet part of this question. A request ID stored in a @TaskLocal flows into async let children, group children and Task {}, but not into Task.detached. Candidates who reach for detached to "get off the main actor" often lose their tracing context without noticing. On current Swift, a nonisolated async function is the cleaner way to leave the main actor while keeping structure.
Bridging callbacks with withCheckedThrowingContinuation
A continuation is the handle to a suspended task that your callback code resumes with a value or an error. The contract is exactly once. A checked continuation traps if you resume it twice, and logs a warning if it is deallocated without ever being resumed, which leaves the awaiting task suspended forever.
func currentLocation(
using provider: LocationProvider
) async throws -> Location {
try await withCheckedThrowingContinuation { continuation in
provider.requestLocation { result in
continuation.resume(with: result)
}
}
}Asked in the loopThe SDK calls the completion once with a cached location and again with a fresh one. What happens?
The second value is ignored.
The second resume crashes a checked continuation. With withUnsafeThrowingContinuation it is undefined behavior instead, which is worse. I read the SDK's contract before bridging it. If it can call back more than once, a single-value continuation is the wrong shape: either I resume on the first call and drop the rest behind a guard, or I bridge it as an AsyncStream, which is designed for many values.
Delegate APIs raise the same issue from the other side. Store the continuation in a property, resume it, then set the property to nil in the same place, so a late delegate call finds nothing to resume.
The full senior answer combines this with cancellation. The continuation has no idea the task was cancelled, so you connect the two with a Sendable token that the legacy client checks, and the client promises to call completion exactly once, including on cancel.
func fetch(_ id: String, client: LegacyClient) async throws -> Data {
let token = CancelToken() // Sendable, guarded by a Mutex
return try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { continuation in
// client calls completion exactly once, also on cancel
client.fetch(id, token: token) { result in
continuation.resume(with: result)
}
}
} onCancel: {
token.cancel()
}
}The follow-up: "What if the client ignores the token?" Then cancellation does nothing and the caller waits for the full request. You can resume early with CancellationError from the handler, but then the completion must not resume again, so the token has to own the continuation and hand it out once. That is the level of care that separates a staff answer from a senior one. The distinction is worth knowing in general; senior or staff covers the other signals.
How to prepare for the follow-up
Reading the Swift Evolution proposals will give you correct first answers. It will not give you the follow-ups, because those come from interleaving, and interleaving is something you rehearse, not something you recall. For each question above, say your answer out loud, then ask yourself three things: what if a second caller arrives during the await, what if the parent is cancelled, and what if the callback fires twice. If your answer changes, the new version is the one to practice.
Senior iOS roles at large US tech companies commonly pay $300k to $400k in total compensation, and in my experience they are decided at the follow-up, not the definition. If you want the questions in the order loops ask them, with weak and strong answers and the follow-ups written out, that is what iOS Interview Training is. The free sample shows the format before you decide.
Questions readers ask
© 2026 Mike Salari, Interview Runtime. All rights reserved. Quoting a short excerpt with a link to this page is welcome; republishing the article, in full or in part, is not.
