Expert guidance on Swift concurrency using the Office Building mental model. Use when working with actors, isolation, Sendable, TaskGroups, or fixing concurrency warnings and data race issues.
Expert guidance on Swift concurrency using the Office Building mental model. Use when working with actors, isolation, Sendable, TaskGroups, or fixing concurrency warnings and data race issues.
Working with actors, isolation, Sendable, TaskGroups
Keywords: actor, isolation, Sendable, TaskGroup, nonisolated, async let
Fixing concurrency warnings or data race issues
Agent Behavior Contract (Follow These Rules)
Analyze the project/package file to find out which Swift language mode (Swift 5.x vs Swift 6) and which Xcode/Swift toolchain is used when advice depends on it.
Before proposing fixes, identify the isolation boundary: @MainActor, custom actor, actor instance isolation, or nonisolated.
Do not recommend @MainActor as a blanket fix. Justify why main-actor isolation is correct for the code.
Prefer structured concurrency (child tasks, task groups) over unstructured tasks. Use Task.detached only with a clear reason.
If recommending @preconcurrency, @unchecked Sendable, or nonisolated(unsafe), require:
a documented safety invariant
a follow-up ticket to remove or migrate it
For migration work, optimize for minimal blast radius (small, reviewable changes) and add verification steps.
Course references are for deeper learning only. Use them sparingly and only when they clearly help answer the developer's question.
Project Settings Discovery
Always confirm these before interpreting diagnostics or giving migration-sensitive guidance. Do not guess — if any are unknown, ask the developer.
Setting
SwiftPM (Package.swift)
Xcode (.pbxproj)
Language mode
.swiftLanguageMode(.v6) per-target inside swiftSettings (NOT package-level swiftLanguageVersions, which only advertises compatibility)
Match a Task's entry isolation to its synchronous prefix — everything from { to the first await. Whatever runs in that prefix executes on the inherited actor.
If the prefix needs @MainActor (touches UI state, mutates self.isLoading, etc.), keep the inherited start.
If the prefix has nothing main-actor-bound, prefer Task { @concurrent in ... } and hop back via MainActor.run { ... } only for the UI mutation.
A trivial non-main statement (e.g. print) followed by main-actor work is not a reason to switch to @concurrent — the cheap line rides along.
For delayed retries, timers, and backoff: separate the waiting from the UI mutation. The sleep usually belongs off-main even when the final state update belongs on-main.
// ❌ Called from @MainActor; fetchData() is nonisolated, so the task starts on main then hops away// (whether the hop happens depends on fetchData()'s declared isolation — nonisolated/@concurrent hop, @MainActor does not)Task {
await fetchData() // nonisolated async
}
// ✅ Start off the main actor, hop back only for UI workTask { @concurrentinlet data =tryawait fetchData()
awaitMainActor.run { self.items = data }
}
// ✅ Prefix DOES need main actor — keep inheritanceTask {
self.isLoading =true// needs @MainActor, before any awaitawait fetchData()
self.isLoading =false
}
TaskGroup for Dynamic Parallel Work
tryawait withThrowingTaskGroup(of: Void.self) { group in
group.addTask { avatar =tryawait downloadAvatar() }
group.addTask { bio =tryawait fetchBio() }
tryawait group.waitForAll()
}
Actors
actorBankAccount {
var balance: Double=0funcdeposit(_amount: Double) { balance += amount }
// No await needed - can access directly inside actornonisolatedfuncbankName() -> String { "Acme Bank" }
}
await account.deposit(100) // Must await from outsidelet name = account.bankName() // No await needed
Sendable Types
// Automatically Sendable - value typestructUser: Sendable {
let id: Intlet name: String
}
// Thread-safe class with internal synchronizationfinalclassThreadSafeCache: @unchecked Sendable {
privatelet lock =NSLock()
privatevar storage: [String: Data] = [:]
}
Common Mistakes
1. Thinking async = background
// WRONG: Still blocks main thread!@MainActorfuncslowFunction() async {
let result = expensiveCalculation() // Synchronous = blocking
}
// CORRECT: Use detached task for CPU-heavy workTask.detached(priority: .userInitiated) {
let result = expensiveCalculation()
awaitMainActor.run { updateUI(result) }
}
Production impact: Apps get rejected for "became unresponsive." See references/production-pitfalls.md section 2.
2. Creating too many actors
Most things can live on MainActor. Only create actors when you have shared mutable state that can't be on MainActor.
3. Using MainActor.run unnecessarily
// WRONGawaitMainActor.run { self.data = data }
// CORRECT - annotate the function@MainActorfuncloadData() async { self.data =await fetchData() }
4. Blocking the cooperative thread pool (violates runtime contract)
Never use DispatchSemaphore, DispatchGroup.wait(), or condition variables in async code.
Why: These primitives hide dependencies from the runtime. The cooperative thread pool has a contract that threads will always make forward progress. Blocking primitives violate this contract and can cause deadlock.
// ❌ DANGEROUS: Can deadlock the cooperative poollet semaphore =DispatchSemaphore(value: 0)
Task {
await doWork()
semaphore.signal()
}
semaphore.wait() // Thread blocked, runtime unaware// ✅ Use async/await insteadlet result =await doWork()
Debug tip: Set LIBDISPATCH_COOPERATIVE_POOL_STRICT=1 to catch blocking calls during development.
Not everything needs to cross boundaries. Ask if data actually moves between isolation domains.
7. Not batching MainActor hops
The main thread is separate from the cooperative thread pool. Each hop to/from MainActor requires a full context switch.
// ❌ Multiple context switchesfor item in items {
let processed =await processItem(item)
awaitMainActor.run { displayItem(processed) } // Context switch per item
}
// ✅ Single context switchlet processed =await processAllItems(items)
awaitMainActor.run {
for item in processed { displayItem(item) }
}
8. Async for loops silently losing data
Using try? or empty catch {} in async loops swallows failures. Users lose data with zero indication. Acceptable for fire-and-forget (cache warming, analytics), dangerous for uploads/sync/migration. See references/production-pitfalls.md section 1.
9. Ignoring Task cancellation in long-running loops
for await under .task modifier is safe (structured concurrency propagates cancellation). But for await or while loops in stored Task { } properties need explicit Task.isCancelled checks. See references/production-pitfalls.md section 3.
@preconcurrency and nonisolated(unsafe) hide real data races. Mixing DispatchQueue with async/await creates confusing execution contexts. Always document safety invariants and plan removal. See references/production-pitfalls.md section 4.
11. Task inside onAppear instead of .task modifier
Task { } in .onAppear is unstructured: not cancelled on disappear, fires on every re-appear. Use .task { } for async work tied to view lifecycle, .onAppear for sync-only setup. See references/production-pitfalls.md section 5.
Quick Reference
Keyword
Purpose
async
Function can pause
await
Pause here until done
Task { }
Start async work, inherits context
Task.detached { }
Start async work, no context
@MainActor
Runs on main thread
actor
Type with isolated mutable state
nonisolated
Opts out of actor isolation
Sendable
Safe to pass between isolation domains
@unchecked Sendable
Trust me, it's thread-safe
async let
Start parallel work
TaskGroup
Dynamic parallel work
When the Compiler Complains
Trace the isolation: Where did it come from? Where is code trying to run? What data crosses a boundary?
The answer is usually obvious once you ask the right question.
Reference Files
Load these files as needed for specific topics:
Foundational Concepts
async-await-basics.md - async/await syntax, execution order, async let, URLSession patterns
Run tests, especially concurrency-sensitive ones (see references/testing.md).
If performance-related, verify with Instruments (see references/performance.md).
If lifetime-related, verify deinit/cancellation behavior (see references/memory-management.md).
Migration Validation Loop
For Swift 6 / strict-concurrency migration, apply this cycle for each change:
Build — surface new diagnostics
Fix — address one category at a time (e.g., all Sendable issues first, then all isolation issues)
Rebuild — confirm the fix compiles cleanly before moving on
Test — run the suite to catch regressions
Only then proceed to the next file/module
Never batch unrelated fixes into one change. If a fix introduces new warnings, resolve them before continuing. Keep commits small and reviewable. See references/migration.md for detailed migration steps.