Skillshub axiom-swift-concurrency
Use when you see 'actor-isolated', 'Sendable', 'data race', '@MainActor' errors, or when asking 'why is this not thread safe', 'how do I use async/await', 'what is @MainActor for', 'my app is crashing with concurrency errors', 'how do I fix data races' - Swift 6 strict concurrency patterns with actor isolation and async/await
git clone https://github.com/ComeOnOliver/skillshub
T=$(mktemp -d) && git clone --depth=1 https://github.com/ComeOnOliver/skillshub "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/CharlesWiltgen/Axiom/axiom-swift-concurrency" ~/.claude/skills/comeonoliver-skillshub-axiom-swift-concurrency && rm -rf "$T"
skills/CharlesWiltgen/Axiom/axiom-swift-concurrency/SKILL.mdSwift 6 Concurrency Guide
Purpose: Progressive journey from single-threaded to concurrent Swift code Swift Version: Swift 6.0+, Swift 6.2+ for
@concurrent
iOS Version: iOS 17+ (iOS 18.2+ for @concurrent)
Xcode: Xcode 16+ (Xcode 16.2+ for @concurrent)
Context: WWDC 2025-268 "Embracing Swift concurrency" - approachable path to data-race safety
When to Use This Skill
✅ Use this skill when:
- Starting a new project and deciding concurrency strategy
- Debugging Swift 6 concurrency errors (actor isolation, data races, Sendable warnings)
- Deciding when to introduce async/await vs concurrency
- Implementing
classes or async functions@MainActor - Converting delegate callbacks to async-safe patterns
- Deciding between
,@MainActor
,nonisolated
, or actor isolation@concurrent - Resolving "Sending 'self' risks causing data races" errors
- Making types conform to
Sendable - Offloading CPU-intensive work to background threads
- UI feels unresponsive and profiling shows main thread bottleneck
❌ Do NOT use this skill for:
- General Swift syntax (use Swift documentation)
- SwiftUI-specific patterns (use
oraxiom-swiftui-debugging
)axiom-swiftui-performance - API-specific patterns (use API documentation)
Core Philosophy: Start Single-Threaded
Apple's Guidance (WWDC 2025-268): "Your apps should start by running all of their code on the main thread, and you can get really far with single-threaded code."
The Progressive Journey
Single-Threaded → Asynchronous → Concurrent → Actors ↓ ↓ ↓ ↓ Start here Hide latency Background Move data (network) CPU work off main
When to advance:
- Stay single-threaded if UI is responsive and operations are fast
- Add async/await when high-latency operations (network, file I/O) block UI
- Add concurrency when CPU-intensive work (image processing, parsing) freezes UI
- Add actors when too much main actor code causes contention
Key insight: Concurrent code is more complex. Only introduce concurrency when profiling shows it's needed.
Step 1: Single-Threaded Code (Start Here)
All code runs on the main thread by default in Swift 6.
// ✅ Simple, single-threaded class ImageModel { var imageCache: [URL: Image] = [:] func fetchAndDisplayImage(url: URL) throws { let data = try Data(contentsOf: url) // Reads local file let image = decodeImage(data) view.displayImage(image) } func decodeImage(_ data: Data) -> Image { // Decode image data return Image() } }
Main Actor Mode (Xcode 26+):
- Enabled by default for new projects
- All code protected by
unless explicitly marked otherwise@MainActor - Access shared state safely without worrying about concurrent access
Build Setting (Xcode 26+):
Build Settings → Swift Compiler — Language → "Default Actor Isolation" = Main Actor Build Settings → Swift Compiler — Upcoming Features → "Approachable Concurrency" = Yes
When this is enough: If all operations are fast (<16ms for 60fps), stay single-threaded!
Step 2: Asynchronous Tasks (Hide Latency)
Add async/await when waiting on data (network, file I/O) would freeze UI.
Problem: Network Access Blocks UI
// ❌ Blocks main thread until network completes func fetchAndDisplayImage(url: URL) throws { let (data, _) = try URLSession.shared.data(from: url) // ❌ Freezes UI! let image = decodeImage(data) view.displayImage(image) }
Solution: Async/Await
// ✅ Suspends without blocking main thread func fetchAndDisplayImage(url: URL) async throws { let (data, _) = try await URLSession.shared.data(from: url) // ✅ Suspends here let image = decodeImage(data) // ✅ Resumes here when data arrives view.displayImage(image) }
What happens:
- Function starts on main thread
suspends function without blocking main threadawait- URLSession fetches data on background thread (library handles this)
- Function resumes on main thread when data arrives
- UI stays responsive the entire time
Task Creation
Create tasks in response to user events:
class ImageModel { var url: URL = URL(string: "https://swift.org")! func onTapEvent() { Task { // ✅ Create task for user action do { try await fetchAndDisplayImage(url: url) } catch { displayError(error) } } } }
Task Interleaving (Important Concept)
Multiple async tasks can run on the same thread by taking turns:
Task 1: [Fetch Image] → (suspend) → [Decode] → [Display] Task 2: [Fetch News] → (suspend) → [Display News] Main Thread Timeline: [Fetch Image] → [Fetch News] → [Decode Image] → [Display Image] → [Display News]
Benefits:
- Main thread never sits idle
- Tasks make progress as soon as possible
- No concurrency yet—still single-threaded!
When to use tasks:
- High-latency operations (network, file I/O)
- Library APIs handle background work for you (URLSession, FileManager)
- Your own code stays on main thread
Step 3: Concurrent Code (Background Threads)
Add concurrency when CPU-intensive work blocks UI.
Problem: Decoding Blocks UI
Profiling shows
decodeImage() takes 200ms, causing UI glitches:
func fetchAndDisplayImage(url: URL) async throws { let (data, _) = try await URLSession.shared.data(from: url) let image = decodeImage(data) // ❌ 200ms on main thread! view.displayImage(image) }
Solution 1: @concurrent
Attribute (Swift 6.2+)
@concurrentForces function to always run on background thread:
func fetchAndDisplayImage(url: URL) async throws { let (data, _) = try await URLSession.shared.data(from: url) let image = await decodeImage(data) // ✅ Runs on background thread view.displayImage(image) } @concurrent func decodeImage(_ data: Data) async -> Image { // ✅ Always runs on background thread pool // Good for: image processing, file I/O, parsing return Image() }
What
does:@concurrent
- Function always switches to background thread pool
- Compiler highlights main actor data access (shows what you need to fix)
- Cannot access
properties without@MainActorawait
Requirements: Swift 6.2, Xcode 16.2+, iOS 18.2+
Solution 2: nonisolated
(Library APIs)
nonisolatedIf providing a general-purpose API, use
nonisolated instead:
// ✅ Stays on caller's actor nonisolated func decodeImage(_ data: Data) -> Image { // Runs on whatever actor called it // Main actor → stays on main actor // Background → stays on background return Image() }
When to use
:nonisolated
- Library APIs where caller decides where work happens
- Small operations that might be OK on main thread
- General-purpose code used in many contexts
When to use
:@concurrent
- Operations that should always run on background (image processing, parsing)
- Performance-critical work that shouldn't block UI
Breaking Ties to Main Actor
When you mark a function
@concurrent, compiler shows main actor access:
@MainActor class ImageModel { var cachedImage: [URL: Image] = [:] // Main actor data @concurrent func decodeImage(_ data: Data, at url: URL) async -> Image { if let image = cachedImage[url] { // ❌ Error: main actor access! return image } // decode... } }
Strategy 1: Move to caller (keep work synchronous):
func fetchAndDisplayImage(url: URL) async throws { // ✅ Check cache on main actor BEFORE async work if let image = cachedImage[url] { view.displayImage(image) return } let (data, _) = try await URLSession.shared.data(from: url) let image = await decodeImage(data) // No URL needed now view.displayImage(image) } @concurrent func decodeImage(_ data: Data) async -> Image { // ✅ No main actor access needed return Image() }
Strategy 2: Use await (access main actor asynchronously):
@concurrent func decodeImage(_ data: Data, at url: URL) async -> Image { // ✅ Await to access main actor data if let image = await cachedImage[url] { return image } // decode... }
Strategy 3: Make nonisolated (if doesn't need actor):
nonisolated func decodeImage(_ data: Data) -> Image { // ✅ No actor isolation, can call from anywhere return Image() }
Concurrent Thread Pool
When work runs on background:
Main Thread: [UI] → (suspend) → [UI Update] ↓ Background Pool: [Task A] → [Task B] → [Task A resumes] Thread 1 Thread 2 Thread 3
Key points:
- System manages thread pool size (1-2 threads on Watch, many on Mac)
- Task can resume on different thread than it started
- You never specify which thread—system optimizes automatically
Step 4: Actors (Move Data Off Main Thread)
Add actors when too much code runs on main actor causing contention.
Problem: Main Actor Contention
@MainActor class ImageModel { var cachedImage: [URL: Image] = [:] let networkManager: NetworkManager = NetworkManager() // ❌ Also @MainActor func fetchAndDisplayImage(url: URL) async throws { // ✅ Background work... let connection = await networkManager.openConnection(for: url) // ❌ Hops to main! let data = try await connection.data(from: url) await networkManager.closeConnection(connection, for: url) // ❌ Hops to main! let image = await decodeImage(data) view.displayImage(image) } }
Issue: Background task keeps hopping to main actor for network manager access.
Solution: Network Manager Actor
// ✅ Move network state off main actor actor NetworkManager { var openConnections: [URL: Connection] = [:] func openConnection(for url: URL) -> Connection { if let connection = openConnections[url] { return connection } let connection = Connection() openConnections[url] = connection return connection } func closeConnection(_ connection: Connection, for url: URL) { openConnections.removeValue(forKey: url) } } @MainActor class ImageModel { let networkManager: NetworkManager = NetworkManager() func fetchAndDisplayImage(url: URL) async throws { // ✅ Now runs mostly on background let connection = await networkManager.openConnection(for: url) let data = try await connection.data(from: url) await networkManager.closeConnection(connection, for: url) let image = await decodeImage(data) view.displayImage(image) } }
What changed:
is now anNetworkManager
instead ofactor@MainActor class- Network state isolated in its own actor
- Background code can access network manager without hopping to main actor
- Main thread freed up for UI work
When to Use Actors
✅ Use actors for:
- Non-UI subsystems with independent state (network manager, cache, database)
- Data that's causing main actor contention
- Separating concerns from UI code
❌ Do NOT use actors for:
- UI-facing classes (ViewModels, View Controllers) → Use
@MainActor - Model classes used by UI → Keep
or non-Sendable@MainActor - Every class in your app (actors add complexity)
Guideline: Profile first. If main actor has too much state causing bottlenecks, extract one subsystem at a time into actors.
Sendable Types (Data Crossing Actor Boundaries)
When data passes between actors or tasks, Swift checks it's Sendable (safe to share).
Value Types Are Sendable
// ✅ Value types copy when passed let url = URL(string: "https://swift.org")! Task { // ✅ This is a COPY of url, not the original // URLSession.shared.data runs on background automatically let data = try await URLSession.shared.data(from: url) } // ✅ Original url unchanged by background task
Why safe: Each actor gets its own independent copy. Changes don't affect other copies.
What's Sendable?
// ✅ Basic types extension URL: Sendable {} extension String: Sendable {} extension Int: Sendable {} extension Date: Sendable {} // ✅ Collections of Sendable elements extension Array: Sendable where Element: Sendable {} extension Dictionary: Sendable where Key: Sendable, Value: Sendable {} // ✅ Structs/enums with Sendable storage struct Track: Sendable { let id: String let title: String let duration: TimeInterval } enum PlaybackState: Sendable { case stopped case playing case paused } // ✅ Main actor types @MainActor class ImageModel {} // Implicitly Sendable (actor protects state) // ✅ Actor types actor NetworkManager {} // Implicitly Sendable (actor protects state)
Reference Types (Classes) and Sendable
// ❌ Classes are NOT Sendable by default class MyImage { var width: Int var height: Int var pixels: [Color] func scale(by factor: Double) { // Mutates shared state } } let image = MyImage() let otherImage = image // ✅ Both reference SAME object image.scale(by: 0.5) // ✅ Changes visible through otherImage!
Problem with concurrency:
func scaleAndDisplay(imageName: String) { let image = loadImage(imageName) Task { image.scale(by: 0.5) // Background task modifying } view.displayImage(image) // Main thread reading // ❌ DATA RACE! Both threads could touch same object! }
Solution 1: Finish modifications before sending:
@concurrent func scaleAndDisplay(imageName: String) async { let image = loadImage(imageName) image.scale(by: 0.5) // ✅ All modifications on background image.applyAnotherEffect() // ✅ Still on background await view.displayImage(image) // ✅ Send to main actor AFTER modifications done // ✅ Main actor now owns image exclusively }
Solution 2: Don't share classes concurrently:
Keep model classes
@MainActor or non-Sendable to prevent concurrent access.
Sendable Checking
Happens automatically when:
- Passing data into/out of actors
- Passing data into/out of tasks
- Crossing actor boundaries with
await
func fetchAndDisplayImage(url: URL) async throws { let (data, _) = try await URLSession.shared.data(from: url) // ↑ Sendable ↑ Sendable (crosses to background) let image = await decodeImage(data) // ↑ data crosses to background (must be Sendable) // ↑ image returns to main (must be Sendable) }
Common Patterns (Copy-Paste Templates)
Pattern 1: Sendable Enum/Struct
When: Type crosses actor boundaries
// ✅ Enum (no associated values) private enum PlaybackState: Sendable { case stopped case playing case paused } // ✅ Struct (all properties Sendable) struct Track: Sendable { let id: String let title: String let artist: String? } // ✅ Enum with Sendable associated values enum Result: Sendable { case success(data: Data) case failure(error: Error) // Error is Sendable }
Pattern 2: Delegate Value Capture (CRITICAL)
When:
nonisolated delegate method needs to update @MainActor state
nonisolated func delegate(_ param: SomeType) { // ✅ Step 1: Capture delegate parameter values BEFORE Task let value = param.value let status = param.status // ✅ Step 2: Task hop to MainActor Task { @MainActor in // ✅ Step 3: Safe to access self (we're on MainActor) self.property = value print("Status: \(status)") } }
Why: Delegate methods are
nonisolated (called from library's threads). Capture parameters before Task. Accessing self inside Task { @MainActor in } is safe.
Pattern 3: Weak Self in Tasks
When: Task is stored as property OR runs for long time
class MusicPlayer { private var progressTask: Task<Void, Never>? func startMonitoring() { progressTask = Task { [weak self] in // ✅ Weak capture guard let self = self else { return } while !Task.isCancelled { await self.updateProgress() } } } deinit { progressTask?.cancel() } }
Note: Short-lived Tasks (not stored) can use strong captures.
Pattern 4: Background Work with @concurrent
When: CPU-intensive work should always run on background (Swift 6.2+)
@concurrent func decodeImage(_ data: Data) async -> Image { // ✅ Always runs on background thread pool // Good for: image processing, file I/O, JSON parsing return Image() } // Usage let image = await decodeImage(data) // Automatically offloads
Requirements: Swift 6.2, Xcode 16.2+, iOS 18.2+
Pattern 5: Isolated Protocol Conformances (Swift 6.2+)
When: Type needs to conform to protocol with specific actor isolation
protocol Exportable { func export() } class PhotoProcessor { @MainActor func exportAsPNG() { // Export logic requiring UI access } } // ✅ Conform with explicit isolation extension StickerModel: @MainActor Exportable { func export() { photoProcessor.exportAsPNG() // ✅ Safe: both on MainActor } }
When to use: Protocol methods need specific actor context (main actor for UI, background for processing)
Pattern 6: Atomic Snapshots
When: Reading multiple properties that could change mid-access
var currentTime: TimeInterval { get async { // ✅ Cache reference for atomic snapshot guard let player = player else { return 0 } return player.currentTime } }
Pattern 7: MainActor for UI Code
When: Code touches UI
@MainActor class PlayerViewModel: ObservableObject { @Published var currentTrack: Track? @Published var isPlaying: Bool = false func play(_ track: Track) async { // Already on MainActor self.currentTrack = track self.isPlaying = true } }
Data Persistence Concurrency Patterns
Pattern 8: Background SwiftData Access
actor DataFetcher { let modelContainer: ModelContainer func fetchAllTracks() async throws -> [Track] { let context = ModelContext(modelContainer) let descriptor = FetchDescriptor<Track>( sortBy: [SortDescriptor(\.title)] ) return try context.fetch(descriptor) } } @MainActor class TrackViewModel: ObservableObject { @Published var tracks: [Track] = [] func loadTracks() async { let fetchedTracks = try await fetcher.fetchAllTracks() self.tracks = fetchedTracks // Back on MainActor } }
Pattern 9: Core Data Thread-Safe Fetch
actor CoreDataFetcher { func fetchTracksID(genre: String) async throws -> [String] { let context = persistentContainer.newBackgroundContext() var trackIDs: [String] = [] try await context.perform { let request = NSFetchRequest<CDTrack>(entityName: "Track") request.predicate = NSPredicate(format: "genre = %@", genre) let results = try context.fetch(request) trackIDs = results.map { $0.id } // Extract IDs before leaving context } return trackIDs // Lightweight, Sendable } }
Pattern 10: Batch Import with Progress
actor DataImporter { func importRecords(_ records: [RawRecord], onProgress: @MainActor (Int, Int) -> Void) async throws { let chunkSize = 1000 let context = ModelContext(modelContainer) for (index, chunk) in records.chunked(into: chunkSize).enumerated() { for record in chunk { context.insert(Track(from: record)) } try context.save() let processed = (index + 1) * chunkSize await onProgress(min(processed, records.count), records.count) if Task.isCancelled { throw CancellationError() } } } }
Pattern 11: GRDB Background Execution
actor DatabaseQueryExecutor { let dbQueue: DatabaseQueue func fetchUserWithPosts(userId: String) async throws -> (user: User, posts: [Post]) { return try await dbQueue.read { db in let user = try User.filter(Column("id") == userId).fetchOne(db)! let posts = try Post .filter(Column("userId") == userId) .order(Column("createdAt").desc) .limit(100) .fetchAll(db) return (user, posts) } } }
Quick Decision Tree
Starting new feature? └─ Is UI responsive with all operations on main thread? ├─ YES → Stay single-threaded (Step 1) └─ NO → Continue... └─ Do you have high-latency operations? (network, file I/O) ├─ YES → Add async/await (Step 2) └─ NO → Continue... └─ Do you have CPU-intensive work? (Instruments shows main thread busy) ├─ YES → Add @concurrent or nonisolated (Step 3) └─ NO → Continue... └─ Is main actor contention causing slowdowns? └─ YES → Extract subsystem to actor (Step 4) Error: "Main actor-isolated property accessed from nonisolated context" ├─ In delegate method? │ └─ Pattern 2: Value Capture Before Task ├─ In async function? │ └─ Add @MainActor or call from Task { @MainActor in } └─ In @concurrent function? └─ Move access to caller, use await, or make nonisolated Error: "Type does not conform to Sendable" ├─ Enum/struct with Sendable properties? │ └─ Add `: Sendable` └─ Class? └─ Make @MainActor or keep non-Sendable (don't share concurrently) Want to offload work to background? ├─ Always background (image processing)? │ └─ Use @concurrent (Swift 6.2+) ├─ Caller decides? │ └─ Use nonisolated └─ Too much main actor state? └─ Extract to actor
Build Settings (Xcode 16+)
Build Settings → Swift Compiler — Language → "Default Actor Isolation" = Main Actor → "Approachable Concurrency" = Yes Build Settings → Swift Compiler — Concurrency → "Strict Concurrency Checking" = Complete
What this enables:
- Main actor mode (all code @MainActor by default)
- Compile-time data race prevention
- Progressive concurrency adoption
Anti-Patterns (DO NOT DO THIS)
❌ Using Concurrency When Not Needed
// ❌ Premature optimization @concurrent func addNumbers(_ a: Int, _ b: Int) async -> Int { return a + b // ❌ Trivial work, concurrency adds overhead } // ✅ Keep simple func addNumbers(_ a: Int, _ b: Int) -> Int { return a + b }
❌ Strong Self in Stored Tasks
// ❌ Memory leak progressTask = Task { while true { await self.update() // ❌ Strong capture } } // ✅ Weak capture progressTask = Task { [weak self] in guard let self = self else { return } // ... }
❌ Making Every Class an Actor
// ❌ Don't do this actor MyViewModel: ObservableObject { // ❌ UI code should be @MainActor! @Published var state: State // ❌ Won't work correctly } // ✅ Do this @MainActor class MyViewModel: ObservableObject { @Published var state: State }
Code Review Checklist
Before Adding Concurrency
- Profiled and confirmed UI unresponsiveness
- Identified specific slow operations (network, CPU, contention)
- Started with simplest solution (async → concurrent → actors)
Async/Await
- Used for high-latency operations only
- Task creation in response to events
- Error handling with do-catch
Background Work
-
for always-background work (Swift 6.2+)@concurrent -
for library APIsnonisolated - No blocking operations on main actor
Sendable
- Value types for data crossing actors
- Classes stay @MainActor or non-Sendable
- No concurrent modification of shared classes
Actors
- Only for non-UI subsystems
- UI code stays @MainActor
- Model classes stay @MainActor or non-Sendable
Real-World Impact
Before: Random crashes, data races, "works on my machine" bugs, premature complexity After: Compile-time guarantees, progressive adoption, only use concurrency when needed
Key insight: Swift 6's approach makes you prove code is safe before compilation succeeds. Start simple, add complexity only when profiling proves it's needed.
Resources
WWDC: 2025-268, 2025-245, 2022-110351, 2021-10133
Docs: /swift/adoptingswift6, /swift/sendable
Skills: axiom-lldb (debug actor/task state in the debugger)
Last Updated: 2025-12-01 Status: Enhanced with WWDC 2025-268 progressive journey, @concurrent attribute, isolated conformances, and approachable concurrency patterns