Swift Concurrency
Swift structured concurrency with async/await, actors, and task groups for safe concurrent iOS code
You are an expert in Swift Concurrency for building iOS applications with Swift.
## Key Points
- **Prefer structured concurrency** (`async let`, `TaskGroup`) over unstructured `Task { }`. Structured tasks propagate cancellation and errors automatically.
- **Use `.task {}` in SwiftUI** instead of `Task { }` inside `.onAppear`. The `.task` modifier manages cancellation on disappear.
- **Mark view models `@MainActor`** to ensure all `@Published` property updates happen on the main thread without manual dispatching.
- **Respect cancellation.** Check `Task.isCancelled` or call `try Task.checkCancellation()` in long-running loops. Well-behaved async functions should stop work promptly when cancelled.
- **Use actors for shared mutable state** instead of locks or serial dispatch queues. The compiler enforces isolation.
- **Adopt `Sendable` incrementally.** Enable strict concurrency checking (`-strict-concurrency=complete`) and fix warnings to prepare for Swift 6.
- **Actor reentrancy.** An actor method that `await`s something allows other callers to interleave. State may change between suspension points. Always re-check state after `await`.
- **Blocking the main actor.** Synchronous CPU-heavy work on a `@MainActor` class still blocks the UI. Offload computation with `Task.detached` or a non-isolated async function.
- **Calling `continuation.resume` more than once.** `withCheckedContinuation` traps on double resume in debug builds. Ensure callback-based APIs invoke the callback exactly once.skilldb get ios-swift-skills/Swift ConcurrencyFull skill: 339 linesSwift Concurrency — iOS/Swift
You are an expert in Swift Concurrency for building iOS applications with Swift.
Core Philosophy
Swift Concurrency exists to make concurrent code safe by default. The traditional approach of using GCD queues, locks, and callbacks put the burden of correctness entirely on the developer -- data races were easy to introduce and hard to find. With actors, Sendable, and structured concurrency, the compiler catches entire categories of concurrency bugs at build time. The goal is not just making async code easier to write, but making unsafe concurrent code impossible to compile.
Structured concurrency means that the lifetime of concurrent work is tied to a scope. When you use async let or TaskGroup, child tasks are automatically cancelled if their parent scope exits. This is fundamentally different from firing off a DispatchQueue.async block that runs independently of everything else. Structured concurrency makes it possible to reason about what concurrent work is happening and when it will end, which is essential for resource management and avoiding leaks.
The @MainActor annotation is not a convenience -- it is a correctness tool. UI state must only be mutated on the main thread, and @MainActor enforces this at compile time rather than relying on developers to remember DispatchQueue.main.async. Annotating view models and UI-related types with @MainActor eliminates the most common source of threading bugs in iOS apps: updating @Published properties from background threads.
Anti-Patterns
-
Creating unstructured Task blocks that leak: Using
Task { }insideonAppearwithout storing and cancelling it means the work continues even after the view disappears. The.task {}modifier handles cancellation automatically and should be preferred for any async work tied to a view's lifecycle. -
Assuming actor methods execute atomically across suspension points: An actor serializes access, but when an actor method hits an
await, other callers can interleave. State you checked before theawaitmay have changed by the time theawaitcompletes. Always re-validate state after every suspension point inside an actor. -
Blocking the main actor with synchronous CPU-heavy work: Marking a class
@MainActormeans all its methods run on the main thread. If one of those methods does expensive computation (sorting a large array, image processing), the UI freezes. Offload heavy work to aTask.detachedor a nonisolated async function. -
Calling continuation.resume more than once: When bridging callback-based APIs with
withCheckedContinuation, callingresumetwice traps in debug builds and is undefined behavior in release builds. Ensure the underlying API invokes its callback exactly once, and guard against double invocation if it does not. -
Mixing GCD and Swift Concurrency in the same code path: Using
DispatchQueue.main.asyncinside@MainActorcode breaks the concurrency model's assumptions about thread ownership. Useawait MainActor.run { }when you need to hop to main from a nonisolated context. The two concurrency models should not be interleaved.
Overview
Swift Concurrency (introduced in Swift 5.5 / iOS 15) provides first-class language support for asynchronous and concurrent programming. It replaces callback-based patterns with async/await, prevents data races at compile time with actors and Sendable, and organizes concurrent work through structured concurrency (task groups, child tasks). It integrates deeply with SwiftUI via .task {} and @MainActor.
Core Concepts
async/await
Functions marked async can suspend execution without blocking a thread. Calling them requires await:
func fetchUser(id: String) async throws -> User {
let url = URL(string: "https://api.example.com/users/\(id)")!
let (data, response) = try await URLSession.shared.data(from: url)
guard let http = response as? HTTPURLResponse,
(200..<300).contains(http.statusCode) else {
throw APIError.invalidResponse
}
return try JSONDecoder().decode(User.self, from: data)
}
// Calling site
let user = try await fetchUser(id: "42")
Structured Concurrency with TaskGroup
TaskGroup runs multiple child tasks concurrently and collects results. Child tasks are automatically cancelled if the group scope exits:
func fetchDashboard() async throws -> Dashboard {
async let profile = fetchProfile()
async let notifications = fetchNotifications()
async let feed = fetchFeed()
return try await Dashboard(
profile: profile,
notifications: notifications,
feed: feed
)
}
For dynamic numbers of tasks, use withTaskGroup or withThrowingTaskGroup:
func fetchAllThumbnails(ids: [String]) async throws -> [String: UIImage] {
try await withThrowingTaskGroup(of: (String, UIImage).self) { group in
for id in ids {
group.addTask {
let image = try await downloadThumbnail(id: id)
return (id, image)
}
}
var results: [String: UIImage] = [:]
for try await (id, image) in group {
results[id] = image
}
return results
}
}
async let
async let launches a child task immediately and suspends only when you await the result:
func loadScreen() async throws -> ScreenData {
async let weather = WeatherService.current()
async let news = NewsService.topStories()
// Both requests are in-flight concurrently
let w = try await weather
let n = try await news
return ScreenData(weather: w, news: n)
}
Actors
Actors serialize access to their mutable state, eliminating data races:
actor ImageCache {
private var cache: [URL: UIImage] = [:]
private var inFlight: [URL: Task<UIImage, Error>] = [:]
func image(for url: URL) async throws -> UIImage {
if let cached = cache[url] {
return cached
}
if let existing = inFlight[url] {
return try await existing.value
}
let task = Task {
let (data, _) = try await URLSession.shared.data(from: url)
guard let image = UIImage(data: data) else {
throw ImageError.decodingFailed
}
return image
}
inFlight[url] = task
let image = try await task.value
cache[url] = image
inFlight[url] = nil
return image
}
func clear() {
cache.removeAll()
}
}
@MainActor
@MainActor ensures code runs on the main thread — critical for UI updates:
@MainActor
class ProfileViewModel: ObservableObject {
@Published var user: User?
@Published var isLoading = false
@Published var errorMessage: String?
func load() async {
isLoading = true
defer { isLoading = false }
do {
user = try await UserService.fetchCurrentUser()
} catch {
errorMessage = error.localizedDescription
}
}
}
SwiftUI views are implicitly @MainActor. Use .task {} to launch async work tied to a view's lifecycle:
struct ProfileView: View {
@StateObject private var viewModel = ProfileViewModel()
var body: some View {
Group {
if viewModel.isLoading {
ProgressView()
} else if let user = viewModel.user {
UserDetailView(user: user)
}
}
.task {
await viewModel.load()
}
}
}
Sendable
Sendable marks types safe to pass across concurrency domains. The compiler enforces this:
// Value types are implicitly Sendable when all stored properties are Sendable
struct Message: Sendable {
let id: UUID
let text: String
let timestamp: Date
}
// Classes must be final, with immutable Sendable properties
final class Config: Sendable {
let apiKey: String
let baseURL: URL
init(apiKey: String, baseURL: URL) {
self.apiKey = apiKey
self.baseURL = baseURL
}
}
// Use @Sendable for closures crossing concurrency boundaries
func process(items: [Item], transform: @Sendable (Item) -> Result) async -> [Result] {
await withTaskGroup(of: Result.self) { group in
for item in items {
group.addTask { transform(item) }
}
var results: [Result] = []
for await result in group {
results.append(result)
}
return results
}
}
Implementation Patterns
Cancellation Handling
func fetchWithCancellation() async throws -> [Article] {
var articles: [Article] = []
for page in 1...10 {
// Check before expensive work
try Task.checkCancellation()
let pageArticles = try await fetchPage(page)
articles.append(contentsOf: pageArticles)
if pageArticles.isEmpty { break }
}
return articles
}
// In SwiftUI — .task automatically cancels when the view disappears
struct ArticleList: View {
@State private var articles: [Article] = []
var body: some View {
List(articles) { article in
ArticleRow(article: article)
}
.task {
// Automatically cancelled on disappear
do {
articles = try await fetchWithCancellation()
} catch is CancellationError {
// View disappeared, ignore
} catch {
print("Error: \(error)")
}
}
}
}
AsyncSequence for Streams
// Monitor a file for changes using an AsyncStream
func fileChanges(at path: String) -> AsyncStream<Data> {
AsyncStream { continuation in
let source = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: open(path, O_EVTONLY),
eventMask: .write,
queue: .global()
)
source.setEventHandler {
if let data = FileManager.default.contents(atPath: path) {
continuation.yield(data)
}
}
continuation.onTermination = { _ in
source.cancel()
}
source.resume()
}
}
// Consuming
for await data in fileChanges(at: configPath) {
try await processConfig(data)
}
Bridging Callback APIs
func currentLocation() async throws -> CLLocation {
try await withCheckedThrowingContinuation { continuation in
locationManager.requestLocation { result in
switch result {
case .success(let location):
continuation.resume(returning: location)
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
Best Practices
- Prefer structured concurrency (
async let,TaskGroup) over unstructuredTask { }. Structured tasks propagate cancellation and errors automatically. - Use
.task {}in SwiftUI instead ofTask { }inside.onAppear. The.taskmodifier manages cancellation on disappear. - Mark view models
@MainActorto ensure all@Publishedproperty updates happen on the main thread without manual dispatching. - Respect cancellation. Check
Task.isCancelledor calltry Task.checkCancellation()in long-running loops. Well-behaved async functions should stop work promptly when cancelled. - Use actors for shared mutable state instead of locks or serial dispatch queues. The compiler enforces isolation.
- Adopt
Sendableincrementally. Enable strict concurrency checking (-strict-concurrency=complete) and fix warnings to prepare for Swift 6.
Common Pitfalls
- Creating unstructured
Taskblocks that leak. ATask { }inonAppearwithout cancellation continues running even after the view disappears. Use.task {}or store and cancel the task manually. - Actor reentrancy. An actor method that
awaits something allows other callers to interleave. State may change between suspension points. Always re-check state afterawait. - Blocking the main actor. Synchronous CPU-heavy work on a
@MainActorclass still blocks the UI. Offload computation withTask.detachedor a non-isolated async function. - Calling
continuation.resumemore than once.withCheckedContinuationtraps on double resume in debug builds. Ensure callback-based APIs invoke the callback exactly once. - Mixing GCD and Swift Concurrency carelessly. Avoid
DispatchQueue.main.asyncinside@MainActorcode — it breaks the concurrency model's assumptions. Useawait MainActor.run { }if you must hop to main from a non-isolated context.
Install this skill directly: skilldb add ios-swift-skills
Related Skills
App Architecture
MVVM and Clean Architecture patterns for structuring scalable, testable iOS applications
Combine
Combine framework reactive programming patterns for handling asynchronous events in iOS
Core Data
Core Data persistence framework for modeling, storing, and querying structured data in iOS apps
Navigation
SwiftUI navigation patterns using NavigationStack, programmatic routing, and deep linking
Networking
URLSession networking patterns for building robust API clients and handling data transfer in iOS
Swiftui
SwiftUI declarative UI framework fundamentals for building modern iOS interfaces