Swift Concurrency: The Complete Guide
Published on Β· Updated Β· 21 min
Swift Concurrency revolutionized asynchronous programming on Apple platforms. Introduced with Swift 5.5 in 2021, the model has evolved significantly up to Swift 6.2 which finally simplifies its usage with "Approachable Concurrency". This guide covers everything you need to know, from beginner to expert.
Why Swift Concurrency?
Before Swift 5.5, asynchronous programming relied on callbacks, completion closures, and Grand Central Dispatch (GCD). This model worked but created problems:
Pyramid of doom with nested callbacks
Fragmented error handling that's easy to forget
No protection against data races
Code that's hard to read and maintain
Swift Concurrency solves these problems with a model built into the language. The compiler verifies concurrent access safety and async code looks like synchronous code.
Fundamentals: async/await
The async keyword marks a function that can suspend its execution. The await keyword indicates a potential suspension point.
A crucial point: await never blocks the thread. It frees the thread for other tasks while waiting. This is fundamentally different from a sleep or synchronous wait.
Suspension and Continuations
When the runtime encounters an await, it splits your function into "partial tasks". Each segment between two await calls is an execution unit that the runtime can schedule independently.
The runtime can interleave these partial tasks with those of other async functions, optimizing system resource usage.
Task: The Unit of Asynchronous Work
Task is the fundamental container for asynchronous work. It provides an execution context, manages priorities, and propagates cancellation.
Task Types
Swift offers several Task types depending on your needs:
The difference between Task and Task.detached is important. A standard Task inherits the caller's context (actor, priority, task-local values). A Task.detached starts in a clean context.
Task Priorities
Swift defines several priority levels that influence scheduling:
The runtime can also perform "priority escalation": if a high-priority task awaits a low-priority task, the latter is temporarily promoted.
async let: Simple Parallelism
When you have multiple independent operations, async let executes them in parallel:
Note the difference with sequential execution:
async let creates child tasks that are automatically cancelled if an error occurs or the scope ends.
Cooperative Cancellation
Swift uses a cooperative cancellation model. When you cancel a Task, it's not killed abruptly. The runtime marks it as cancelled, and it's up to your code to check and react.
Two methods to check for cancellation:
withTaskCancellationHandler
For long-running operations with callbacks, use withTaskCancellationHandler:
Async Error Handling
async throws functions propagate errors naturally:
With async let, if a parallel task fails, the others are automatically cancelled:
SwiftUI Integration
SwiftUI integrates naturally with Swift Concurrency via the .task modifier:
The .task(id:) modifier automatically restarts when the identifier changes:
Swift 6.2: Approachable Concurrency
Swift 6.2 (Xcode 26) introduces major changes to simplify concurrency. The "Improving the approachability of data-race safety" document acknowledges that the model had become too complex.
MainActor by Default (SE-0466)
The most impactful change: you can configure an entire module to run on MainActor by default.
With this setting, all code without explicit annotation runs on MainActor:
In Xcode 26, new projects have this option enabled by default.
Async on Caller's Actor (SE-0461)
Before Swift 6.2, nonisolated async functions automatically jumped to a background thread:
With Swift 6.2, async functions stay on the caller's actor:
This behavior is enabled via the upcoming feature NonisolatedNonsendingByDefault.
@concurrent: Explicit Opt-in
If you want a function to truly execute in parallel, Swift 6.2 introduces @concurrent:
This explicit distinction makes code much more readable.
Isolated Conformances (SE-0470)
SE-0470 allows restricting a protocol conformance to a specific actor:
Without @MainActor Equatable, the compiler could execute == on any thread, violating the class isolation.
Best Practices
1. Prefer Structured Concurrency
Use async let and TaskGroup rather than Task.detached when possible. Structured concurrency ensures child tasks are automatically cleaned up.
2. Check Cancellation Regularly
In long loops, periodically check for cancellation:
3. Avoid Strong Captures in Tasks
Use [weak self] to avoid retain cycles:
4. Use the Right Isolation Level
Don't put everything on MainActor. Reserve it for UI code:
Common Pitfalls
1. Forgetting await
The compiler warns you, but be careful in contexts where await is implicit:
2. Blocking the MainActor
Avoid long operations on MainActor:
3. Task.sleep vs Synchronous Sleep
Always use Task.sleep for delays:
Migrating from GCD
If you have existing GCD code, here are the equivalences:
| GCD | Swift Concurrency |
|---|---|
DispatchQueue.main.async | Task { @MainActor in } |
DispatchQueue.global().async | Task.detached { } |
DispatchGroup | TaskGroup |
DispatchSemaphore | AsyncStream or Actors |
DispatchWorkItem.cancel() | task.cancel() |
Migration example:
Going Further
Swift Concurrency is a vast topic. Here are the recommended next steps:
TaskGroup and ThrowingTaskGroup for dynamic parallelism
Actors for shared data isolation
AsyncStream for asynchronous value streams
Sendable and data isolation