On this page 11 sections
- The mental model in four sentences
- async and await
- Running work in parallel: async let and task groups
- Actors: shared mutable state without locks
- The main actor and default isolation
- Sendable: what can cross between tasks
- Cancellation
- Bridging older callback APIs
- Unstructured tasks: use sparingly
- A short checklist when adopting concurrency
- Key takeaways
The mental model in four sentences
An async function can pause at an await without blocking the thread it’s on. A task is a unit of asynchronous work; every async function runs inside one. An actor protects its own mutable state so that only one task touches it at a time. The main actor is the actor for everything that drives the UI.
Everything else is detail on top of those four ideas.
async and await
Here is a network call written the modern way:
struct Forecast: Decodable, Sendable {
let summary: String
let highCelsius: Double
}
struct WeatherClient: Sendable {
let baseURL: URL
let session: URLSession
func forecast(for city: String) async throws -> Forecast {
var components = URLComponents(url: baseURL.appending(path: "forecast"),
resolvingAgainstBaseURL: false)!
components.queryItems = [URLQueryItem(name: "city", value: city)]
let (data, response) = try await session.data(from: components.url!)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
throw URLError(.badServerResponse)
}
return try JSONDecoder().decode(Forecast.self, from: data)
}
}Errors propagate with throws, the result comes back as a return value, and there’s no callback to forget to call on an error path. Each await marks a suspension point: a place where other work can run before your function continues. That matters later, when we get to actors.
Running work in parallel: async let and task groups
Sequential awaits run one after another. When two calls don’t depend on each other, start them together with async let:
func loadDashboard(for userID: User.ID) async throws -> Dashboard {
async let profile = api.profile(id: userID)
async let orders = api.recentOrders(for: userID)
async let messages = api.unreadMessages(for: userID)
return try await Dashboard(profile: profile, orders: orders, messages: messages)
}All three requests are in flight at once. If any one throws, the others are cancelled automatically and the error comes out of the await.
When the number of tasks isn’t known until runtime, use a task group:
func downloadAll(_ urls: [URL], using session: URLSession) async throws -> [URL: Data] {
try await withThrowingTaskGroup(of: (URL, Data).self) { group in
for url in urls {
group.addTask {
let (data, _) = try await session.data(from: url)
return (url, data)
}
}
var results: [URL: Data] = [:]
for try await (url, data) in group {
results[url] = data
}
return results
}
}This is structured concurrency: the child tasks can’t outlive the group, so you can’t leak work that keeps running after the function returns. Prefer it over creating unstructured Task { } blocks whenever you can.
For a long list, starting every download at once can swamp the network. A simple limit is to add a handful of tasks first, then add one more each time one finishes.
Actors: shared mutable state without locks
An actor is a reference type that serialises access to its state. Here is an image cache that also makes sure two screens asking for the same image share one download:
actor ImageDataCache {
private var stored: [URL: Data] = [:]
private var inFlight: [URL: Task<Data, Error>] = [:]
private let session: URLSession
init(session: URLSession = .shared) {
self.session = session
}
func data(for url: URL) async throws -> Data {
if let cached = stored[url] { return cached }
if let existing = inFlight[url] { return try await existing.value }
let task = Task {
let (data, _) = try await session.data(from: url)
return data
}
inFlight[url] = task
defer { inFlight[url] = nil }
let data = try await task.value
stored[url] = data
return data
}
}Callers outside the actor must await its methods, because they may have to wait their turn.
The important subtlety is reentrancy. When an actor method hits an await, the actor is free to run other calls while that one is suspended. State you checked before the await may have changed after it. The inFlight dictionary above exists precisely because of this: without it, two callers could both see an empty cache, both start a download, and both write the result. The rule of thumb is to finish reading and writing actor state before you suspend, or re-check it afterwards.
The main actor and default isolation
UIKit and SwiftUI must be used from the main thread. In Swift concurrency, that’s expressed as isolation to @MainActor. Mark your view models and anything else that drives UI with it, and the compiler stops you from touching them from a background task by accident.
Since Xcode 26, new app projects go further. They turn on the Approachable Concurrency setting and set the default actor isolation to the main actor, so code in the app target is main-actor isolated unless you say otherwise. Apple explains the reasoning in the WWDC25 session Embracing Swift concurrency: most app code is single-threaded UI code, so it should be safe by default, and you opt into concurrency where you need it. The underlying language changes are Swift Evolution proposals SE-0466 (default isolation) and SE-0461 (where nonisolated async functions run).
With those settings on, the keywords you’ll use are:
- nothing: the code runs on the main actor (in the app target);
nonisolated: the code isn’t tied to the main actor; anasyncfunction runs on whatever actor called it;@concurrent: theasyncfunction always leaves the caller’s actor and runs on the background thread pool.
So heavy work that must not block the UI looks like this:
nonisolated struct ReportBuilder {
@concurrent
func makeReport(from rows: [SaleRow]) async -> Report {
// Parsing, sorting and summing thousands of rows happens off the main actor.
let grouped = Dictionary(grouping: rows, by: \.month)
return Report(months: grouped.mapValues { $0.reduce(0) { $0 + $1.amount } })
}
}
@MainActor
@Observable
final class ReportModel {
private(set) var report: Report?
func refresh(rows: [SaleRow]) async {
report = await ReportBuilder().makeReport(from: rows)
}
}SaleRow and Report need to be Sendable for this to compile, which brings us to the next topic.
Swift packages don’t get these settings automatically. If you split your app into packages, set .defaultIsolation(MainActor.self) in a UI package’s swiftSettings if you want the same behaviour there, and leave lower-level packages nonisolated.
Sendable: what can cross between tasks
A value is Sendable if it’s safe to share between concurrent code. Structs and enums made of Sendable parts get it automatically within a module. Actors are always Sendable. Classes need care: a final class with only immutable let properties of Sendable types can conform; a class with mutable state should usually be an actor or be isolated to the main actor instead.
In the Swift 6 language mode the compiler enforces this, and the errors can be noisy when you first switch an older project over. Work through them module by module rather than all at once. Swift.org’s migration guide is the best reference, and Apple’s Adopting strict concurrency in Swift 6 apps covers the Xcode side.
Avoid reaching for @unchecked Sendable or nonisolated(unsafe) to silence a warning. They’re promises to the compiler that you’ve made the type safe by other means, such as a lock. If you haven’t, you’ve just hidden a data race.
Cancellation
Cancellation in Swift is cooperative: cancelling a task sets a flag, and the code inside decides when to stop. Most system APIs check for you. Task.sleep throws CancellationError, and URLSession throws URLError(.cancelled). In your own long loops, check explicitly:
func importRows(_ rows: [CSVRow]) async throws -> Int {
var imported = 0
for row in rows {
try Task.checkCancellation()
try await store.insert(row)
imported += 1
}
return imported
}SwiftUI makes good use of this. .task(id:) cancels the previous run whenever the id changes, which gives you a clean search-as-you-type with a short debounce:
struct SearchView: View {
@State private var query = ""
@State private var results: [Place] = []
let search: PlaceSearch
var body: some View {
List(results) { PlaceRow(place: $0) }
.searchable(text: $query)
.task(id: query) {
guard query.count >= 2 else { results = []; return }
do {
try await Task.sleep(for: .milliseconds(300))
results = try await search.places(matching: query)
} catch is CancellationError {
// A newer query replaced this one.
} catch {
results = []
}
}
}
}Each keystroke changes query, cancelling the previous task while it’s still sleeping, so only the last query after a pause reaches the network.
Bridging older callback APIs
Some APIs still use completion handlers. Wrap them once with a continuation and the rest of your code stays async:
func currentPosition(using provider: LegacyLocationProvider) async throws -> Coordinate {
try await withCheckedThrowingContinuation { continuation in
provider.requestPosition { result in
continuation.resume(with: result)
}
}
}The checked variant traps in debug builds if you resume twice or never resume, which catches the classic bugs in callback code. Resume exactly once on every path.
Unstructured tasks: use sparingly
Task { } starts work that isn’t tied to a parent. You need it at the boundary between synchronous and asynchronous code, such as a UIKit button action. Two cautions:
- It inherits the actor of the code that creates it. A
Task { }inside a main-actor method runs its synchronous parts on the main actor. - Nothing cancels it for you. If the work should stop when a screen goes away, keep a reference and cancel it, or use SwiftUI’s
.taskmodifier, which handles that for you.
Task.detached doesn’t inherit the actor or task-local values. With @concurrent available there are very few good reasons to use it in app code.
A short checklist when adopting concurrency
- Turn on the Swift 6 language mode for one module at a time, starting with the smallest.
- Mark UI-facing types
@MainActor, or rely on default main-actor isolation in the app target. - Make data types that cross task boundaries
Sendablevalue types. - Replace shared mutable singletons with actors.
- Use
async letand task groups before reaching forTask { }. - Check cancellation in long loops and let
.taskmanage view lifetimes. - Profile: concurrency moves work around, it doesn’t make work free. The Swift Concurrency template in Instruments shows tasks, actors and where time goes. See Finding and fixing iOS performance problems with Instruments.
Key takeaways
async/awaitmakes asynchronous code read in order; everyawaitis a point where other work can run.- Prefer structured concurrency (
async let, task groups,.task) so work can’t outlive its owner. - Actors remove data races on shared state, but watch for reentrancy across
await. - New Xcode projects default to main-actor isolation; use
@concurrentfor work that must leave the main actor. - Treat
Sendableerrors as design feedback rather than something to silence.
Moving an existing app to Swift 6 goes more smoothly with a plan than with a wall of compiler errors. If you’d like help planning the migration or fixing a stubborn set of warnings, get in touch.
Written by Lee Johnson. Published 30 October 2026.