iOS Concurrency Skill
Core Rules
- Use async/await for ALL new asynchronous code. Do not use completion handlers or Combine for async operations in new code.
- Use structured concurrency (Task, TaskGroup). Avoid unstructured task leaks. Prefer
async let or TaskGroup over spawning loose Task {} blocks.
- Mark UI-updating code with @MainActor. SwiftUI views are already
@MainActor-isolated. UIKit code that touches UI must run on @MainActor.
- Use actors for shared mutable state instead of locks, semaphores, or serial dispatch queues.
- All types crossing actor boundaries must be Sendable. The compiler enforces this in strict concurrency mode.
- Prefer value types (struct, enum) for Sendable. They are implicitly Sendable when all stored properties are Sendable.
- Use
withCheckedContinuation to bridge callback-based APIs to async/await. Never resume a continuation more than once.
- Use
AsyncStream for bridging delegate/callback patterns to AsyncSequence.
Task.detached is rarely needed. Use Task {} with explicit actor isolation instead. Detached tasks lose actor context and priority inheritance.
- Always handle Task cancellation cooperatively. Check
Task.isCancelled or call try Task.checkCancellation() at appropriate points.
Decision Guide
| Scenario |
Solution |
| Single async operation |
async func / Task {} |
| Multiple independent operations |
TaskGroup / async let |
| Sequential dependent operations |
await one after another |
| Shared mutable state |
actor |
| UI updates from background |
@MainActor / MainActor.run {} |
| Bridge callback API |
withCheckedContinuation / withCheckedThrowingContinuation |
| Bridge delegate pattern |
AsyncStream with continuation |
| Streaming data |
AsyncSequence / AsyncStream |
| Debounce user input |
Task cancellation pattern |
| Non-Sendable legacy type |
@preconcurrency import / @unchecked Sendable (last resort) |
| Combine publisher to async |
.values property on publisher |
| Timer / periodic work |
AsyncTimerSequence (from swift-async-algorithms) or AsyncStream |
| Parallel with limit |
TaskGroup with semaphore-like counter |
Quick Reference: async/await
// Basic async function
func fetchUser(id: String) async throws -> User {
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw APIError.invalidResponse
}
return try JSONDecoder().decode(User.self, from: data)
}
// Calling from SwiftUI
.task {
do {
user = try await fetchUser(id: "123")
} catch {
errorMessage = error.localizedDescription
}
}
// Parallel execution with async let
async let profile = fetchProfile(id: userId)
async let posts = fetchPosts(userId: userId)
async let followers = fetchFollowers(userId: userId)
let result = try await (profile, posts, followers)
// TaskGroup for dynamic parallelism
func fetchAllUsers(ids: [String]) async throws -> [User] {
try await withThrowingTaskGroup(of: User.self) { group in
for id in ids {
group.addTask { try await self.fetchUser(id: id) }
}
var users: [User] = []
for try await user in group {
users.append(user)
}
return users
}
}
Quick Reference: Actors
// Actor for shared state
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
}
}
// Usage — await is required to cross isolation boundary
let cache = ImageCache()
await cache.store(image, for: url)
let cached = await cache.image(for: url)
// @MainActor for UI
@MainActor
final class ViewModel: ObservableObject {
@Published var items: [Item] = []
@Published var isLoading = false
func loadItems() async {
isLoading = true
defer { isLoading = false }
do {
items = try await api.fetchItems()
} catch {
// handle error
}
}
}
Quick Reference: Sendable
// Value types — automatically Sendable if all members are
struct UserDTO: Sendable {
let id: String
let name: String
}
// Reference types — must be final with immutable stored properties
final class Configuration: Sendable {
let apiKey: String
let baseURL: URL
init(apiKey: String, baseURL: URL) {
self.apiKey = apiKey
self.baseURL = baseURL
}
}
// @unchecked Sendable — escape hatch (use with caution)
final class LegacyManager: @unchecked Sendable {
private let lock = NSLock()
private var _state: State = .idle
var state: State {
lock.withLock { _state }
}
}
Quick Reference: Continuations
// Bridge completion handler to async/await
func fetchData() async throws -> Data {
try await withCheckedThrowingContinuation { continuation in
legacyAPI.fetch { result in
switch result {
case .success(let data):
continuation.resume(returning: data)
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
// Bridge delegate to AsyncStream
func locationUpdates() -> AsyncStream<CLLocation> {
AsyncStream { continuation in
let delegate = LocationDelegate { location in
continuation.yield(location)
}
continuation.onTermination = { _ in
delegate.stop()
}
delegate.start()
}
}
Quick Reference: Task Cancellation
// Cooperative cancellation
func processItems(_ items: [Item]) async throws {
for item in items {
try Task.checkCancellation() // throws CancellationError
await process(item)
}
}
// Manual check
func fetchWithFallback() async -> Data {
if Task.isCancelled { return Data() }
// ... continue work
}
// Debounce pattern
@MainActor
final class SearchViewModel: ObservableObject {
@Published var query = ""
@Published var results: [Result] = []
private var searchTask: Task<Void, Never>?
func search() {
searchTask?.cancel()
searchTask = Task {
try? await Task.sleep(for: .milliseconds(300))
guard !Task.isCancelled else { return }
results = await api.search(query)
}
}
}
Swift 6 Strict Concurrency Quick Guide
// Enable in Package.swift
.target(
name: "MyTarget",
swiftSettings: [.swiftLanguageMode(.v6)]
)
// Or in Xcode: Build Settings → Swift Language Version → 6
// Common fixes:
// 1. Non-Sendable type crossing isolation boundary
// → Make type Sendable or use @unchecked Sendable
// 2. Mutable capture in @Sendable closure
// → Use actor or move state inside Task
// 3. Global variable not concurrency-safe
// → Use actor, nonisolated(unsafe), or make it let
// 4. Legacy framework types not Sendable
// → @preconcurrency import FrameworkName
Anti-Patterns to Avoid
// BAD: Using Task.detached without good reason
Task.detached {
await self.doWork() // loses actor isolation and priority
}
// GOOD: Use Task {} — inherits actor context
Task {
await doWork()
}
// BAD: Blocking an actor with synchronous work
actor DataProcessor {
func process(_ data: Data) -> Result {
heavySyncComputation(data) // blocks the actor's executor
}
}
// GOOD: Move heavy sync work off the actor
actor DataProcessor {
func process(_ data: Data) async -> Result {
await Task.detached(priority: .utility) {
heavySyncComputation(data) // runs on cooperative pool
}.value
}
}
// BAD: Ignoring cancellation
Task {
for item in hugeList {
await process(item) // never checks cancellation
}
}
// GOOD: Cooperative cancellation
Task {
for item in hugeList {
try Task.checkCancellation()
await process(item)
}
}
// BAD: Resuming continuation multiple times (CRASH)
withCheckedContinuation { continuation in
api.fetch { data in
continuation.resume(returning: data)
}
api.fetch { data in // second resume — CRASH
continuation.resume(returning: data)
}
}
// BAD: Never resuming continuation (LEAK — task hangs forever)
withCheckedContinuation { continuation in
api.fetch { data in
if let data = data {
continuation.resume(returning: data)
}
// if data is nil, continuation is never resumed!
}
}
Performance Considerations
- Task creation overhead: ~1-2 microseconds. Do not create tasks in tight loops for trivial work.
- Actor contention: If many tasks await the same actor, they serialize. Keep actor methods fast.
- MainActor bottleneck: Do not run heavy computation on
@MainActor. Offload to a non-isolated async function or Task.detached.
- async let: Creates a child task immediately. Only use when you actually need parallelism.
- TaskGroup: Prefer over multiple
async let when the number of operations is dynamic.
- Sendable checking: Zero runtime cost. It is compile-time only.
- When NOT to use async/await: Pure synchronous computation, simple property access, performance-critical inner loops.
Reference Files
- async-await.md — async/await, Task, TaskGroup, continuations, AsyncSequence
- actors.md — actor, @MainActor, GlobalActor, nonisolated, Sendable
- patterns.md — common patterns, Swift 6 migration, Combine vs async/await
Related Skills
swift-concurrency-expert — Swift concurrency
ios-performance — performance with concurrency
ios-networking — async networking
GitNexus Index
This skill is indexed by GitNexus for knowledge graph traversal.
Index path: /Users/localuser/.claude/skills/ios-concurrency/.gitnexus
Last indexed: 2026-05-23
1---2name: ios-concurrency3description: Swift Concurrency expert skill covering async/await, structured concurrency (Task, TaskGroup), actors and @MainActor, Sendable protocol and checking, AsyncSequence/AsyncStream, continuations for bridging callback APIs, Swift 6 strict concurrency mode, and common concurrency patterns (debouncing, throttling, actor-based shared state, background processing). Use this skill whenever the user writes concurrent Swift code, works with async/await, actors, or Sendable, migrates to Swift 6 concurrency, or needs to handle background work and thread safety. Triggers on: async, await, Task, TaskGroup, actor, @MainActor, Sendable, @Sendable, concurrency, AsyncSequence, AsyncStream, continuation, withCheckedContinuation, nonisolated, GlobalActor, Swift 6, strict concurrency, data race, thread safety, background task, parallel, concurrent, dispatch queue migration, or any Swift concurrency question.4---56# iOS Concurrency Skill78## Core Rules9101. **Use async/await for ALL new asynchronous code.** Do not use completion handlers or Combine for async operations in new code.112. **Use structured concurrency (Task, TaskGroup).** Avoid unstructured task leaks. Prefer `async let` or `TaskGroup` over spawning loose `Task {}` blocks.123. **Mark UI-updating code with @MainActor.** SwiftUI views are already `@MainActor`-isolated. UIKit code that touches UI must run on `@MainActor`.134. **Use actors for shared mutable state** instead of locks, semaphores, or serial dispatch queues.145. **All types crossing actor boundaries must be Sendable.** The compiler enforces this in strict concurrency mode.156. **Prefer value types (struct, enum) for Sendable.** They are implicitly Sendable when all stored properties are Sendable.167. **Use `withCheckedContinuation` to bridge callback-based APIs** to async/await. Never resume a continuation more than once.178. **Use `AsyncStream` for bridging delegate/callback patterns** to `AsyncSequence`.189. **`Task.detached` is rarely needed.** Use `Task {}` with explicit actor isolation instead. Detached tasks lose actor context and priority inheritance.1910. **Always handle Task cancellation cooperatively.** Check `Task.isCancelled` or call `try Task.checkCancellation()` at appropriate points.2021## Decision Guide2223| Scenario | Solution |24|----------|----------|25| Single async operation | `async func` / `Task {}` |26| Multiple independent operations | `TaskGroup` / `async let` |27| Sequential dependent operations | `await` one after another |28| Shared mutable state | `actor` |29| UI updates from background | `@MainActor` / `MainActor.run {}` |30| Bridge callback API | `withCheckedContinuation` / `withCheckedThrowingContinuation` |31| Bridge delegate pattern | `AsyncStream` with continuation |32| Streaming data | `AsyncSequence` / `AsyncStream` |33| Debounce user input | Task cancellation pattern |34| Non-Sendable legacy type | `@preconcurrency import` / `@unchecked Sendable` (last resort) |35| Combine publisher to async | `.values` property on publisher |36| Timer / periodic work | `AsyncTimerSequence` (from swift-async-algorithms) or `AsyncStream` |37| Parallel with limit | `TaskGroup` with semaphore-like counter |3839## Quick Reference: async/await4041```swift42// Basic async function43func fetchUser(id: String) async throws -> User {44 let (data, response) = try await URLSession.shared.data(from: url)45 guard let httpResponse = response as? HTTPURLResponse,46 httpResponse.statusCode == 200 else {47 throw APIError.invalidResponse48 }49 return try JSONDecoder().decode(User.self, from: data)50}5152// Calling from SwiftUI53.task {54 do {55 user = try await fetchUser(id: "123")56 } catch {57 errorMessage = error.localizedDescription58 }59}6061// Parallel execution with async let62async let profile = fetchProfile(id: userId)63async let posts = fetchPosts(userId: userId)64async let followers = fetchFollowers(userId: userId)65let result = try await (profile, posts, followers)6667// TaskGroup for dynamic parallelism68func fetchAllUsers(ids: [String]) async throws -> [User] {69 try await withThrowingTaskGroup(of: User.self) { group in70 for id in ids {71 group.addTask { try await self.fetchUser(id: id) }72 }73 var users: [User] = []74 for try await user in group {75 users.append(user)76 }77 return users78 }79}80```8182## Quick Reference: Actors8384```swift85// Actor for shared state86actor ImageCache {87 private var cache: [URL: UIImage] = [:]8889 func image(for url: URL) -> UIImage? {90 cache[url]91 }9293 func store(_ image: UIImage, for url: URL) {94 cache[url] = image95 }96}9798// Usage — await is required to cross isolation boundary99let cache = ImageCache()100await cache.store(image, for: url)101let cached = await cache.image(for: url)102103// @MainActor for UI104@MainActor105final class ViewModel: ObservableObject {106 @Published var items: [Item] = []107 @Published var isLoading = false108109 func loadItems() async {110 isLoading = true111 defer { isLoading = false }112 do {113 items = try await api.fetchItems()114 } catch {115 // handle error116 }117 }118}119```120121## Quick Reference: Sendable122123```swift124// Value types — automatically Sendable if all members are125struct UserDTO: Sendable {126 let id: String127 let name: String128}129130// Reference types — must be final with immutable stored properties131final class Configuration: Sendable {132 let apiKey: String133 let baseURL: URL134 init(apiKey: String, baseURL: URL) {135 self.apiKey = apiKey136 self.baseURL = baseURL137 }138}139140// @unchecked Sendable — escape hatch (use with caution)141final class LegacyManager: @unchecked Sendable {142 private let lock = NSLock()143 private var _state: State = .idle144 var state: State {145 lock.withLock { _state }146 }147}148```149150## Quick Reference: Continuations151152```swift153// Bridge completion handler to async/await154func fetchData() async throws -> Data {155 try await withCheckedThrowingContinuation { continuation in156 legacyAPI.fetch { result in157 switch result {158 case .success(let data):159 continuation.resume(returning: data)160 case .failure(let error):161 continuation.resume(throwing: error)162 }163 }164 }165}166167// Bridge delegate to AsyncStream168func locationUpdates() -> AsyncStream<CLLocation> {169 AsyncStream { continuation in170 let delegate = LocationDelegate { location in171 continuation.yield(location)172 }173 continuation.onTermination = { _ in174 delegate.stop()175 }176 delegate.start()177 }178}179```180181## Quick Reference: Task Cancellation182183```swift184// Cooperative cancellation185func processItems(_ items: [Item]) async throws {186 for item in items {187 try Task.checkCancellation() // throws CancellationError188 await process(item)189 }190}191192// Manual check193func fetchWithFallback() async -> Data {194 if Task.isCancelled { return Data() }195 // ... continue work196}197198// Debounce pattern199@MainActor200final class SearchViewModel: ObservableObject {201 @Published var query = ""202 @Published var results: [Result] = []203 private var searchTask: Task<Void, Never>?204205 func search() {206 searchTask?.cancel()207 searchTask = Task {208 try? await Task.sleep(for: .milliseconds(300))209 guard !Task.isCancelled else { return }210 results = await api.search(query)211 }212 }213}214```215216## Swift 6 Strict Concurrency Quick Guide217218```swift219// Enable in Package.swift220.target(221 name: "MyTarget",222 swiftSettings: [.swiftLanguageMode(.v6)]223)224225// Or in Xcode: Build Settings → Swift Language Version → 6226227// Common fixes:228// 1. Non-Sendable type crossing isolation boundary229// → Make type Sendable or use @unchecked Sendable230231// 2. Mutable capture in @Sendable closure232// → Use actor or move state inside Task233234// 3. Global variable not concurrency-safe235// → Use actor, nonisolated(unsafe), or make it let236237// 4. Legacy framework types not Sendable238// → @preconcurrency import FrameworkName239```240241## Anti-Patterns to Avoid242243```swift244// BAD: Using Task.detached without good reason245Task.detached {246 await self.doWork() // loses actor isolation and priority247}248249// GOOD: Use Task {} — inherits actor context250Task {251 await doWork()252}253254// BAD: Blocking an actor with synchronous work255actor DataProcessor {256 func process(_ data: Data) -> Result {257 heavySyncComputation(data) // blocks the actor's executor258 }259}260261// GOOD: Move heavy sync work off the actor262actor DataProcessor {263 func process(_ data: Data) async -> Result {264 await Task.detached(priority: .utility) {265 heavySyncComputation(data) // runs on cooperative pool266 }.value267 }268}269270// BAD: Ignoring cancellation271Task {272 for item in hugeList {273 await process(item) // never checks cancellation274 }275}276277// GOOD: Cooperative cancellation278Task {279 for item in hugeList {280 try Task.checkCancellation()281 await process(item)282 }283}284285// BAD: Resuming continuation multiple times (CRASH)286withCheckedContinuation { continuation in287 api.fetch { data in288 continuation.resume(returning: data)289 }290 api.fetch { data in // second resume — CRASH291 continuation.resume(returning: data)292 }293}294295// BAD: Never resuming continuation (LEAK — task hangs forever)296withCheckedContinuation { continuation in297 api.fetch { data in298 if let data = data {299 continuation.resume(returning: data)300 }301 // if data is nil, continuation is never resumed!302 }303}304```305306## Performance Considerations307308- **Task creation overhead**: ~1-2 microseconds. Do not create tasks in tight loops for trivial work.309- **Actor contention**: If many tasks await the same actor, they serialize. Keep actor methods fast.310- **MainActor bottleneck**: Do not run heavy computation on `@MainActor`. Offload to a non-isolated async function or `Task.detached`.311- **async let**: Creates a child task immediately. Only use when you actually need parallelism.312- **TaskGroup**: Prefer over multiple `async let` when the number of operations is dynamic.313- **Sendable checking**: Zero runtime cost. It is compile-time only.314- **When NOT to use async/await**: Pure synchronous computation, simple property access, performance-critical inner loops.315316## Reference Files317318- [async-await.md](references/async-await.md) — async/await, Task, TaskGroup, continuations, AsyncSequence319- [actors.md](references/actors.md) — actor, @MainActor, GlobalActor, nonisolated, Sendable320- [patterns.md](references/patterns.md) — common patterns, Swift 6 migration, Combine vs async/await321322## Related Skills323- `swift-concurrency-expert` — Swift concurrency324- `ios-performance` — performance with concurrency325- `ios-networking` — async networking326327## GitNexus Index328This skill is indexed by GitNexus for knowledge graph traversal.329Index path: /Users/localuser/.claude/skills/ios-concurrency/.gitnexus330Last indexed: 2026-05-23