Complete guide to Swift's structured concurrency model — async/await, actors, sendable types, task groups, and AsyncSequence for modern iOS development.
Before Swift concurrency, asynchronous iOS code relied on completion handlers, delegate callbacks, and Grand Central Dispatch — patterns that led to deeply nested callback pyramids, difficult error propagation, and subtle concurrency bugs. Swift's async/await syntax transforms asynchronous code into a linear, readable flow that looks and behaves like synchronous code while executing asynchronously. An async function suspends at each await point, yielding its thread to other work, and resumes when the awaited result is available. This suspension mechanism is cooperative and lightweight — unlike threads, which consume significant memory and system resources, Swift tasks can number in the tens of thousands with minimal overhead. Error handling integrates naturally with try/catch rather than requiring separate error parameters in callbacks. The result is code that is dramatically easier to read, write, debug, and maintain. Converting existing completion handler-based APIs to async/await is straightforward using withCheckedContinuation or withCheckedThrowingContinuation.
Actors are reference types that protect their mutable state from concurrent access by serialising all access through the actor's executor. When you define a class as an actor, the compiler ensures that any access to the actor's properties or methods from outside the actor's isolation domain requires an await, because the access may need to wait for other in-progress work to complete. This compile-time enforcement eliminates data races — a category of bugs that are notoriously difficult to reproduce and debug. The @MainActor annotation designates code that must run on the main thread, replacing the common pattern of manually dispatching to DispatchQueue.main for UI updates. In SwiftUI, views and their body computations are automatically MainActor-isolated, and view models can be annotated with @MainActor to ensure all property changes that drive UI updates happen on the main thread. Global actors allow you to define custom isolation domains for specific subsystems, such as a database actor that serialises all database operations.
Task groups enable parallel execution of multiple independent operations with automatic lifecycle management. When you create a withTaskGroup block, you can add child tasks that execute concurrently, and the group automatically waits for all children to complete before the block exits. If any child task throws an error, the group automatically cancels all remaining children — a behaviour called structured cancellation that prevents orphaned work. This structured approach contrasts with unstructured concurrency (Task { } blocks) where the developer is responsible for managing task lifetimes. Use TaskGroup when you have a known set of independent operations — for example, fetching data from multiple API endpoints simultaneously, processing images in parallel, or performing concurrent search across multiple data sources. The group collects results as each child completes, and you can process results in completion order for maximum throughput. ThrowingTaskGroup provides the same functionality with support for throwing child tasks. Detached tasks, created with Task.detached { }, should be used sparingly as they inherit neither the priority nor the actor context of the calling code.
AsyncSequence provides a protocol for types that produce values over time, enabling for-await-in loops that consume values as they become available. This pattern replaces many uses of delegates, NotificationCenter observers, and Combine publishers with a simpler, more intuitive syntax. AsyncStream provides a concrete implementation of AsyncSequence that bridges callback-based APIs to the async world — create a stream with a continuation, yield values from callbacks, and consume them with a for-await-in loop. This is particularly useful for wrapping Core Bluetooth delegate callbacks, CLLocationManager updates, or any API that delivers values over time through a delegate or closure pattern. AsyncSequence supports standard sequence operations like map, filter, compactMap, and reduce, enabling functional data processing of asynchronous value streams.
Swift 6.0 introduces strict sendable checking that ensures values passed between concurrency domains are safe to share. A Sendable type is one that can be safely transferred between actors and tasks without risking data races. Value types (structs and enums) composed entirely of sendable properties are implicitly Sendable. Classes must explicitly conform to Sendable and are only valid if they are final with immutable stored properties, or if they implement their own synchronisation. The @Sendable attribute on closures ensures that closures passed to concurrent contexts do not capture mutable state unsafely. In Swift 6.0's strict concurrency mode, the compiler produces errors (not just warnings) for sendable violations, catching potential data races before they reach production. Adopting strict concurrency checking incrementally — enabling it per module and fixing violations systematically — is the recommended approach for existing codebases. New projects should enable strict concurrency from the start.
Use async/await for most asynchronous operations. Combine remains useful for reactive UI bindings and complex event stream processing, but async/await is simpler, more readable, and directly supported by the language. Apple's direction is clearly toward async/await as the primary concurrency mechanism.
Use withCheckedContinuation or withCheckedThrowingContinuation to wrap completion handler-based functions. Call resume(returning:) or resume(throwing:) exactly once in the completion handler. The checked variants crash with a diagnostic message if resume is called more than once or not at all.