Skip to content

Swift concurrency

Swift concurrency interview questions: what senior loops score

The interview map for explaining correctness over time, across tasks, actors and suspension points, with the follow-up that comes after each first answer.

Share Your Screen (book, $299)

Swift concurrency: what matters most

A senior Swift concurrency answer should explain ownership, lifetime, cancellation, isolation and what may change across every await. Async and await describe suspension, not automatic background execution. Actors serialise access to isolated state, but reentrancy means an invariant can still become stale while an actor method is suspended. Cancellation is cooperative, so work must observe it, and cancelling a caller does not cancel a separate Task it is awaiting. MainActor is the boundary for UI state, not a place to hide expensive computation. Sendable asks whether a value can safely cross isolation domains. In an interview, connect each concept to production behaviour: stale search results, reused image cells, duplicated requests, background saves, UI updates and race-free caches. The strongest answer states the safe default, the nearest alternative and the condition that changes the decision.

The six questions to ask

When reading concurrent code, do not begin with syntax. Trace the work through time and identify the invariant it must protect. Every concurrency question in a senior loop is one of these six wearing a costume.

  • Who creates and owns the task?
  • What ends or cancels it?
  • Which mutable state does it touch?
  • What can change while it is suspended?
  • Which isolation boundary does data cross?
  • How does the UI remain truthful if work finishes late?

Cancellation is cooperative, and a shared Task is not your task

Cancelling a Swift task only marks it as cancelled; nothing stops on its own. A cancelled task keeps running until it reaches code that checks: a cancellation-aware call such as URLSession, Task.checkCancellation(), or Task.isCancelled inside a loop. Expensive synchronous work that never checks will finish the job after the user has left the screen.

The case most answers get backwards is the shared one. A caller awaits value on a separate Task, a token refresh that several callers share, and the caller is cancelled. The refresh keeps running, and the cancelled caller keeps waiting for it. An unstructured Task { } is its own root: it does not inherit the cancellation of the code that created it. Structured children, async let and task groups, do.

Swift
let refresh = Task { try await api.refreshToken() }

let caller = Task {
    // Cancelling caller does not cancel refresh.
    // caller waits here until refresh finishes.
    try await refresh.value
}

caller.cancel()

Whether that is a bug depends on what the work means. Shared work, a token refresh or an image several cells want, should survive one waiter leaving. Superseded work, results for a query the user has typed past, should be cancelled eagerly. If a waiter must stop early, it needs its own wait that cancellation can end, for example a continuation resumed from the onCancel of withTaskCancellationHandler. A Task.checkCancellation() after the await only stops the caller from using a result it already waited for. That extra machinery is the trade-off to say out loud.

Say it like this: cancellation is cooperative. Cancelling the caller marks the caller, not the shared task it awaits, so the refresh finishes and the caller gets its value. For shared work that is what I want. For superseded work I cancel the task itself.

The small example, what Task.value promises and the inverse problem are in You cancelled the caller. Why is the shared Task still running?

Do actors prevent race conditions?

They prevent data races on the state they isolate. They do not prevent race conditions. Isolation guards memory, not the logic around it. An actor runs one piece of its code at a time, but at every await it may start serving another caller, so a check made before the suspension can be false after it. That is reentrancy, and Swift 6 strict checking does not flag it, because nothing about it is a data race.

Swift
actor Wallet {
    private var balance: Int
    private let gateway: PaymentGateway

    init(balance: Int, gateway: PaymentGateway) {
        self.balance = balance
        self.gateway = gateway
    }

    func pay(_ amount: Int) async throws {
        guard balance >= amount else { throw PaymentError.insufficientFunds }
        try await gateway.charge(amount)   // other calls to pay run here
        balance -= amount                  // the guard above is a rumor now
    }
}

Two concurrent payments both pass the guard, both charge, and the balance goes negative. Re-checking after the await looks like the fix and is the trap: by then the charge has gone through, so failing the check aborts a payment that already happened. Reserve before the door and roll back on failure:

balance -= amount before try await gateway.charge(amount), and balance += amount in the catch before rethrowing. For caches, the same idea is to store the in-flight task before the first await, so every later caller awaits one piece of work instead of starting its own.

The rule to carry into review: inside an actor, any state you checked before an await is a rumor after it. Four versions of this bug, each approved in a real review, are in Swift concurrency bugs that pass code review.

Does @MainActor run everything on the main thread?

@MainActor on a class isolates its stored properties and methods to the main actor, unless a member is marked nonisolated. That is the right home for UI state and view-model state, and it is usually a better choice than a custom actor: a custom actor that nothing calls concurrently only adds cost, and every hop to it is a suspension the UI has to survive.

What @MainActor does not do is make async work run somewhere else. An async function isolated to the main actor runs its synchronous stretches on the main actor. Heavy mapping, JSON decoding, image decoding and database reads inside that view model block the UI lane just as they would without the annotation. Since Swift 6.2 a target can default to main-actor isolation, and @concurrent marks the function that should leave it, which makes the boundary explicit instead of accidental.

The follow-up to expect: would you put a cache on @MainActor? No. A cache is shared by work that is not UI, so it belongs behind its own isolation, with the main actor as a client. More on where the boundary goes in A modern Swift concurrency interview map.

Is Sendable a thread-safety marker?

Sendable says a value can cross an isolation boundary without creating a data race. It is a guarantee about data races only. Structs and enums made of Sendable parts can be Sendable, and non-public ones get it without writing it; actors always are. A mutable class is not, unless something you can name protects it.

@unchecked Sendable is a promise you have to be able to back up, not a way to quiet the compiler. When you see it, ask for the invariant that makes it true and who owns that invariant: immutable after initialisation, every access behind one lock, or a box that bridges a callback API through withTaskCancellationHandler, where the handle is shared between the operation and the cancellation closure. That last one is the justified kind. A class marked @unchecked because the warnings were noisy is debt with the warning removed.

Sendable also says nothing about reference counting. That is the subject of Swift 6 rejected the race. ARC stayed atomic.

Isolation, transfer and lifetime are three different questions

Isolation asks who may access this state, and from which concurrency domain: actors and global actors live there. Transfer asks whether a value can cross a boundary safely: Sendable lives there. Lifetime asks how the program keeps an object alive and eventually destroys it: ARC lives there. Most confused answers mix two of the three.

Lifetime has its own interview trap. A Task that captures self and awaits a slow request keeps self alive until the request completes, with no retain cycle anywhere. The view model's deinit does not run when the screen closes, and a for await loop over a stream can keep it alive indefinitely. [weak self] hides the symptom. The fix is to give the task an owner that cancels it.

The full model, with the ARC experiment behind it, is in An actor protects state. It does not own every retain and release.

Practice scenarios

Use search, image loading, pagination, authentication refresh and offline synchronisation. For each one, draw task ownership, cancellation and the final UI state, then answer the six questions above out loud.

Then explain how you would test ordering and cancellation deterministically. A test that waits on Task.sleep passes or fails by timing, not by logic. Inject the clock, gate the fake API so the test decides when each response arrives, and assert the invariant: fifty concurrent callers cause exactly one token refresh. Hold the fake's response until all fifty callers are waiting, or the test can pass by luck because the first refresh finishes before the second call starts.

The free Swift concurrency interview checklist covers the same ground as a one-page review list: cancellation, task ownership, actor reentrancy, MainActor boundaries, Sendable and flaky async tests.

Questions interviewers ask

Does cancelling the caller cancel a shared Task?
No. An unstructured Task is its own root and does not inherit the caller's cancellation. The cancelled caller keeps waiting on the shared task's value until it finishes. That is right for shared work such as a token refresh, and wrong for superseded work such as a stale search, which should be cancelled directly.
Do actors prevent race conditions?
They prevent data races on isolated state, not race conditions. At every await an actor can serve another caller, so a check made before the suspension can be false after it. Reserve or commit before the await, or store the in-flight task so later callers share it.
Does @MainActor run everything on the main thread?
It isolates the class's members to the main actor unless they are nonisolated. Async code on the main actor still runs its synchronous parts there, so heavy decoding or mapping blocks the UI unless it is moved off, for example with @concurrent since Swift 6.2.
Is Sendable a thread-safety marker?
It is a guarantee about data races when a value crosses an isolation boundary, nothing more. @unchecked Sendable is a promise you must be able to back up: name the invariant that makes the type safe, such as immutability or a single lock around every access.
Does actor isolation make ARC non-atomic?
No. Isolation changes what Swift can prove about access to state. It does not change how reference counting is performed. Isolation, transfer and lifetime are separate questions, answered by actors, Sendable and ARC respectively.