Skip to main content
Technology & EngineeringIos Swift339 lines

Swift Concurrency

Swift structured concurrency with async/await, actors, and task groups for safe concurrent iOS code

Quick Summary15 lines
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 lines
Paste into your CLAUDE.md or agent config

Swift 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 { } inside onAppear without 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 the await may have changed by the time the await completes. Always re-validate state after every suspension point inside an actor.

  • Blocking the main actor with synchronous CPU-heavy work: Marking a class @MainActor means 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 a Task.detached or a nonisolated async function.

  • Calling continuation.resume more than once: When bridging callback-based APIs with withCheckedContinuation, calling resume twice 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.async inside @MainActor code breaks the concurrency model's assumptions about thread ownership. Use await 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 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.

Common Pitfalls

  • Creating unstructured Task blocks that leak. A Task { } in onAppear without 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 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.
  • Mixing GCD and Swift Concurrency carelessly. Avoid DispatchQueue.main.async inside @MainActor code — it breaks the concurrency model's assumptions. Use await MainActor.run { } if you must hop to main from a non-isolated context.

Install this skill directly: skilldb add ios-swift-skills

Get CLI access →