
Concurrency Patterns
- 3 installs
- 591 repo stars
- Updated July 24, 2026
- rshankras/claude-code-apple-skills
Provides Swift concurrency patterns for async/await, structured concurrency, actors, and Swift 6.2 features to prevent data races and migrate to Swift 6.
About
Guides Swift concurrency work including actors, Sendable, TaskGroup, and Swift 6.2 approachable-concurrency features while avoiding data-race pitfalls. A developer uses it to review or build async code, fix actor-isolation errors, or migrate to Swift 6.
- Covers async/await, actors, Sendable, and Swift 6 strict concurrency migration
- Bridges completion-handler APIs and addresses reentrancy/cancellation bugs
Concurrency Patterns by the numbers
- 3 all-time installs (skills.sh)
- +1 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #458 of 596 Debugging skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/rshankras/claude-code-apple-skills --skill concurrency-patternsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 3 |
|---|---|
| repo stars | ★ 591 |
| Last updated | July 24, 2026 |
| Repository | rshankras/claude-code-apple-skills ↗ |
What it does
Provides Swift concurrency patterns for async/await, structured concurrency, actors, and Swift 6.2 features to prevent data races and migrate to Swift 6.
Files
Swift Concurrency Patterns
Comprehensive guide for Swift concurrency covering async/await, structured concurrency, actors, and the Swift 6.2 "Approachable Concurrency" features. Focuses on patterns that prevent data races and common mistakes that cause crashes.
When This Skill Activates
- User has data race errors or actor isolation compiler errors
- User is migrating to Swift 6 strict concurrency
- User asks about async/await, actors, Sendable, TaskGroup, or MainActor
- User needs to bridge legacy completion-handler APIs to async/await
- User is working with Swift 6.2 features (@concurrent, isolated conformances)
- User has concurrency bugs (actor reentrancy, task cancellation, UI freezes)
Decision Tree
What concurrency problem are you solving?
│
├─ Swift 6 compiler errors / migration
│ └─ migration-guide.md
│
├─ Swift 6.2 new features (@concurrent, isolated conformances)
│ └─ swift62-concurrency.md
│
├─ Running work in parallel (async let, TaskGroup)
│ └─ structured-concurrency.md
│
├─ Thread safety for shared mutable state
│ └─ actors-and-isolation.md
│
├─ Bridging old APIs (delegates, callbacks) to async/await
│ └─ continuations-bridging.md
│
└─ General async/await patterns
└─ See macos/coding-best-practices/modern-concurrency.md for basicsQuick Reference
| Pattern | When to Use | Reference |
|---|---|---|
async let | Fixed number of parallel operations | structured-concurrency.md |
withTaskGroup | Dynamic number of parallel operations | structured-concurrency.md |
withDiscardingTaskGroup | Fire-and-forget parallel operations | structured-concurrency.md |
.task { } modifier | Load data when view appears | structured-concurrency.md |
.task(id:) modifier | Re-load when a value changes | structured-concurrency.md |
actor | Shared mutable state protection | actors-and-isolation.md |
@MainActor | UI-bound state and updates | actors-and-isolation.md |
@concurrent | Explicitly offload to background (6.2) | swift62-concurrency.md |
| Isolated conformances | @MainActor type conforming to protocol (6.2) | swift62-concurrency.md |
withCheckedContinuation | Bridge callback API to async | continuations-bridging.md |
AsyncStream | Bridge delegate/notification API to async sequence | continuations-bridging.md |
| Strict concurrency migration | Incremental Swift 6 adoption | migration-guide.md |
Process
1. Identify the Problem
Read the user's code or error messages to determine:
- Is this a compiler error (strict concurrency) or a runtime issue (data race, crash)?
- What Swift version and concurrency checking level are they using?
- Are they migrating existing code or writing new code?
2. Load Relevant Reference Files
Based on the problem, read from this directory:
swift62-concurrency.md— Swift 6.2 approachable concurrency featuresstructured-concurrency.md— async let, TaskGroup, .task modifier lifecycleactors-and-isolation.md— Actor patterns, reentrancy, @MainActor, Sendablecontinuations-bridging.md— withCheckedContinuation, AsyncStream, legacy bridgingmigration-guide.md— Incremental Swift 6 strict concurrency adoption
3. Review Checklist
- [ ] No blocking calls on
@MainActor(useawaitfor long operations) - [ ] Shared mutable state protected by an actor (not locks or DispatchQueue)
- [ ]
Sendableconformance correct for types crossing isolation boundaries - [ ] Task cancellation handled (check
Task.isCancelledorTask.checkCancellation()) - [ ] No unstructured
Task {}where structured concurrency (.task,TaskGroup) would work - [ ] Actor reentrancy considered at suspension points
- [ ]
withCheckedContinuationcalled exactly once (not zero, not twice) - [ ]
.task(id:)used instead of manualonChange+ cancel patterns
4. Cross-Reference
- For async/await basics and actor fundamentals, see
macos/coding-best-practices/modern-concurrency.md - For networking concurrency patterns, see
generators/networking-layer/networking-patterns.md - For SwiftData concurrency (@ModelActor), see
macos/swiftdata-architecture/repository-pattern.md - For auth token refresh with actors, see
generators/auth-flow/auth-patterns.md
References
- Swift Concurrency
- Migrating to Swift 6
- Apple doc:
/Users/ravishankar/Downloads/docs/Swift-Concurrency-Updates.md
Actors and Isolation
Actors serialize access to mutable state, preventing data races at compile time. This file covers actor patterns, reentrancy pitfalls, @MainActor, Sendable, and isolation boundaries.
Actor Basics
actor ImageCache {
private var cache: [URL: UIImage] = [:]
func image(for url: URL) -> UIImage? {
cache[url]
}
func store(_ image: UIImage, for url: URL) {
cache[url] = image
}
func clear() {
cache.removeAll()
}
}
// All access is through await:
let cache = ImageCache()
await cache.store(image, for: url)
let cached = await cache.image(for: url)nonisolated Members
Functions that don't access mutable state can be nonisolated to skip the await:
actor UserStore {
let id: UUID // let is implicitly nonisolated
private var users: [User] = []
nonisolated var storeIdentifier: String {
id.uuidString // Only accesses let property — safe
}
func addUser(_ user: User) { // Isolated — requires await
users.append(user)
}
}
let store = UserStore()
let id = store.storeIdentifier // No await needed
await store.addUser(user) // await requiredActor Reentrancy
Actors are reentrant: when an actor method hits an await suspension point, other callers can execute on the same actor. This means state can change across await points.
// ❌ Bug — actor reentrancy
actor BankAccount {
var balance: Int = 1000
func withdraw(amount: Int) async -> Bool {
guard balance >= amount else { return false }
// ⚠️ SUSPENSION POINT — another caller can run here
await logTransaction(amount)
// balance may have changed! Another withdraw could have run.
balance -= amount // Could go negative!
return true
}
}Fixing Reentrancy
Pattern 1: Read and write state before the suspension point
// ✅ Capture state before await
actor BankAccount {
var balance: Int = 1000
func withdraw(amount: Int) async -> Bool {
guard balance >= amount else { return false }
balance -= amount // Modify BEFORE the await
await logTransaction(amount) // Now it's safe
return true
}
}Pattern 2: Re-check state after the suspension point
// ✅ Re-validate after await
actor BankAccount {
var balance: Int = 1000
func withdraw(amount: Int) async -> Bool {
guard balance >= amount else { return false }
await logTransaction(amount)
// Re-check after suspension
guard balance >= amount else {
await reverseTransaction(amount)
return false
}
balance -= amount
return true
}
}Pattern 3: Use a synchronous method for the critical section
// ✅ No suspension point in the critical path
actor BankAccount {
var balance: Int = 1000
// Synchronous — no reentrancy possible
func withdraw(amount: Int) -> Bool {
guard balance >= amount else { return false }
balance -= amount
return true
}
// Logging is separate, after the state change
func withdrawAndLog(amount: Int) async -> Bool {
let success = withdraw(amount: amount)
if success {
await logTransaction(amount)
}
return success
}
}@MainActor
Marks code that must run on the main thread. Use for all UI-related state and updates.
On Types
@MainActor
@Observable
final class ItemListViewModel {
var items: [Item] = []
var isLoading = false
var errorMessage: String?
func loadItems() async {
isLoading = true
defer { isLoading = false }
do {
items = try await itemService.fetchAll()
} catch {
errorMessage = error.localizedDescription
}
}
}On Functions
class DataProcessor {
@MainActor
func updateUI(with result: ProcessingResult) {
// Guaranteed to run on main thread
}
nonisolated func processInBackground(data: Data) async -> ProcessingResult {
// Runs on any thread
...
}
}MainActor.run
For one-off main thread execution from a non-main context:
func processData() async {
let result = await heavyComputation()
await MainActor.run {
self.displayResult = result
}
}Prefer @MainActor on the type/method over MainActor.run — it's more declarative and catches errors at compile time.
Sendable
Types that can safely cross isolation boundaries (between actors, between threads).
Automatically Sendable
- Value types (structs, enums) with all Sendable properties
- Actors (always Sendable)
- Immutable classes (
final classwith onlyletproperties)
Explicit Sendable
// ✅ Value type with Sendable properties — automatically Sendable
struct UserProfile: Sendable {
let id: UUID
let name: String
let email: String
}
// ✅ Immutable final class
final class AppConfig: Sendable {
let apiURL: URL
let timeout: TimeInterval
}@unchecked Sendable
For types you guarantee are thread-safe through external mechanisms (locks, queues):
// ⚠️ Use only when you can prove thread safety
final class ThreadSafeCache<Key: Hashable & Sendable, Value: Sendable>: @unchecked Sendable {
private let lock = NSLock()
private var storage: [Key: Value] = [:]
func value(for key: Key) -> Value? {
lock.withLock { storage[key] }
}
func store(_ value: Value, for key: Key) {
lock.withLock { storage[key] = value }
}
}Prefer actors over @unchecked Sendable + locks. Use @unchecked Sendable only for performance-critical paths or bridging with legacy code.
@Sendable Closures
Closures that cross isolation boundaries must be @Sendable:
// TaskGroup requires @Sendable closures
await withTaskGroup(of: Void.self) { group in
group.addTask { @Sendable in
await processItem(item) // Closure must be @Sendable
}
}Most closure parameters in the concurrency APIs are already marked @Sendable. You typically only need the annotation when the compiler can't infer it.
Isolation Boundaries in Practice
Sending Values Between Actors
actor DataStore {
func save(_ item: Item) { ... }
}
@MainActor
class ViewModel {
let store = DataStore()
func saveItem(_ item: Item) async {
// item crosses from MainActor to DataStore actor
// item must be Sendable
await store.save(item)
}
}Non-Sendable Types at Boundaries
// ❌ NSMutableArray is not Sendable
actor Processor {
func process(_ array: NSMutableArray) { } // Compiler error
}
// ✅ Convert to Sendable type at the boundary
actor Processor {
func process(_ items: [Item]) { } // [Item] is Sendable if Item is
}Common Mistakes
Assuming Actors Are Like Locks
// ❌ Wrong mental model — actor is not a lock, it's a mailbox
actor Counter {
var count = 0
func incrementTwice() async {
count += 1
await someAsyncWork() // Other callers can run here!
count += 1 // count may have been modified
}
}
// ✅ Correct mental model — avoid await between state mutations
actor Counter {
var count = 0
func incrementTwice() { // Synchronous — no interleaving
count += 2
}
}@MainActor with Heavy Computation
// ❌ Blocks the main thread — UI freezes
@MainActor
func processImages(_ images: [Data]) -> [UIImage] {
images.map { heavyProcessing($0) } // Synchronous heavy work on main thread
}
// ✅ Offload to background, return to main actor
@MainActor
func processImages(_ images: [Data]) async -> [UIImage] {
await withTaskGroup(of: UIImage.self) { group in
for data in images {
group.addTask { @Sendable in
heavyProcessing(data) // Runs on concurrent thread pool
}
}
var results: [UIImage] = []
for await image in group {
results.append(image)
}
return results
}
}Using @unchecked Sendable to Silence Warnings
// ❌ Dangerous — silences compiler but doesn't fix the data race
class UnsafeCache: @unchecked Sendable {
var items: [String: Any] = [:] // No synchronization!
}
// ✅ Use an actor instead
actor SafeCache {
var items: [String: Any] = [:]
}Checklist
- [ ] Shared mutable state protected by actors (not locks or DispatchQueue)
- [ ] Actor reentrancy considered — state mutations before
awaitpoints - [ ]
@MainActoron all UI-bound types (ViewModels, UI state) - [ ]
Sendableconformance on types that cross isolation boundaries - [ ]
@unchecked Sendableused only with proven synchronization, never to silence warnings - [ ] No heavy synchronous work on
@MainActor - [ ]
nonisolatedon actor methods that don't access mutable state - [ ] Prefer
@MainActorannotation overMainActor.runfor clarity
Continuations and Bridging
Patterns for wrapping legacy callback-based, delegate-based, and notification-based APIs into async/await.
withCheckedContinuation
Wraps a single-callback API into an async function:
func currentLocation() async -> CLLocation {
await withCheckedContinuation { continuation in
locationManager.requestLocation { location in
continuation.resume(returning: location)
}
}
}Throwing Variant
func fetchImage(named name: String) async throws -> UIImage {
try await withCheckedThrowingContinuation { continuation in
imageLoader.load(name: name) { result in
switch result {
case .success(let image):
continuation.resume(returning: image)
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}The "Exactly Once" Rule
A continuation must be resumed exactly once. Resuming zero times leaks the task forever. Resuming twice crashes.
// ❌ Bug — continuation never resumed on timeout
func fetchWithTimeout() async throws -> Data {
try await withCheckedThrowingContinuation { continuation in
apiClient.fetch { data in
continuation.resume(returning: data)
}
// If timeout fires and callback never fires → leaked forever
}
}
// ❌ Bug — continuation resumed twice
func fetchData() async throws -> Data {
try await withCheckedThrowingContinuation { continuation in
apiClient.fetch { result in
switch result {
case .success(let data):
continuation.resume(returning: data)
case .failure(let error):
continuation.resume(throwing: error)
}
}
// What if the callback fires twice? Second resume → crash
}
}Fix: Guard with a flag or use the callback structure carefully:
// ✅ Ensure exactly one resume
func fetchData() async throws -> Data {
try await withCheckedThrowingContinuation { continuation in
var hasResumed = false
apiClient.fetch { result in
guard !hasResumed else { return }
hasResumed = true
switch result {
case .success(let data):
continuation.resume(returning: data)
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}Checked vs Unsafe Continuations
| Type | Debug Behavior | Release Behavior | Use When |
|---|---|---|---|
withCheckedContinuation | Traps on misuse (zero or double resume) | Traps on misuse | Default choice, always start here |
withUnsafeContinuation | No checking | No checking | Performance-critical hot paths only |
Always use withCheckedContinuation unless profiling shows the check is a bottleneck. The checked variant catches bugs that are extremely hard to debug otherwise.
Bridging Delegate APIs
Many Apple APIs use delegates (CLLocationManager, ASAuthorizationController, etc.). Bridge them with a continuation-holding helper:
class LocationFetcher: NSObject, CLLocationManagerDelegate {
private var continuation: CheckedContinuation<CLLocation, Error>?
private let manager = CLLocationManager()
override init() {
super.init()
manager.delegate = self
}
func requestLocation() async throws -> CLLocation {
try await withCheckedThrowingContinuation { continuation in
self.continuation = continuation
manager.requestLocation()
}
}
// MARK: - CLLocationManagerDelegate
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
continuation?.resume(returning: locations.last!)
continuation = nil
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
continuation?.resume(throwing: error)
continuation = nil
}
}
// Usage:
let fetcher = LocationFetcher()
let location = try await fetcher.requestLocation()Sign in with Apple
class AppleSignInCoordinator: NSObject, ASAuthorizationControllerDelegate {
private var continuation: CheckedContinuation<ASAuthorization, Error>?
func signIn() async throws -> ASAuthorization {
let provider = ASAuthorizationAppleIDProvider()
let request = provider.createRequest()
request.requestedScopes = [.fullName, .email]
let controller = ASAuthorizationController(authorizationRequests: [request])
controller.delegate = self
return try await withCheckedThrowingContinuation { continuation in
self.continuation = continuation
controller.performRequests()
}
}
func authorizationController(controller: ASAuthorizationController,
didCompleteWithAuthorization authorization: ASAuthorization) {
continuation?.resume(returning: authorization)
continuation = nil
}
func authorizationController(controller: ASAuthorizationController,
didCompleteWithError error: Error) {
continuation?.resume(throwing: error)
continuation = nil
}
}AsyncStream — Bridging Multi-Value Sources
For APIs that produce multiple values over time (delegates, NotificationCenter, KVO), use AsyncStream:
NotificationCenter
extension NotificationCenter {
func notifications(named name: Notification.Name) -> AsyncStream<Notification> {
AsyncStream { continuation in
let observer = addObserver(forName: name, object: nil, queue: nil) { notification in
continuation.yield(notification)
}
continuation.onTermination = { @Sendable _ in
NotificationCenter.default.removeObserver(observer)
}
}
}
}
// Usage:
for await notification in NotificationCenter.default.notifications(named: .NSManagedObjectContextDidSave) {
await handleSave(notification)
}CLLocationManager Continuous Updates
class LocationStream: NSObject, CLLocationManagerDelegate {
private var continuation: AsyncStream<CLLocation>.Continuation?
private let manager = CLLocationManager()
func locations() -> AsyncStream<CLLocation> {
AsyncStream { continuation in
self.continuation = continuation
continuation.onTermination = { @Sendable [weak self] _ in
self?.manager.stopUpdatingLocation()
}
manager.delegate = self
manager.startUpdatingLocation()
}
}
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
for location in locations {
continuation?.yield(location)
}
}
}
// Usage:
for await location in locationStream.locations() {
updateMap(with: location)
}AsyncStream with Buffering
AsyncStream(bufferingPolicy: .bufferingNewest(10)) { continuation in
// Keeps only the 10 most recent values if consumer is slow
eventSource.onEvent = { event in
continuation.yield(event)
}
}Buffering policies:
| Policy | Behavior |
|---|---|
.unbounded | Buffer everything (default, can grow unbounded) |
.bufferingNewest(N) | Keep only the N most recent values |
.bufferingOldest(N) | Keep only the N oldest values, drop new ones |
AsyncThrowingStream
For streams that can also produce errors:
func eventStream() -> AsyncThrowingStream<Event, Error> {
AsyncThrowingStream { continuation in
websocket.onMessage = { message in
continuation.yield(message)
}
websocket.onError = { error in
continuation.finish(throwing: error)
}
websocket.onClose = {
continuation.finish()
}
}
}
// Usage:
do {
for try await event in eventStream() {
handle(event)
}
} catch {
handleDisconnection(error)
}Common Mistakes
Forgetting onTermination Cleanup
// ❌ Resource leak — observer never removed
AsyncStream<Notification> { continuation in
let observer = NotificationCenter.default.addObserver(...)
// No cleanup when stream is cancelled
}
// ✅ Clean up on termination
AsyncStream<Notification> { continuation in
let observer = NotificationCenter.default.addObserver(...)
continuation.onTermination = { @Sendable _ in
NotificationCenter.default.removeObserver(observer)
}
}Capturing Self Strongly in Continuation
// ❌ Retain cycle — continuation holds self, self holds continuation
class StreamProvider {
var continuation: AsyncStream<Event>.Continuation?
func events() -> AsyncStream<Event> {
AsyncStream { continuation in
self.continuation = continuation // Strong reference cycle
}
}
}
// ✅ Use weak self in onTermination, nil out continuation
continuation.onTermination = { @Sendable [weak self] _ in
self?.continuation = nil
}Using AsyncStream for Single Values
// ❌ Overkill — AsyncStream for a one-shot result
func fetchUser() -> AsyncStream<User> {
AsyncStream { continuation in
api.getUser { user in
continuation.yield(user)
continuation.finish()
}
}
}
// ✅ Use withCheckedContinuation for single values
func fetchUser() async -> User {
await withCheckedContinuation { continuation in
api.getUser { user in
continuation.resume(returning: user)
}
}
}Checklist
- [ ] Using
withCheckedContinuation(not unsafe) unless profiling demands it - [ ] Continuation resumed exactly once in all code paths
- [ ]
continuation = nilafter resuming to prevent double-resume - [ ]
onTerminationhandler cleans up resources (observers, delegates, timers) - [ ]
AsyncStreamfor multi-value sources,withCheckedContinuationfor single-value - [ ] Appropriate buffering policy chosen for
AsyncStream - [ ] No strong reference cycles between continuation holder and stream provider
Swift 6 Strict Concurrency Migration
Step-by-step guide for incrementally adopting Swift 6 strict concurrency checking. Covers the migration path from Swift 5 to Swift 6.2.
Migration Strategy
Adopt strict concurrency incrementally, not all at once:
Swift 5 mode → Swift 6 language mode
(no checking) (strict checking)
Step 1: minimal Warnings for clearly unsafe patterns
Step 2: targeted Warnings for code interacting with concurrent features
Step 3: complete Warnings for ALL concurrency violations
Step 4: Swift 6 mode Warnings become errors
Step 5: Swift 6.2 Enable "infer main actor" for even cleaner codeStep 1: Enable Minimal Checking
Start with minimal concurrency warnings to find the most obvious issues.
Xcode: Build Settings → "Strict Concurrency Checking" → "Minimal"
Package.swift:
.target(
name: "MyTarget",
swiftSettings: [
.enableExperimentalFeature("StrictConcurrency=minimal")
]
)Fix issues like:
- Global/static
varwithout isolation - Non-Sendable types used in
@Sendableclosures
Step 2: Enable Targeted Checking
Xcode: Build Settings → "Strict Concurrency Checking" → "Targeted"
This warns about code that interacts with concurrency features (async functions, actors, Task).
Step 3: Enable Complete Checking
Xcode: Build Settings → "Strict Concurrency Checking" → "Complete"
Package.swift:
.target(
name: "MyTarget",
swiftSettings: [
.enableExperimentalFeature("StrictConcurrency=complete")
]
)All concurrency violations now produce warnings. Fix them all before proceeding.
Step 4: Switch to Swift 6 Language Mode
Now promote all warnings to errors:
Xcode: Build Settings → "Swift Language Version" → "6"
Package.swift:
// swift-tools-version: 6.0
let package = Package(
name: "MyPackage",
...
)Step 5: Adopt Swift 6.2 Features (Optional)
Enable "infer main actor by default" for app targets to eliminate remaining annotations:
Xcode: Build Settings → "Default Actor Isolation" → "MainActor"
See swift62-concurrency.md for details.
Common Migration Patterns
Global / Static Variables
// ❌ Swift 6 error: Static property is not concurrency-safe
class AppConfig {
static var shared = AppConfig()
var apiKey: String = ""
}Fix options:
// Option 1: Make it @MainActor (simplest for app code)
@MainActor
class AppConfig {
static let shared = AppConfig()
var apiKey: String = ""
}
// Option 2: Make it an actor
actor AppConfig {
static let shared = AppConfig()
var apiKey: String = ""
}
// Option 3: Make it Sendable + immutable
final class AppConfig: Sendable {
static let shared = AppConfig()
let apiKey: String = "..." // Must be let, not var
}
// Option 4: nonisolated(unsafe) — last resort
nonisolated(unsafe) static var shared = AppConfig()Non-Sendable Closures
// ❌ Closure captures non-Sendable type
let viewModel = MyViewModel() // Not Sendable
Task {
await viewModel.load() // Sending non-Sendable across isolation
}Fix options:
// Option 1: Make the type Sendable
final class MyViewModel: Sendable { ... }
// Option 2: Make it @MainActor (if it's a ViewModel)
@MainActor
final class MyViewModel { ... }
// Option 3: Use the type within its own isolation
@MainActor
func loadData() async {
let viewModel = MyViewModel() // Created on MainActor
await viewModel.load() // Stays on MainActor
}Protocol Conformances
// ❌ Swift 6 error: @MainActor type can't conform to non-isolated protocol
protocol DataProvider {
func fetchData() async -> [Item]
}
@MainActor
class ItemProvider: DataProvider {
func fetchData() async -> [Item] { ... } // Error
}Fix with Swift 6.2 isolated conformance:
// ✅ Swift 6.2: Isolated conformance
extension ItemProvider: @MainActor DataProvider {
func fetchData() async -> [Item] { ... }
}Fix for Swift 6.0/6.1:
// ✅ Make the protocol method nonisolated
extension ItemProvider: DataProvider {
nonisolated func fetchData() async -> [Item] {
await MainActor.run { ... }
}
}Imported C / Objective-C Types
Many imported types are not annotated for concurrency. Use @preconcurrency to suppress warnings:
// ❌ Warning: Type from ObjC module is not Sendable
import CoreLocation
// ✅ Suppress warnings for imported module
@preconcurrency import CoreLocation@preconcurrency silences Sendable warnings for types from that module. Remove it once the module adds Sendable annotations.
Delegate Patterns
// ❌ Delegate callback crosses isolation
class LocationService: NSObject, CLLocationManagerDelegate {
@MainActor var lastLocation: CLLocation?
func locationManager(_ manager: CLLocationManager,
didUpdateLocations locations: [CLLocation]) {
// Error: mutating @MainActor property from non-isolated context
lastLocation = locations.last
}
}Fix:
// ✅ Dispatch to MainActor
func locationManager(_ manager: CLLocationManager,
didUpdateLocations locations: [CLLocation]) {
let location = locations.last
Task { @MainActor in
lastLocation = location
}
}Notification Observers
// ❌ Closure may run on any thread
NotificationCenter.default.addObserver(
forName: .someNotification, object: nil, queue: nil
) { notification in
self.handleNotification(notification) // May cross isolation
}
// ✅ Specify main queue, or bridge to AsyncSequence
NotificationCenter.default.addObserver(
forName: .someNotification, object: nil, queue: .main
) { notification in
self.handleNotification(notification) // Runs on main thread
}
// ✅ Or use async notification stream
for await notification in NotificationCenter.default.notifications(named: .someNotification) {
await handleNotification(notification)
}nonisolated(unsafe) — Last Resort
For code that you know is safe but can't prove to the compiler:
// Only use when you've exhausted all other options
nonisolated(unsafe) static var shared = LegacyManager()When it's acceptable:
- Bridging with C/ObjC singletons that are known thread-safe
- Third-party library types that are thread-safe but not Sendable-annotated
- Temporary escape hatch during incremental migration
When it's NOT acceptable:
- To silence warnings you don't understand
- For mutable state without synchronization
- As a permanent solution (always plan to remove it)
Module-by-Module Migration
For large projects, migrate one module at a time:
1. Start with leaf modules (no dependencies on other project modules) 2. Enable "complete" checking on that module 3. Fix all warnings 4. Move to the next module up the dependency chain 5. Once all modules pass, switch to Swift 6 language mode
This prevents a flood of errors across the entire project.
Checklist
- [ ] Started with "minimal" checking, progressed to "complete"
- [ ] All global/static
vareither@MainActor, actor-isolated, or immutable - [ ] Non-Sendable types not crossing isolation boundaries
- [ ]
@preconcurrency importused for unannoted third-party modules - [ ] No
nonisolated(unsafe)without a comment explaining why it's safe - [ ] Protocol conformances using isolated conformances (6.2) or
nonisolatedworkarounds - [ ] Delegate callbacks dispatched to correct isolation context
- [ ] Migration done module-by-module for large projects
- [ ] Swift 6 language mode enabled after all warnings resolved
Structured Concurrency
Patterns for running concurrent work with automatic cancellation and scoped lifetimes. Prefer structured concurrency over unstructured Task {} whenever possible.
async let — Fixed Parallel Operations
Run a known number of operations concurrently:
func loadDashboard() async throws -> Dashboard {
async let user = fetchUser()
async let posts = fetchPosts()
async let notifications = fetchNotifications()
// All three run concurrently. Await collects results.
return try await Dashboard(
user: user,
posts: posts,
notifications: notifications
)
}Key properties:
- Child tasks start immediately when
async letis evaluated - Results are awaited where they are used
- If the enclosing scope exits early (error thrown), pending tasks are automatically cancelled
- If one
async letthrows, the others are cancelled
async let vs TaskGroup
| Feature | async let | TaskGroup |
|---|---|---|
| Number of tasks | Fixed, known at compile time | Dynamic, determined at runtime |
| Return types | Can be different per binding | Must be the same (or use enum) |
| Syntax | Simple variable bindings | Closure with group.addTask |
| Use when | 2-5 parallel calls with different types | Processing a collection in parallel |
// ✅ async let — different return types, fixed count
async let user = fetchUser() // -> User
async let settings = fetchSettings() // -> Settings
// ✅ TaskGroup — same return type, dynamic count
let images = try await withThrowingTaskGroup(of: UIImage.self) { group in
for url in imageURLs {
group.addTask { try await downloadImage(url) }
}
var results: [UIImage] = []
for try await image in group {
results.append(image)
}
return results
}TaskGroup — Dynamic Parallel Operations
Collecting Results
func fetchAllItems(ids: [UUID]) async throws -> [Item] {
try await withThrowingTaskGroup(of: Item.self) { group in
for id in ids {
group.addTask {
try await fetchItem(id: id)
}
}
var items: [Item] = []
for try await item in group {
items.append(item)
}
return items
}
}Limiting Concurrency
Prevent overwhelming the system with too many parallel tasks:
func downloadImages(urls: [URL]) async throws -> [UIImage] {
try await withThrowingTaskGroup(of: UIImage.self) { group in
let maxConcurrent = 4
var index = 0
var results: [UIImage] = []
// Start initial batch
for _ in 0..<min(maxConcurrent, urls.count) {
group.addTask { try await self.downloadImage(urls[index]) }
index += 1
}
// As each completes, start the next
for try await image in group {
results.append(image)
if index < urls.count {
group.addTask { try await self.downloadImage(urls[index]) }
index += 1
}
}
return results
}
}DiscardingTaskGroup (Swift 5.9+)
For fire-and-forget child tasks where you don't need to collect results. More memory-efficient because completed child task values are discarded immediately.
// ✅ Discarding — good for side effects (logging, notifications, cache warming)
await withDiscardingTaskGroup { group in
for item in items {
group.addTask {
await cacheService.warm(item)
}
}
// No iteration needed — results are discarded
}
// ❌ Don't use regular TaskGroup if you ignore results — leaks memory
await withTaskGroup(of: Void.self) { group in
for item in items {
group.addTask { await cacheService.warm(item) }
}
// Must iterate even for Void: for await _ in group { }
// Or results accumulate in memory
}Use withThrowingDiscardingTaskGroup if child tasks can throw.
.task Modifier — SwiftUI View Lifecycle
Basic .task
struct ItemListView: View {
@State private var items: [Item] = []
var body: some View {
List(items) { item in
ItemRow(item: item)
}
.task {
// Runs when view appears
// Automatically cancelled when view disappears
items = await fetchItems()
}
}
}Key properties:
- Starts an async task when the view appears
- Automatically cancels the task when the view disappears
- The task inherits the view's actor isolation (usually
@MainActor)
.task(id:) — Re-run on Value Change
struct ItemDetailView: View {
let itemID: UUID
@State private var item: Item?
var body: some View {
Group {
if let item {
ItemContent(item: item)
} else {
ProgressView()
}
}
.task(id: itemID) {
// Runs when view appears AND when itemID changes
// Previous task is cancelled before new one starts
item = await fetchItem(id: itemID)
}
}
}Why use `.task(id:)` over `.onChange` + manual Task?
// ❌ Manual pattern — error-prone, must handle cancellation yourself
@State private var loadTask: Task<Void, Never>?
.onChange(of: itemID) { _, newID in
loadTask?.cancel()
loadTask = Task {
item = await fetchItem(id: newID)
}
}
// ✅ .task(id:) handles cancellation automatically
.task(id: itemID) {
item = await fetchItem(id: itemID)
}.task vs Task {} in .onAppear
// ❌ Unstructured task — not cancelled when view disappears
.onAppear {
Task {
items = await fetchItems() // May complete after view is gone
}
}
// ✅ Structured — automatically cancelled on disappear
.task {
items = await fetchItems()
}The .onAppear + Task {} pattern creates an unstructured task that outlives the view. If the view disappears quickly (e.g., fast tab switching), the task keeps running and may update @State on a deallocated view.
Task Cancellation
Checking Cancellation
func processLargeDataset(_ items: [Item]) async throws -> [ProcessedItem] {
var results: [ProcessedItem] = []
for item in items {
// Check before each expensive operation
try Task.checkCancellation() // Throws CancellationError if cancelled
results.append(await processItem(item))
}
return results
}Cooperative Cancellation in Loops
func processItems(_ items: [Item]) async -> [ProcessedItem] {
var results: [ProcessedItem] = []
for item in items {
guard !Task.isCancelled else { break } // Non-throwing check
results.append(await processItem(item))
}
return results
}withTaskCancellationHandler
For proactive cleanup when a task is cancelled (e.g., aborting a network request):
func downloadFile(from url: URL) async throws -> Data {
let request = URLRequest(url: url)
let (data, _) = try await withTaskCancellationHandler {
try await URLSession.shared.data(for: request)
} onCancel: {
// Called immediately when the task is cancelled
// Runs on an arbitrary thread — must be Sendable-safe
URLSession.shared.getAllTasks { tasks in
tasks.filter { $0.originalRequest?.url == url }.forEach { $0.cancel() }
}
}
return data
}Task.yield()
Cooperatively yield execution in CPU-heavy loops to let other tasks run:
func processHugeArray(_ items: [Item]) async -> [ProcessedItem] {
var results: [ProcessedItem] = []
for (index, item) in items.enumerated() {
results.append(process(item))
if index.isMultiple(of: 100) {
await Task.yield() // Let other tasks run every 100 items
}
}
return results
}Task Priorities
Task(priority: .userInitiated) { await loadVisibleContent() }
Task(priority: .background) { await prefetchNextPage() }
Task(priority: .low) { await updateSearchIndex() }Priority Escalation
If a high-priority task awaits a low-priority task, the low-priority task's priority is escalated:
let backgroundTask = Task(priority: .background) {
await heavyComputation()
}
// Later, a high-priority task needs the result:
Task(priority: .userInitiated) {
let result = await backgroundTask.value // Escalates backgroundTask to .userInitiated
}This prevents priority inversion but can cause unexpected scheduling behavior. Design your concurrency so high-priority paths don't depend on low-priority tasks.
Common Mistakes
Creating Tasks Where Structured Concurrency Works
// ❌ Unstructured — no automatic cancellation, harder to reason about
func loadData() {
let task1 = Task { await fetchUsers() }
let task2 = Task { await fetchPosts() }
// Must manually manage cancellation
}
// ✅ Structured — automatic cancellation, clear lifetime
func loadData() async {
async let users = fetchUsers()
async let posts = fetchPosts()
let (u, p) = await (users, posts)
}Not Iterating TaskGroup Results
// ❌ Memory leak — completed task values accumulate
await withTaskGroup(of: Data.self) { group in
for url in urls {
group.addTask { await download(url) }
}
// Never iterate group — results pile up in memory
}
// ✅ Always iterate, or use withDiscardingTaskGroup for Void results
await withTaskGroup(of: Data.self) { group in
for url in urls {
group.addTask { await download(url) }
}
for await data in group {
process(data)
}
}Ignoring Task Cancellation
// ❌ Runs to completion even when parent is cancelled
func processAll(_ items: [Item]) async -> [Result] {
var results: [Result] = []
for item in items {
results.append(await process(item)) // Never checks cancellation
}
return results
}
// ✅ Cooperative cancellation
func processAll(_ items: [Item]) async throws -> [Result] {
var results: [Result] = []
for item in items {
try Task.checkCancellation()
results.append(await process(item))
}
return results
}Checklist
- [ ] Using
async letfor fixed parallel operations (notTask {}) - [ ] Using
TaskGroupfor dynamic parallel operations - [ ] Using
withDiscardingTaskGroupwhen results aren't needed - [ ]
.taskmodifier instead of.onAppear+Task {} - [ ]
.task(id:)instead of.onChange+ manual task cancellation - [ ] Cooperative cancellation in long loops (
Task.checkCancellation()orTask.isCancelled) - [ ]
Task.yield()in CPU-heavy loops to prevent hangs - [ ] TaskGroup results iterated (or using discarding variant)
Swift 6.2 Approachable Concurrency
Swift 6.2 makes strict concurrency dramatically easier to adopt. The core philosophy: code runs on @MainActor by default, async functions stay on the calling actor, and you explicitly opt into background execution with @concurrent.
Async Functions Stay on the Calling Actor
In Swift 6.0/6.1, non-actor-annotated async functions hopped to the generic concurrent executor. This caused data race errors when called from @MainActor types.
// ❌ Swift 6.0/6.1 — ERROR: Sending 'self.processor' risks causing data races
@MainActor
final class StickerModel {
let processor = PhotoProcessor()
func extract(_ item: PhotosPickerItem) async throws -> Sticker? {
// processor would hop off MainActor — data race
return await processor.extractSticker(data: data, with: item.itemIdentifier)
}
}
class PhotoProcessor {
func extractSticker(data: Data, with id: String?) async -> Sticker? { ... }
}// ✅ Swift 6.2 — No error. Async functions stay on the caller's actor.
// The same code compiles cleanly with no changes needed.
@MainActor
final class StickerModel {
let processor = PhotoProcessor()
func extract(_ item: PhotosPickerItem) async throws -> Sticker? {
return await processor.extractSticker(data: data, with: item.itemIdentifier)
}
}Why? In 6.2, extractSticker stays on @MainActor because the caller is @MainActor. No hop, no data race.
Infer Main Actor by Default
An opt-in build setting that makes all code implicitly @MainActor unless explicitly opted out. Eliminates most data race errors for app targets.
Enabling It
Xcode: Build Settings → Swift Compiler - Concurrency → "Default Actor Isolation" → "MainActor"
Swift Package Manager:
.executableTarget(
name: "MyApp",
swiftSettings: [
.defaultIsolation(MainActor.self)
]
)What Changes
With this mode enabled, you no longer need @MainActor annotations on app-level types:
// ❌ Before (Swift 6.0/6.1) — manual annotations everywhere
@MainActor
final class StickerLibrary {
static let shared: StickerLibrary = .init()
}
@MainActor
final class StickerModel {
let processor: PhotoProcessor
var selection: [PhotosPickerItem]
}// ✅ After (Swift 6.2 with infer main actor) — no annotations needed
final class StickerLibrary {
static let shared: StickerLibrary = .init() // Implicitly @MainActor
}
final class StickerModel {
let processor: PhotoProcessor
var selection: [PhotosPickerItem] // Implicitly @MainActor
}When to Use
| Target Type | Recommended? | Why |
|---|---|---|
| App target | Yes | Apps are UI-driven, most code belongs on MainActor |
| Script target | Yes | Scripts are sequential, MainActor default is natural |
| Library/framework | No | Libraries should not impose actor isolation on consumers |
| Package plugin | No | Same as libraries |
Opting Out
When a type or function needs to run off the main actor, use nonisolated:
// With "infer main actor" enabled:
nonisolated struct ImageProcessor {
func processImage(_ data: Data) -> UIImage { ... } // Runs on any thread
}
nonisolated func heavyComputation() -> Result { ... }Isolated Conformances
Allows @MainActor types to conform to protocols that don't require actor isolation:
protocol Exportable {
func export()
}
// ❌ Swift 6.0/6.1 — ERROR: Conformance crosses into main actor-isolated code
extension StickerModel: Exportable {
func export() {
processor.exportAsPNG()
}
}// ✅ Swift 6.2 — Isolated conformance
extension StickerModel: @MainActor Exportable {
func export() {
processor.exportAsPNG()
}
}Usage Rules
Isolated conformances can only be used in matching isolation contexts:
// ✅ Used within @MainActor context — OK
@MainActor
struct ImageExporter {
var items: [any Exportable]
mutating func add(_ item: StickerModel) {
items.append(item) // OK — both are @MainActor
}
}
// ❌ Used outside @MainActor — compile error
nonisolated struct ImageExporter {
var items: [any Exportable]
mutating func add(_ item: StickerModel) {
items.append(item) // Error: Main actor-isolated conformance
// cannot be used in nonisolated context
}
}@concurrent — Explicit Background Execution
When you need true parallelism (CPU-heavy work off the main thread), use @concurrent:
class PhotoProcessor {
var cachedStickers: [String: Sticker]
func extractSticker(data: Data, with id: String) async -> Sticker {
if let sticker = cachedStickers[id] { return sticker }
let sticker = await Self.extractSubject(from: data) // Runs on background
cachedStickers[id] = sticker
return sticker
}
@concurrent
static func extractSubject(from data: Data) async -> Sticker {
// Heavy image processing — runs on concurrent thread pool
...
}
}Steps to Offload Work
1. Make the type nonisolated (if it's a struct/class, not an actor) 2. Add @concurrent to the function 3. Make the function async 4. Callers use await
nonisolated struct ImageProcessor {
@concurrent
func resize(image: Data, to size: CGSize) async -> Data {
// Runs on background thread
...
}
}
// Caller (on MainActor):
let resized = await ImageProcessor().resize(image: data, to: targetSize)@concurrent vs Task.detached vs Actor
| Mechanism | Use Case |
|---|---|
@concurrent | Single function that must run on background thread |
Task.detached | Fire-and-forget background work, no structured parent |
actor | Shared mutable state that needs serialized access |
Task {} | Unstructured task inheriting current actor (usually MainActor) |
Prefer @concurrent for compute-heavy functions. Prefer actors for shared state. Avoid Task.detached when structured concurrency works.
Mental Model Summary
Swift 6.2 Concurrency Defaults:
┌────────────────────────────────────────────┐
│ Everything is @MainActor by default │
│ (with "infer main actor" build setting) │
│ │
│ async functions stay on the calling actor │
│ (no implicit hop to background) │
│ │
│ Use @concurrent to explicitly go background │
│ Use nonisolated to opt out of MainActor │
└────────────────────────────────────────────┘
Progression:
1. Write code → runs on MainActor → no data races
2. Use async/await → stays on calling actor → still no races
3. Need parallelism → @concurrent → explicit, auditableChecklist
- [ ] Using Swift 6.2+ for approachable concurrency features
- [ ] "Infer main actor by default" enabled for app targets (not libraries)
- [ ]
@concurrentused for CPU-heavy functions that must run on background - [ ]
nonisolatedused to opt types/functions out of MainActor when needed - [ ] Isolated conformances (
@MainActor Protocol) used for MainActor types conforming to non-isolated protocols - [ ] Not using
@MainActorannotations everywhere (let inference handle it in 6.2)