Swift Modern Concurrency — Complete Interview Preparation Guide

Audience: Senior iOS Engineers preparing for Apple-level interviews
Scope: Swift 5.5+ Structured Concurrency, async/await, actors, and the full runtime model Depth: Internals, gotchas, Apple interview patterns, and production tradeoffs


Table of Contents

  1. [The Problem With Traditional Concurrency](#1-the-problem-with- traditional-concurrency)
  2. async/await Fundamentals
  3. [Structured Concurrency & Task Trees](#3-structured-concurrency--task- trees)
  4. Task Groups
  5. Unstructured & Detached Tasks
  6. Actors
  7. Global Actors & @MainActor
  8. Sendable & Data Isolation
  9. AsyncSequence & AsyncStream
  10. [Continuations — Bridging Legacy Code](#10-continuations--bridging- legacy-code)
  11. Task Cancellation
  12. Task Local Values
  13. Clocks & Time
  14. The Swift Concurrency Runtime
  15. Interop with GCD & Objective-C
  16. Testing Concurrent Code
  17. Common Pitfalls & Anti-Patterns
  18. [Apple Interview Patterns & Scenarios](#18-apple-interview-patterns-- scenarios)

1. The Problem With Traditional Concurrency

Before Swift Modern Concurrency (introduced in Swift 5.5, WWDC 2021), iOS developers relied on Grand Central Dispatch (GCD), OperationQueue, callbacks, and delegate patterns. Each approach has fundamental problems that Swift concurrency was specifically designed to solve.

1.1 Callback Hell and Pyramid of Doom

// Traditional callback-based code
func fetchUserProfile(id: String, completion: @escaping (Result<Profile, Error>) -> Void) {
    fetchUser(id: id) { result in
        switch result {
        case .success(let user):
            fetchAvatar(url: user.avatarURL) { avatarResult in
                switch avatarResult {
                case .success(let image):
                    fetchPreferences(userId: user.id) { prefResult in
                        // Deeply nested, hard to reason about, error
                        // handling duplicated
                        switch prefResult {
                        case .success(let prefs):
                            completion(.success(Profile(user: user, avatar:
                                image, prefs: prefs)))
                        case .failure(let error):
                            completion(.failure(error)) // Easy to forget
                        }
                    }
                case .failure(let error):
                    completion(.failure(error))
                }
            }
        case .failure(let error):
            completion(.failure(error))
        }
    }
}

Problems: Error propagation is duplicated and fragile. Forgetting to call completion is a silent bug. Execution order is non-obvious. No compiler enforcement.

1.2 Thread Safety Problems

GCD-based code has no compile-time guarantees about thread safety. You must manually ensure shared mutable state is only accessed from the correct queue.

// Classic GCD race condition — no compile-time warning
class Cache {
    private var storage: [String: Data] = [:]
    
    func set(_ data: Data, for key: String) {
        storage[key] = data // RACE: Multiple threads can write
            simultaneously
    }
}

1.3 What Swift Concurrency Solves

Problem GCD/Callbacks Swift Concurrency
Callback hell ❌ Pyramid of doom ✅ Linear, sequential-looking code
Forget completion ❌ Silent bug ✅ Compiler error if async fn returns
Thread safety ❌ Runtime crashes ✅ Actor isolation at compile time
Cancellation ❌ Ad hoc, manual ✅ Structured, propagates via task tree
Priority propagation ❌ Manual ✅ Automatic through task hierarchy
Thread explosion ❌ Possible ✅ Cooperative thread pool prevents it

2. async/await Fundamentals

2.1 What async Means

Marking a function async tells the compiler that the function may suspend — it can pause its execution on the current thread, yield that thread to other work, and later resume (possibly on a different thread).

func fetchData(from url: URL) async throws -> Data {
    let (data, response) = try await URLSession.shared.data(from: url)
    guard let httpResponse = response as? HTTPURLResponse,
          httpResponse.statusCode == 200 else {
        throw URLError(.badServerResponse)
    }
    return data
}

Key insight: async does NOT mean "runs on a background thread." It means the function can be suspended. Where it runs depends on the executor context.

2.2 Suspension Points

await is a potential suspension point. The function may or may not actually suspend — it depends on whether the awaited operation needs to do async work. The compiler requires you to mark every suspension point with await explicitly, making them visible.

func process() async {
    let value = await computeValue()  // May suspend here
    // Execution resumes here, possibly on a different thread
    print(value)
}

Critical interview point: After any await, you must not assume you're on the same thread as before. However, you ARE guaranteed to be in the same actor context (if the function is actor- isolated).

2.3 async let — Parallel Binding

async let launches a child task immediately and evaluates the binding lazily when you await it. This is the primary mechanism for parallel work within structured concurrency.

func loadDashboard() async throws -> Dashboard {
    // These three fetches start CONCURRENTLY
    async let user = fetchUser()
    async let posts = fetchPosts()
    async let notifications = fetchNotifications()
    
    // This line awaits all three — total time = max(user, posts,
    // notifications)
    return try await Dashboard(user: user, posts: posts, notifications:
        notifications)
}

Compare with sequential:

func loadDashboardSequential() async throws -> Dashboard {
    let user = try await fetchUser()           // Wait for this...
    let posts = try await fetchPosts()         // Then this...
    let notifications = try await fetchNotifications() // Then this
    // Total time = user + posts + notifications
    return Dashboard(user: user, posts: posts, notifications: notifications)
}

Interview trap: With async let, if the await throws, ALL child tasks from async let are automatically cancelled. This is structured concurrency in action.

2.4 Calling Async Code from Sync Context

You cannot call async functions from synchronous contexts directly. The bridges are:

// 1. Task {} — creates an unstructured task from sync context
func viewDidLoad() {
    Task {
        let data = try await fetchData()
        await updateUI(with: data)
    }
}

// 2. Task.detached {} — detached from current context (rare)
Task.detached(priority: .background) {
    await expensiveBackgroundWork()
}

// 3. In tests: XCTest has async test support
func testFetch() async throws {
    let data = try await fetchData()
    XCTAssertNotNil(data)
}

3. Structured Concurrency & Task Trees

3.1 The Task Tree Concept

Swift concurrency introduces a task hierarchy. Every task has a parent, and this relationship creates a tree. This structure gives us:

Task (root)
├── async let fetchUser      (child)
├── async let fetchPosts     (child)
│   └── async let images     (grandchild)
└── async let fetchPrefs     (child)

3.2 Structured vs Unstructured

Feature Structured (async let, TaskGroup) Unstructured
Parent task Has one None (or the enclosing task)
Cancellation Inherits from parent Manual via stored Task handle
Priority Inherits from parent Inherits at creation, can specify
Lifetime Bounded by parent scope Independent
await required Yes — parent awaits children No — fire & forget

3.3 Why Structured Concurrency Matters

The key insight: structured concurrency makes concurrent code as predictable as sequential code with respect to lifetime and error handling. When a do block exits (normally or via thrown error), all child tasks it created are guaranteed to be cancelled and awaited.

func example() async throws {
    async let a = task1()
    async let b = task2()
    
    // If task1() throws, task2() is automatically cancelled
    // The compiler ensures both are awaited before this scope exits
    let result = try await (a, b)
}
// Guaranteed: when we reach here, both task1 and task2 have
// completed/cancelled

4. Task Groups

Task groups allow you to create a dynamic number of concurrent child tasks at runtime, unlike async let which requires knowing the tasks at compile time.

4.1 withTaskGroup

func fetchAllImages(urls: [URL]) async throws -> [UIImage] {
    try await withThrowingTaskGroup(of: UIImage.self) { group in
        for url in urls {
            group.addTask {
                try await downloadImage(from: url)
            }
        }
        
        var images: [UIImage] = []
        for try await image in group {
            images.append(image)
        }
        return images
    }
}

Order is NOT guaranteed. Results arrive in completion order, not submission order. If you need ordering, use (Int, UIImage) as the result type and carry the index.

4.2 Collecting Results With Index

func fetchOrderedImages(urls: [URL]) async throws -> [UIImage] {
    try await withThrowingTaskGroup(of: (Int, UIImage).self) { group in
        for (index, url) in urls.enumerated() {
            group.addTask {
                let image = try await downloadImage(from: url)
                return (index, image)
            }
        }
        
        var results: [(Int, UIImage)] = []
        for try await result in group {
            results.append(result)
        }
        return results
            .sorted { $0.0 < $1.0 }
            .map { $0.1 }
    }
}

4.3 Limiting Concurrency

Task groups by default add ALL tasks immediately, which could overwhelm a server. Limit concurrency with a sliding window pattern:

func fetchWithConcurrencyLimit(urls: [URL], maxConcurrent: Int) async
    throws -> [Data] {
    try await withThrowingTaskGroup(of: (Int, Data).self) { group in
        var results = [Data](repeating: Data(), count: urls.count)
        var index = 0
        
        // Seed initial batch
        while index < min(maxConcurrent, urls.count) {
            let i = index
            let url = urls[i]
            group.addTask { (i, try await URLSession.shared.data(from:
                url).0) }
            index += 1
        }
        
        // As each finishes, add next
        for try await (i, data) in group {
            results[i] = data
            if index < urls.count {
                let nextIndex = index
                let url = urls[nextIndex]
                group.addTask { (nextIndex, try await
                    URLSession.shared.data(from: url).0) }
                index += 1
            }
        }
        
        return results
    }
}

4.4 withDiscardingTaskGroup (Swift 5.9+)

When you don't need results from child tasks (fire-and-collect-errors only), use withDiscardingTaskGroup — it's more memory-efficient because it doesn't buffer results.

await withDiscardingTaskGroup { group in
    for item in items {
        group.addTask {
            await process(item) // no return value needed
        }
    }
}

4.5 TaskGroup Error Handling

With withThrowingTaskGroup: if any child task throws, the group cancels all remaining children and rethrows the first error. This is the key difference from non-throwing groups, which ignore individual errors.


5. Unstructured & Detached Tasks

5.1 Unstructured Tasks (Task {})

Created from a synchronous context or when you need a task that outlives its enclosing scope. It inherits the actor context and priority of where it's created, but has no structured parent.

class ViewModel: ObservableObject {
    @Published var data: [Item] = []
    
    func refresh() {
        // This Task inherits @MainActor isolation because ViewModel is on
        // MainActor
        Task {
            let items = try await fetchItems()
            self.data = items // Safe: we're still on MainActor
        }
    }
}

You can hold a reference to cancel it:

var loadingTask: Task<Void, Never>?

func startLoading() {
    loadingTask?.cancel()
    loadingTask = Task {
        await performLoad()
    }
}

func stopLoading() {
    loadingTask?.cancel()
    loadingTask = nil
}

5.2 Detached Tasks (Task.detached {})

A detached task inherits nothing from its creation context — not actor isolation, not priority, not task-local values (unless explicitly specified).

Task.detached(priority: .background) {
    // NOT on @MainActor even if created from MainActor context
    // No task-local values from the calling context
    await expensiveOperation()
}

When to use detached: Rarely. Use it when you explicitly don't want to inherit the calling context's actor isolation (e.g., to escape MainActor for CPU-intensive work).

5.3 Task Priorities

public enum TaskPriority: UInt8 {
    case high       // 25  — .userInitiated equivalent
    case medium     // 21  — default for Task {}
    case low        // 17  
    case background // 9   — .background equivalent
    
    // Aliases:
    // .userInitiated = .high
    // .utility = .low
}

Priority escalation: If a high-priority task is waiting on a result from a low-priority task, the runtime can promote the low-priority task's priority to avoid priority inversion.


6. Actors

6.1 The Core Problem Actors Solve

Actors provide a unit of concurrency-safe mutable state. The compiler statically guarantees that actor state is only accessed from within the actor's executor (its serial queue), eliminating data races.

actor BankAccount {
    private var balance: Decimal
    
    init(balance: Decimal) {
        self.balance = balance
    }
    
    func deposit(_ amount: Decimal) {
        balance += amount  // Safe — actor isolation guarantees serial
            access
    }
    
    func withdraw(_ amount: Decimal) throws {
        guard balance >= amount else { throw BankError.insufficient }
        balance -= amount
    }
    
    var currentBalance: Decimal { balance }
}

// Usage
let account = BankAccount(balance: 1000)
await account.deposit(500)          // Must await from outside
let balance = await account.currentBalance

6.2 Actor Reentrancy

This is the most critical actor concept for Apple interviews. Actors are reentrant by default — an actor can start processing a new message while waiting at a suspension point within the current message.

actor ImageCache {
    private var cache: [URL: UIImage] = [:]
    private var inFlight: [URL: Task<UIImage, Error>] = [:]
    
    func image(for url: URL) async throws -> UIImage {
        if let cached = cache[url] {
            return cached  // Fast path — no suspension
        }
        
        // REENTRANCY TRAP:
        // When we await here, another call to image(for: url) can enter the
        // actor
        // That second call will also not find it in cache and start ANOTHER
        // download
        let image = try await downloadImage(from: url)  // ← suspension
            point
        
        // By the time we resume, someone else may have already cached it
        cache[url] = image
        return image
    }
}

The correct solution — use a "deduplicate in-flight requests" pattern:

actor ImageCache {
    private var cache: [URL: UIImage] = [:]
    private var inFlight: [URL: Task<UIImage, Error>] = [:]
    
    func image(for url: URL) async throws -> UIImage {
        if let cached = cache[url] { return cached }
        
        if let existingTask = inFlight[url] {
            return try await existingTask.value  // Join the in-flight task
        }
        
        let task = Task { try await downloadImage(from: url) }
        inFlight[url] = task
        
        do {
            let image = try await task.value
            cache[url] = image
            inFlight[url] = nil
            return image
        } catch {
            inFlight[url] = nil
            throw error
        }
    }
}

6.3 nonisolated

Use nonisolated to mark actor members that don't need actor isolation — they can be called without await and are safe because they don't access mutable state.

actor Logger {
    let name: String  // Stored let properties are implicitly nonisolated
    private var logs: [String] = []
    
    // Explicitly nonisolated — can be called synchronously from anywhere
    nonisolated func makePrefix() -> String {
        return "[\(name)]"  // OK: only accesses immutable `name`
    }
    
    // nonisolated cannot access mutable state
    // nonisolated func badMethod() { logs.append("") } // ❌ Compile error
}

// No await needed:
let prefix = logger.makePrefix()

6.4 Actor Inheritance

Actors cannot inherit from other actors (or from classes). They can conform to protocols.

// ❌ Not allowed
// actor Child: ParentActor { }

// ✅ Allowed
protocol Cacheable {
    func invalidate() async
}

actor ImageCache: Cacheable {
    func invalidate() async { /* ... */ }
}

6.5 Actors vs Classes with Locks

actor Class + NSLock/DispatchQueue
Compile-time safety ✅ Yes ❌ No
Priority inversion ✅ Handled by runtime ❌ Manual
Deadlock risk ✅ None (no blocking) ❌ Possible
Reentrancy ✅ Explicit via suspension points ⚠️ Depends on design
Interop with async code ✅ Native ⚠️ Complex bridging

7. Global Actors & @MainActor

7.1 What Are Global Actors?

A global actor is a singleton actor that provides a shared execution context for code that must run on a specific executor. The most important global actor is MainActor, which corresponds to the main thread.

@globalActor
actor DatabaseActor {
    static let shared = DatabaseActor()
}

@DatabaseActor
class DataStore {
    func save(_ item: Item) { /* guaranteed on DatabaseActor */ }
}

7.2 @MainActor In Depth

@MainActor isolates code to the main thread. You can apply it to:

// Entire class
@MainActor
class ViewModel: ObservableObject {
    @Published var items: [Item] = []
    
    func loadItems() async {
        // loadItems() starts on MainActor
        let fetched = await fetchItems()  // Suspension — may switch to pool
        // Resumes back on MainActor automatically
        items = fetched  // Safe: we're on MainActor
    }
}

// Individual method
class MixedClass {
    @MainActor
    func updateUI() {
        // Guaranteed main thread
    }
    
    func backgroundWork() async {
        // Not MainActor
        await updateUI()  // Must await to hop to MainActor
    }
}

// Closures
Task { @MainActor in
    // This closure body is isolated to MainActor
}

7.3 MainActor Reentrancy

@MainActor is itself an actor, so it's also reentrant. This is crucial to understand:

@MainActor
class Controller {
    var count = 0
    
    func increment() async {
        count += 1                        // Main thread
        await Task.yield()               // ← suspension point!
        // Another caller could have changed count here
        print("Count: \(count)")          // May not be count + 1 from above
    }
}

7.4 Propagating MainActor to SwiftUI

In SwiftUI, @StateObject, @ObservedObject ViewModels should be @MainActor to ensure UI updates are safe:

@MainActor
class ProfileViewModel: ObservableObject {
    @Published var user: User?
    @Published var isLoading = false
    
    func load(id: String) async {
        isLoading = true
        defer { isLoading = false }
        
        do {
            user = try await userService.fetchUser(id: id)
        } catch {
            // handle error
        }
    }
}

8. Sendable & Data Isolation

8.1 The Sendable Protocol

Sendable marks types that are safe to share across concurrency boundaries (across actors, across tasks). If a type is Sendable, its values can cross isolation boundaries without data races.

// Value types are generally Sendable
struct UserProfile: Sendable {
    let name: String      // Sendable
    let age: Int          // Sendable
}

// Classes must be manually verified
final class SafeCache: @unchecked Sendable {
    // We take responsibility for thread safety
    private let lock = NSLock()
    private var storage: [String: Data] = [:]
    
    func set(_ data: Data, key: String) {
        lock.withLock { storage[key] = data }
    }
}

8.2 Sendable Conformance Rules

// @Sendable closures
func performInBackground(_ work: @Sendable @escaping () async -> Void) {
    Task.detached {
        await work()
    }
}

// This won't compile if the closure captures non-Sendable types:
class NonSendable { var count = 0 }
let obj = NonSendable()
performInBackground {
    obj.count += 1  // ❌ Compile error: obj is not Sendable
}

8.3 Isolation Regions (Swift 6)

Swift 6 introduces strict concurrency checking. The compiler tracks isolation regions — groups of values that must be accessed from the same isolation context. Transferring a value between regions requires the value to be Sendable.

// Swift 6 strict checking
@MainActor
func updateUI(with data: [Item]) {
    // data must be Sendable to cross from background to MainActor
}

func fetchAndUpdate() async {
    let items = await fetchItems()     // On cooperative pool
    await updateUI(with: items)        // items crosses to MainActor — must
        be Sendable
}

8.4 Retroactive Conformance

Making third-party types Sendable when you can't modify them:

// In your own module
extension ThirdPartyModel: @retroactive Sendable {}

9. AsyncSequence & AsyncStream

9.1 AsyncSequence Protocol

AsyncSequence is the async equivalent of Sequence. It produces values asynchronously, one at a time. You consume it with for await in.

protocol AsyncSequence {
    associatedtype Element
    associatedtype AsyncIterator: AsyncIteratorProtocol
    func makeAsyncIterator() -> AsyncIterator
}

protocol AsyncIteratorProtocol {
    associatedtype Element
    mutating func next() async throws -> Element?
}

Usage:

func processLines(from url: URL) async throws {
    let (bytes, _) = try await URLSession.shared.bytes(from: url)
    
    for try await line in bytes.lines {
        process(line)
    }
}

9.2 AsyncStream — Building Your Own

AsyncStream is the primary way to create async sequences from callback- based or delegate-based APIs.

// Wrapping a delegate-based API
func locationStream(manager: CLLocationManager) -> AsyncStream<CLLocation> {
    AsyncStream { continuation in
        let delegate = LocationDelegate { location in
            continuation.yield(location)
        } onFinish: {
            continuation.finish()
        }
        manager.delegate = delegate
        manager.startUpdatingLocation()
        
        continuation.onTermination = { _ in
            manager.stopUpdatingLocation()
        }
    }
}

// Usage
for await location in locationStream(manager: locationManager) {
    updateMap(with: location)
}

9.3 AsyncThrowingStream

For streams that can fail:

func dataStream(from socket: WebSocket) -> AsyncThrowingStream<Data, Error>
    {
    AsyncThrowingStream { continuation in
        socket.onReceive { data in
            continuation.yield(data)
        }
        socket.onError { error in
            continuation.finish(throwing: error)
        }
        socket.onClose {
            continuation.finish()
        }
    }
}

9.4 AsyncStream Buffer Policies

// Unbounded — keeps all values (memory risk)
AsyncStream(Int.self, bufferingPolicy: .unbounded) { ... }

// Bounded — drops values when full
AsyncStream(Int.self, bufferingPolicy: .bufferingNewest(10)) { ... }
AsyncStream(Int.self, bufferingPolicy: .bufferingOldest(10)) { ... }

9.5 Combining AsyncSequences

// Filter
let filtered = stream.filter { $0.isValid }

// Map
let mapped = stream.map { transform($0) }

// First match
let first = await stream.first(where: { $0.isReady })

// Collect
let all = try await stream.reduce(into: []) { $0.append($1) }

9.6 Real Pattern: Multicasting AsyncStream

A common architecture pattern for sharing a single upstream async sequence with multiple consumers:

actor StreamMulticaster<Element: Sendable> {
    private var continuations: [UUID: AsyncStream<Element>.Continuation] =
        [:]
    
    func subscribe() -> AsyncStream<Element> {
        let id = UUID()
        return AsyncStream { continuation in
            continuations[id] = continuation
            continuation.onTermination = { [weak self] _ in
                Task { await self?.unsubscribe(id: id) }
            }
        }
    }
    
    func broadcast(_ element: Element) {
        for continuation in continuations.values {
            continuation.yield(element)
        }
    }
    
    private func unsubscribe(id: UUID) {
        continuations.removeValue(forKey: id)
    }
}

10. Continuations — Bridging Legacy Code

10.1 Why Continuations Exist

Continuations allow you to bridge callback-based or delegate-based APIs into the async/await world. They represent the "rest of the computation" — the mechanism that resumes an async function.

10.2 withCheckedContinuation

func fetchData(from url: URL) async throws -> Data {
    try await withCheckedThrowingContinuation { continuation in
        URLSession.shared.dataTask(with: url) { data, _, error in
            if let error {
                continuation.resume(throwing: error)
            } else if let data {
                continuation.resume(returning: data)
            } else {
                continuation.resume(throwing: URLError(.unknown))
            }
        }.resume()
    }
}

The "checked" variants verify at runtime that you call resume exactly once:

10.3 withUnsafeContinuation

Use when performance is critical and you've verified correct usage:

// No runtime checking — faster but dangerous
func fetchFast() async -> Data {
    await withUnsafeContinuation { continuation in
        // Must guarantee exactly one resume call
        legacyFetch { data in
            continuation.resume(returning: data)
        }
    }
}

10.4 Wrapping Delegate Patterns

More complex: a delegate that produces multiple callbacks before finishing.

class LocationFetcher: NSObject, CLLocationManagerDelegate {
    private var continuation: CheckedContinuation<CLLocation, Error>?
    private let manager = CLLocationManager()
    
    func fetchOnce() async throws -> CLLocation {
        try await withCheckedThrowingContinuation { cont in
            self.continuation = cont
            manager.delegate = self
            manager.requestLocation()
        }
    }
    
    func locationManager(_ manager: CLLocationManager, 
                         didUpdateLocations locations: [CLLocation]) {
        guard let location = locations.first else { return }
        continuation?.resume(returning: location)
        continuation = nil  // Prevent double-resume
    }
    
    func locationManager(_ manager: CLLocationManager, 
                         didFailWithError error: Error) {
        continuation?.resume(throwing: error)
        continuation = nil
    }
}

11. Task Cancellation

11.1 How Cancellation Works

Cancellation in Swift is cooperative — you must check for it and respond. Calling task.cancel() sets a cancellation flag on the task; it does NOT forcefully stop execution.

func longOperation() async throws {
    for i in 0..<1000 {
        try Task.checkCancellation()  // Throws CancellationError if
            cancelled
        await processStep(i)
    }
}

// Alternative: check without throwing
func longOperationGraceful() async -> [Result] {
    var results: [Result] = []
    for i in 0..<1000 {
        if Task.isCancelled { break }  // Check without throwing
        results.append(await processStep(i))
    }
    return results
}

11.2 CancellationError

CancellationError is a special error. Many APIs in Swift (URLSession, Task.sleep, etc.) throw CancellationError when cancelled.

do {
    let data = try await URLSession.shared.data(from: url)
} catch is CancellationError {
    // Handle cancellation gracefully — don't show error UI
    return
} catch {
    // Handle actual error
    showError(error)
}

11.3 withTaskCancellationHandler

Run cleanup code when a task is cancelled, even if you're suspended waiting on something that doesn't check cancellation:

func operationWithCleanup() async throws -> Data {
    try await withTaskCancellationHandler {
        // Normal work
        try await someAsyncOperation()
    } onCancel: {
        // Called immediately when task is cancelled
        // Runs synchronously, on any thread
        // Must be Sendable closure
        cleanupResources()
    }
}

Critical note: onCancel runs on whichever thread triggered cancellation — it must be thread- safe and ideally non-blocking.

11.4 Propagation Through the Task Tree

let parentTask = Task {
    async let child1 = longTask1()
    async let child2 = longTask2()
    return try await (child1, child2)
}

parentTask.cancel()
// Both child1 and child2 are automatically cancelled

11.5 Detached Tasks and Cancellation

Detached tasks do NOT inherit cancellation from their creator. You must hold a reference and cancel manually:

var backgroundTask: Task<Void, Never>?

func startBackground() {
    backgroundTask = Task.detached {
        await heavyWork()
    }
}

func stopBackground() {
    backgroundTask?.cancel()
    backgroundTask = nil
}

12. Task Local Values

12.1 Concept

Task local values are like thread-local storage for tasks. They propagate through the task hierarchy — child tasks inherit the values set by parent tasks, but changes in a child don't affect the parent.

enum RequestContext {
    @TaskLocal static var traceID: String = "default"
    @TaskLocal static var userID: String? = nil
}

12.2 Usage

func handleRequest(traceID: String) async {
    await RequestContext.$traceID.withValue(traceID) {
        // All async code within this scope (and child tasks) sees traceID
        await fetchData()
        await processData()
    }
}

func fetchData() async {
    let id = RequestContext.traceID  // Reads current task's value
    print("Fetching with trace: \(id)")
    
    async let child = childOperation()
    // child also inherits traceID
}

12.3 Real-World Uses


13. Clocks & Time

13.1 The Clock Protocol (Swift 5.7+)

Swift 5.7 introduced the Clock protocol, making time-dependent code testable.

protocol Clock: Sendable {
    associatedtype Duration
    associatedtype Instant: InstantProtocol
    
    var now: Instant { get }
    func sleep(until deadline: Instant, tolerance: Duration?) async throws
}

Built-in clocks:

13.2 Task.sleep

// Old way (still works)
try await Task.sleep(nanoseconds: 1_000_000_000)  // 1 second

// Modern way
try await Task.sleep(for: .seconds(1))
try await Task.sleep(for: .milliseconds(500))
try await Task.sleep(until: .now + .seconds(5), clock: .continuous)

13.3 Making Code Testable with Clocks

struct RetryPolicy<C: Clock> {
    let clock: C
    
    func withRetry<T>(attempts: Int, operation: () async throws -> T) async
        throws -> T {
        for attempt in 0..<attempts {
            do {
                return try await operation()
            } catch {
                if attempt < attempts - 1 {
                    try await clock.sleep(for: .seconds(pow(2.0,
                        Double(attempt))))
                }
            }
        }
        throw RetryError.exhausted
    }
}

// In tests, inject a mock clock that you control

14. The Swift Concurrency Runtime

14.1 The Cooperative Thread Pool

Swift concurrency does NOT use one-thread-per-task. Instead, it uses a cooperative thread pool with a fixed number of threads (typically equal to CPU core count).

Why this matters: You should never block a cooperative thread pool thread. Blocking means Thread.sleep, semaphore waits, locks, synchronous I/O, etc.

// ❌ DANGEROUS — blocks a thread pool thread
func badWork() async {
    Thread.sleep(forTimeInterval: 5)  // Blocks the thread, starves the pool
}

// ✅ CORRECT — suspends the task, frees the thread
func goodWork() async {
    try await Task.sleep(for: .seconds(5))  // Thread is freed during sleep
}

14.2 Executors

An executor is the entity responsible for running a task. Swift has two kinds:

Serial executors: Run one task at a time (actors have serial executors by default)

Concurrent executors: Run multiple tasks concurrently (the default task pool)

// Custom executor (advanced — rarely needed)
actor MyActor {
    nonisolated var unownedExecutor: UnownedSerialExecutor {
        // Custom executor — e.g., pin to specific queue
        customQueue.asUnownedSerialExecutor()
    }
}

14.3 Actor Hop

When you call an actor-isolated function from a different actor (or from unstructured code), there's an actor hop — the runtime suspends the current context, enqueues the work on the target actor's executor, and resumes the current context when the actor call returns.

@MainActor
class ViewController {
    func buttonTapped() async {
        // We're on MainActor
        let result = await myActor.compute()  // Hop to myActor's executor
        // We're back on MainActor
        updateUI(with: result)
    }
}

Each hop is lightweight but not free. Minimize unnecessary hops in hot paths.

14.4 Stack vs Heap Allocation

Async functions that suspend allocate their local state on the heap (as part of the continuation object), unlike synchronous functions which use the stack. This is why you should avoid unnecessarily large async functions or unnecessary suspension points in performance-critical paths.

14.5 Priority Inversion and Promotion

The Swift runtime detects priority inversion: when a high-priority task is waiting on a low-priority task. It automatically promotes the low-priority task's effective priority to match, preventing the high-priority task from being unnecessarily delayed.


15. Interop with GCD & Objective-C

15.1 Calling GCD from Async Context

Never block an async context with GCD semaphores or synchronous waits. Instead, bridge with continuations:

// Bridging a dispatch queue operation
func runOnSerialQueue(_ block: @escaping () -> Void) async {
    await withCheckedContinuation { continuation in
        serialQueue.async {
            block()
            continuation.resume()
        }
    }
}

15.2 DispatchQueue as Executor

You can use a DispatchSerialQueue as a custom actor executor (Swift 5.9+):

actor LegacyWrapper {
    private let underlyingQueue = DispatchSerialQueue(label: "legacy")
    
    nonisolated var unownedExecutor: UnownedSerialExecutor {
        underlyingQueue.asUnownedSerialExecutor()
    }
}

15.3 Objective-C async Methods

Methods annotated with __attribute__((swift_async)) or that follow the completionHandler: convention are automatically available as async in Swift.

// ObjC: - (void)fetchWithCompletion:(void(^)(NSData *, NSError
// *))completion;
// Swift (automatically bridged):
let data = try await legacyAPI.fetch()

15.4 @objc and async

You cannot directly expose async functions to Objective-C. Wrap them in @objc methods that use callbacks:

@objc func fetchForObjC(completion: @escaping (Data?, Error?) -> Void) {
    Task {
        do {
            let data = try await fetchAsync()
            completion(data, nil)
        } catch {
            completion(nil, error)
        }
    }
}

16. Testing Concurrent Code

16.1 async Test Functions

XCTest supports async test functions natively:

final class ViewModelTests: XCTestCase {
    func testLoadItems() async throws {
        let sut = ViewModel(service: MockService())
        await sut.loadItems()
        XCTAssertEqual(sut.items.count, 3)
    }
}

16.2 Testing with Clocks

Inject a controllable clock to test time-dependent behavior without actual delays:

// Using the TestClock from Swift Clocks library (by Point-Free)
func testRetry() async throws {
    let clock = TestClock()
    let sut = RetryPolicy(clock: clock)
    
    var callCount = 0
    let task = Task {
        try await sut.withRetry(attempts: 3) {
            callCount += 1
            if callCount < 3 { throw TestError() }
            return "success"
        }
    }
    
    await clock.advance(by: .seconds(1))  // Trigger first retry
    await clock.advance(by: .seconds(2))  // Trigger second retry
    let result = try await task.value
    XCTAssertEqual(result, "success")
}

16.3 Testing Actors

func testBankAccountConcurrentDeposits() async {
    let account = BankAccount(balance: 0)
    
    // Concurrent deposits
    await withTaskGroup(of: Void.self) { group in
        for _ in 0..<100 {
            group.addTask {
                await account.deposit(10)
            }
        }
    }
    
    let balance = await account.currentBalance
    XCTAssertEqual(balance, 1000)  // Actor ensures correctness
}

16.4 Testing Cancellation

func testCancellation() async throws {
    let sut = LongRunningOperation()
    
    let task = Task {
        try await sut.perform()
    }
    
    task.cancel()
    
    do {
        _ = try await task.value
        XCTFail("Should have thrown")
    } catch is CancellationError {
        // Expected
    }
}

16.5 MainActor in Tests

Test methods do NOT run on the MainActor by default. Use @MainActor annotation on specific tests that need it:

@MainActor
func testUIUpdate() async {
    let viewModel = ViewModel()  // @MainActor class
    await viewModel.load()
    XCTAssertFalse(viewModel.isLoading)
}

17. Common Pitfalls & Anti-Patterns

17.1 Blocking the Cooperative Thread Pool

// ❌ NEVER do this — you'll starve the thread pool
func badActor() async {
    Thread.sleep(forTimeInterval: 10)       // Blocks a thread
    let sema = DispatchSemaphore(value: 0)
    sema.wait()                             // Blocks a thread
    _ = try? await withCheckedContinuation { c in
        // Not resuming — leaked continuation, blocked thread
    }
}

17.2 Data Races on Captured Values

var count = 0

// ❌ Race condition — multiple tasks modifying shared var
await withTaskGroup(of: Void.self) { group in
    for _ in 0..<100 {
        group.addTask {
            count += 1  // WARNING: mutation of captured var in concurrent
                context
        }
    }
}

// ✅ Use actors or aggregate in group
let total = await withTaskGroup(of: Int.self) { group in
    for _ in 0..<100 {
        group.addTask { return 1 }
    }
    return await group.reduce(0, +)
}

17.3 Forgetting That async let Tasks Start Immediately

// ❌ Task starts even if condition is false
func fetch(if condition: Bool) async throws -> Data {
    async let data = expensiveOperation()  // Already running!
    if condition {
        return try await data
    }
    return Data()  // expensiveOperation still ran, was then cancelled
}

// ✅ Conditional fetch
func fetch(if condition: Bool) async throws -> Data {
    if condition {
        return try await expensiveOperation()
    }
    return Data()
}

17.4 Actor Isolation and SwiftUI

// ❌ Missing @MainActor on ObservableObject
class BadViewModel: ObservableObject {
    @Published var items: [Item] = []
    
    func load() async {
        let fetched = await fetchItems()
        items = fetched  // WARNING: published property on non-main thread
    }
}

// ✅ Always @MainActor for ObservableObject
@MainActor
class GoodViewModel: ObservableObject {
    @Published var items: [Item] = []
    
    func load() async {
        let fetched = await fetchItems()
        items = fetched  // Safe
    }
}

17.5 Overcreating Tasks

// ❌ Creating a task per cell in a list — thousands of tasks
ForEach(items) { item in
    ItemView(item: item)
        .task {
            await item.loadDetails()  // OK if item count is small
        }
}

For large collections, use batching or task groups with concurrency limits.

17.6 Not Handling CancellationError Distinctly

// ❌ Showing error UI on cancellation (common bug)
do {
    data = try await fetch()
} catch {
    showError(error)  // Wrong: also shows error when user navigates away
}

// ✅ Distinguish cancellation
do {
    data = try await fetch()
} catch is CancellationError {
    // Silently ignore — user navigated away
} catch {
    showError(error)
}

18. Apple Interview Patterns & Scenarios

18.1 Design an Async Image Loader

A classic Apple question for the Creativity/Photos teams.

actor ImageLoader {
    private var cache: [URL: UIImage] = [:]
    private var inFlight: [URL: Task<UIImage, Error>] = [:]
    
    func load(url: URL) async throws -> UIImage {
        // Check cache first
        if let cached = cache[url] { return cached }
        
        // Deduplicate in-flight requests (actor reentrancy safe pattern)
        if let existing = inFlight[url] {
            return try await existing.value
        }
        
        let task = Task<UIImage, Error> {
            let (data, response) = try await URLSession.shared.data(from:
                url)
            guard let image = UIImage(data: data) else {
                throw ImageError.invalidData
            }
            return image
        }
        
        inFlight[url] = task
        
        do {
            let image = try await task.value
            cache[url] = image
            inFlight.removeValue(forKey: url)
            return image
        } catch {
            inFlight.removeValue(forKey: url)
            throw error
        }
    }
    
    func evict(url: URL) {
        cache.removeValue(forKey: url)
        inFlight[url]?.cancel()
        inFlight.removeValue(forKey: url)
    }
    
    func clearCache() {
        cache.removeAll()
        for task in inFlight.values { task.cancel() }
        inFlight.removeAll()
    }
}

Follow-up questions Apple might ask:

18.2 Design a Rate Limiter

actor RateLimiter {
    private let maxConcurrent: Int
    private var active: Int = 0
    private var waiting: [CheckedContinuation<Void, Never>] = []
    
    init(maxConcurrent: Int) {
        self.maxConcurrent = maxConcurrent
    }
    
    func acquire() async {
        if active < maxConcurrent {
            active += 1
            return
        }
        
        await withCheckedContinuation { continuation in
            waiting.append(continuation)
        }
    }
    
    func release() {
        if let next = waiting.first {
            waiting.removeFirst()
            next.resume()  // Next waiter gets the slot
        } else {
            active -= 1
        }
    }
}

// Usage
let limiter = RateLimiter(maxConcurrent: 5)

func limitedFetch(url: URL) async throws -> Data {
    await limiter.acquire()
    defer { Task { await limiter.release() } }
    return try await URLSession.shared.data(from: url).0
}

18.3 CloudKit + async/await Integration

Relevant for Apple's iCloud/Creativity apps teams:

actor CloudSyncManager {
    private let container: CKContainer
    private var pendingUploads: [CKRecord] = []
    private var syncTask: Task<Void, Error>?
    
    init(container: CKContainer = .default()) {
        self.container = container
    }
    
    func scheduleUpload(_ record: CKRecord) async {
        pendingUploads.append(record)
        await triggerSync()
    }
    
    private func triggerSync() async {
        guard syncTask == nil else { return }
        
        syncTask = Task {
            defer { syncTask = nil }
            try await performSync()
        }
    }
    
    private func performSync() async throws {
        while !pendingUploads.isEmpty {
            let batch = Array(pendingUploads.prefix(400))  //
                CKModifyRecordsOperation limit
            pendingUploads.removeFirst(min(400, pendingUploads.count))
            
            try await withCheckedThrowingContinuation { continuation in
                let operation = CKModifyRecordsOperation(recordsToSave:
                    batch)
                operation.modifyRecordsResultBlock = { result in
                    continuation.resume(with: result)
                }
                container.privateCloudDatabase.add(operation)
            }
        }
    }
}

18.4 Explain: What happens when you await on an actor?

Model answer:

When you call an await expression on an actor-isolated function from outside the actor:

  1. The calling task suspends — its execution context is saved as a continuation object on the heap
  2. The calling thread is freed back to the thread pool — no thread is blocked
  3. The message is enqueued on the target actor's serial queue (its executor)
  4. When the actor finishes its current task (if any), it dequeues and runs the new message
  5. Within the actor, the function runs to completion or until it hits its own suspension point
  6. When the actor function returns, the original continuation is re- enqueued on the appropriate executor (the caller's actor, or the thread pool)
  7. The calling task resumes with the return value

This entire mechanism requires no threads to be blocked at any point.

18.5 Explain: What is actor reentrancy and why does it matter?

Model answer:

Actor reentrancy means that while an actor is suspended at an await point, it can start processing another incoming message before the first one completes. This is necessary to avoid deadlock (if actors couldn't reenter, two actors awaiting each other would deadlock), and to maintain system liveness.

The danger: any state that was valid before a suspension point might be invalid after it. An interleaving message could have mutated actor state between your await calls. You must treat every await within an actor as a potential state invalidation point and re- validate your invariants after resuming.

The solution: either perform operations atomically (without suspension), or design your state machine to be correct in the face of interleaving.

18.6 Explain: Structured vs Unstructured Concurrency tradeoffs

Dimension Structured Unstructured
Cancellation Automatic, hierarchical Manual via stored Task handle
Lifetime Bounded to scope Independent, can escape
Error propagation Automatic to parent Requires explicit handling
Priority Inherited Specified at creation
Use case Most common, parallel work Background, UIKit callbacks

Apple's philosophy: Prefer structured concurrency. Use unstructured only when structure doesn't fit — e.g., responding to a button tap that initiates background work that should survive view lifecycle.

18.7 Common Apple Interview Question: Race Condition Identification

// Find the bug:
class DataManager {
    private var cache: [String: Data] = [:]
    
    func data(for key: String) async -> Data? {
        if let cached = cache[key] { return cached }
        
        let fetched = await fetchFromNetwork(key: key)
        cache[key] = fetched  // ← RACE: Multiple tasks can write
            simultaneously
        return fetched
    }
}

// Fix:
actor DataManager {
    private var cache: [String: Data] = [:]
    private var inFlight: [String: Task<Data?, Never>] = [:]
    
    func data(for key: String) async -> Data? {
        if let cached = cache[key] { return cached }
        
        if let task = inFlight[key] { return await task.value }
        
        let task = Task { await fetchFromNetwork(key: key) }
        inFlight[key] = task
        let result = await task.value
        if let result { cache[key] = result }
        inFlight[key] = nil
        return result
    }
}

18.8 Performance Question: How would you optimize concurrent image

downloads?

Key points to hit:

  1. Limit concurrency — use a sliding window task group pattern (discussed above)
  2. Deduplicate requests — same URL should share one in-flight task
  3. Prioritize visible content — use TaskPriority based on scroll position
  4. Cancel invisible content — cancel tasks for off-screen cells
  5. Two-level cache — memory (actor-protected dictionary) + disk (async file I/O)
  6. Prefetch — speculative loading with .background priority
// Cell reuse pattern — cancel previous task
final class ImageCell: UICollectionViewCell {
    private var loadTask: Task<Void, Never>?
    
    func configure(url: URL, loader: ImageLoader) {
        loadTask?.cancel()
        loadTask = Task { @MainActor in
            let image = try? await loader.load(url: url)
            guard !Task.isCancelled else { return }
            self.imageView.image = image
        }
    }
    
    override func prepareForReuse() {
        super.prepareForReuse()
        loadTask?.cancel()
        loadTask = nil
        imageView.image = nil
    }
}

Quick Reference: Cheat Sheet

When to Use What

Scenario Tool
Sequential async operations async/await
Known number of parallel tasks async let
Dynamic number of parallel tasks TaskGroup
Produce values over time AsyncStream
Bridge callback API withCheckedContinuation
Share mutable state safely actor
Ensure code runs on main thread @MainActor
Pass context through async chain @TaskLocal
Cancel a tree of tasks Store Task handle, call .cancel()
Background work from sync context Task { } or Task.detached { }

The Golden Rules

  1. Never block a cooperative thread pool thread — use Task.sleep, not Thread.sleep
  2. Every await is a potential reentrancy point — re-validate actor state after suspension
  3. Prefer structured concurrency — structured code is easier to reason about
  4. Prefer async let over Task groups when you know how many tasks you need at compile time
  5. Check Task.isCancelled / Task.checkCancellation() at long-running loop boundaries
  6. Distinguish CancellationError from real errors in catch blocks
  7. Sendable is the boundary contract — anything crossing an actor boundary must be Sendable
  8. @MainActor ObservableObjects — always annotate ViewModels to prevent publishing off main thread
  9. Actor reentrancy is intentional — design state around suspension points, not locks
  10. Detached tasks inherit nothing — use sparingly, only when you need to escape actor isolation

Swift Modern Concurrency reference for Apple Senior iOS Engineering interviews Covers Swift 5.5–5.9+ | WWDC 2021–2024 content