Structured Concurrency in Swift: Stop Leaking Tasks and Data Races
Structured concurrency in Swift replaces nested callbacks with scope-bound tasks, actors, and MainActor isolation. Learn how TaskGroup provides safe fan-out/fan-in and where Swift 6 strict checking changes migration.
17 Jun 2026, 19:50 UTC

Callback chains in Apple apps tend to end the same way: a completion handler spawns another completion handler, mutable state is touched from multiple queues, and a view controller disappears while background work keeps running. Structured concurrency in Swift, introduced in Swift 5.5 and back-deployed to iOS 13/macOS 10.15+, replaces that pattern with a scope-based model where tasks live inside their parent and cancellation is automatic.
The useful takeaway is not just readability. It is that the compiler and runtime now enforce lifetimes and isolation, so leaked work and data races become design problems instead of production bugs.
Scope as a safety net
Structured concurrency ties child work to the lifetime of its parent. async let and TaskGroup are structured: the enclosing async function does not return until the children finish or are cancelled. That prevents the classic leak where a network request completes after a screen is dismissed and tries to update deallocated UI.
Unstructured Task {} is different. It inherits the actor context but is not automatically cancelled when the spawning scope exits. Use it only when you intentionally want fire-and-forget work, and understand you must manage cancellation yourself.
Cancellation is cooperative. A task checks Task.isCancelled or calls Task.checkCancellation(). Long-running loops need explicit cancellation points, otherwise the task will run to completion even after the parent is cancelled.
Actors and MainActor replace manual synchronization
An actor serializes access to its mutable state. Marking shared state as an actor replaces manual locks or dispatch queues for many common cases, and Swift 6 language mode makes data-race violations errors instead of warnings.
@MainActor is actor isolation for the main thread. Annotating a class or function with @MainActor makes accidental background UI mutation a compile-time issue. In Swift 5 mode the checker is advisory; in Swift 6 mode it is strict. Codebases can adopt Swift 6 mode per module while the rest stays in Swift 5 mode.
Concurrency checking behavior differs significantly between modes. A codebase that compiles cleanly in Swift 5 mode can fail in Swift 6 mode. Verify per module and fix diagnostics with actors or @MainActor annotations rather than disabling checks.
Fan-out with TaskGroup
TaskGroup is the right tool for fan-out/fan-in work such as fetching several resources concurrently and collecting results.
Run this in a Swift package or playground with a deployment target that supports async/await. No special permissions are required, but network access follows normal App Transport Security rules.
import Foundation
func fetchAll(urls: [URL]) async throws -> [Data] {
try await withThrowingTaskGroup(of: Data.self) { group in
for url in urls {
group.addTask {
let (data, _) = try await URLSession.shared.data(from: url)
return data
}
}
var results: [Data] = []
for try await data in group {
results.append(data)
}
return results
}
}
The group ensures all child tasks are cancelled if the parent is cancelled, and the function does not return until all results are collected or an error is thrown. Use for try await to process results in completion order.
A practical way to check the behavior is to create a small test module that launches a group with artificial delays, cancels the parent task mid-flight, and asserts that remaining children stop making progress. Another check is to enable strict concurrency checking on a sample module and observe which data-race diagnostics appear, then fix them with actors.
Trade-offs and limits
Back-deployment has runtime caveats. Features are available on older OS versions via the Swift runtime, but behavior can differ on the minimum deployment target. Verify against your actual minimum OS version rather than assuming parity.
Swift 6 strict checking is valuable but invasive. Migrating large codebases requires incremental adoption and careful handling of legacy APIs that are not concurrency-safe. Unstructured tasks can surprise developers expecting structured semantics, leading to work that outlives its intended scope.
Cooperative cancellation means you must design for it. If a task performs CPU-bound work without cancellation points, it will not respond promptly to cancellation.
Start by isolating UI state with @MainActor, replace shared mutable structs with actors, and use TaskGroup for concurrent collections. Keep unstructured tasks explicit and audited. That moves concurrency from runtime hope to compile-time structure.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.