Skip to main content

Swift Concurrency Reference

Keywords, quirks, and mental models for Swift Concurrency — from async/await basics to actors, Sendable, task groups, and the traps that catch everyone.

async / await

The entry point for everything. async marks a function that can pause mid-execution. await is where that pause may actually happen. Neither keyword means “runs on a background thread” — they only describe suspension capability.

KeywordWhatWhen
asyncMarks a function that can suspend at one or more pointsAny function that calls another async function or does I/O
awaitSuspends the caller until the async expression resolvesEvery call to an async function — the compiler enforces it
try awaitCombines error propagation with suspensionCalling functions that are both async and throws
// A function that reads a file from disk — it can pause while waiting for I/O
func readConfig(at path: String) async throws -> AppConfig {
    let raw = try await FileManager.default.contentsOfFile(path)
    return try JSONDecoder().decode(AppConfig.self, from: raw)
}

// The caller must use await — the compiler enforces it
let config = try await readConfig(at: "/etc/myapp/config.json")

// Quirk: async does NOT mean background thread.
// This entire function runs on the main actor — it suspends but stays there.
@MainActor
func applySettings() async throws {
    statusLabel.text = "Reading config…"          // main thread ✓
    let config = try await readConfig(at: path)   // suspends, resumes on main
    statusLabel.text = config.environment         // main thread ✓
}

async let

When two pieces of data don’t depend on each other, fetching them sequentially wastes time. async let starts work immediately and lets you await the result later — both operations run at the same time.

KeywordWhatWhen
async letStarts an async operation immediately, in parallel with surrounding codeFetching two or more independent pieces of data at the same time
// Sequential — total wait = loadArticle + loadComments latency
let article  = try await loadArticle(id: articleID)
let comments = try await loadComments(for: articleID)

// Parallel — total wait = max(loadArticle, loadComments) latency
async let article  = loadArticle(id: articleID)
async let comments = loadComments(for: articleID)

// Both start immediately; this line is where we actually wait
let (a, c) = try await (article, comments)

// Quirk: you MUST await every async let before it goes out of scope.
// The compiler rejects code that creates an async let and never awaits it.

Task { }

The bridge from synchronous code into the async world. You create a Task when you need to start async work from a non-async context — like a button tap or viewDidLoad.

APIWhatWhen
Task { }Creates a new async task, inheriting the caller’s actor isolationCalling async code from a sync context, e.g. a button action
Task.valueAwaits the task and returns its resultWhen you need the result of a task you stored in a variable
Task.cancel()Requests cancellation of a running taskStopping work that is no longer needed
Task.isCancelledChecks if the current task has been cancelledInside long loops or between async calls to bail out early
try Task.checkCancellation()Throws CancellationError if cancelledClean early exit from a long operation
// Starting async work from a UIKit button (sync context)
@IBAction func syncTapped(_ sender: UIButton) {
    Task {
        await syncEngine.pushPendingChanges()
    }
}

// Storing the task handle so it can be cancelled later
final class VideoExporter {
    private var exportTask: Task<Void, Error>?

    func beginExport(session: AVAssetExportSession) {
        exportTask = Task {
            try await session.export()
        }
    }

    func cancelExport() {
        exportTask?.cancel()
    }
}

// Respecting cancellation inside a loop
func encodeFrames(_ frames: [CGImage]) async throws -> [Data] {
    var encoded: [Data] = []
    for frame in frames {
        try Task.checkCancellation()  // exits immediately if cancelled
        encoded.append(try await jpegEncoder.encode(frame))
    }
    return encoded
}

// Quirk: a Task created without storing the reference is "fire and forget."
// Nothing can cancel it, and it outlives the scope that created it.

.task Modifier (SwiftUI)

SwiftUI’s managed version of Task. The framework starts it when the view appears and cancels it automatically when the view disappears — no manual task storage needed.

APIWhatWhen
.task { }Runs async work tied to view lifetimeLoading data when a view first appears
.task(id:) { }Re-runs the task whenever id changes, cancelling the previous runReloading data in response to a changing value (search query, selected ID)
// Load data once when the view appears; cancel automatically on disappear
struct PlaylistView: View {
    @State private var tracks: [Track] = []

    var body: some View {
        List(tracks) { TrackRow(track: $0) }
            .task {
                tracks = (try? await MusicLibrary.shared.loadTracks()) ?? []
            }
    }
}

// Reload whenever the search query changes
struct SearchView: View {
    @State var query = ""
    @State private var results: [Song] = []

    var body: some View {
        VStack {
            TextField("Search", text: $query)
            List(results) { SongRow(song: $0) }
        }
        .task(id: query) {
            // The previous search task is cancelled before this one starts
            guard !query.isEmpty else { results = []; return }
            results = (try? await SearchEngine.search(query)) ?? []
        }
    }
}

// Quirk: .task(id:) cancels the moment id changes, mid-flight if needed.
// If your work has side effects (writing to disk, updating a counter),
// check Task.isCancelled before committing them.

Task.detached { }

A task that deliberately breaks away from the caller’s context. It doesn’t inherit the actor isolation, priority, or task-local values of the code that created it. Use sparingly.

APIWhatWhen
Task.detached { }Creates a task with no inherited isolation or priorityCPU-bound background work that must not run on the main actor
// Generating a PDF report without tying up the main actor
func schedulePDFExport(for report: SalesReport) {
    Task.detached(priority: .utility) {
        let pdfData = PDFRenderer.render(report)  // CPU-heavy, off main
        let url = FileManager.default.temporaryDirectory
            .appendingPathComponent("report.pdf")
        try? pdfData.write(to: url)
        await MainActor.run { NotificationCenter.default.post(name: .exportDone, object: url) }
    }
}

// Quirk: Task.detached is the right tool only when you explicitly need
// to escape the caller's actor isolation AND its priority.
// For "just run this off the main thread," reach for @concurrent instead.
// Detached tasks are easy to mis-use and hard to reason about.

TaskGroup

When you have a dynamic number of parallel operations — a batch of files to encode, a set of records to validate — TaskGroup manages them as structured child tasks with automatic cancellation propagation.

APIWhatWhen
withTaskGroup(of:)Creates a group of child tasks that return a valueParallel work that all succeeds — no throwing
withThrowingTaskGroup(of:)Same, but any child can throw and cancel the groupParallel work where a single failure should abort everything
group.addTask { }Adds a child task to the groupInside the group body, for each item you want processed in parallel
group.next()Awaits the next completed result in any orderStreaming results as they finish
// Validate many records in parallel, fail fast on the first error
func validateAll(_ records: [Record]) async throws -> [ValidationResult] {
    try await withThrowingTaskGroup(of: ValidationResult.self) { group in
        for record in records {
            group.addTask { try await Validator.check(record) }
        }
        return try await group.reduce(into: []) { $0.append($1) }
    }
}

// Transcode videos and report progress as each one finishes
func transcodeLibrary(_ videos: [VideoAsset]) async throws {
    try await withThrowingTaskGroup(of: VideoAsset.self) { group in
        for video in videos {
            group.addTask { try await Transcoder.convert(video, preset: .h265) }
        }
        for try await completed in group {
            print("Done: \(completed.title)")
        }
    }
}

// Quirk: child tasks finish in COMPLETION order, not the order they were added.
// When ordering matters, tag each result with its original index:
func transcodeOrdered(_ videos: [VideoAsset]) async throws -> [VideoAsset] {
    try await withThrowingTaskGroup(of: (Int, VideoAsset).self) { group in
        for (i, video) in videos.enumerated() {
            group.addTask { (i, try await Transcoder.convert(video, preset: .h265)) }
        }
        var out = [(Int, VideoAsset)]()
        for try await pair in group { out.append(pair) }
        return out.sorted { $0.0 < $1.0 }.map(\.1)
    }
}

@MainActor

The most common actor annotation. It ties code to the main thread. UIKit, AppKit, and SwiftUI all require main-thread access — @MainActor makes the compiler enforce that requirement rather than leaving it to runtime crashes.

AnnotationWhatWhen
@MainActor on a classAll methods and stored properties are main-thread isolatedAny class that owns UI state — view models, controllers
@MainActor on a functionThat specific function always runs on the main threadAn individual method that touches UI inside an otherwise unannotated type
@MainActor on a propertyThat property can only be read/written on the main threadA single UI-bound property in a mixed type
await MainActor.run { }Hops to the main actor for a blockAlmost never needed — prefer annotating the function directly
// Annotate the whole class: every property and method is main-isolated
@MainActor
class CheckoutViewModel: ObservableObject {
    @Published var cartItems: [CartItem] = []
    @Published var isSubmitting = false

    func submitOrder() async throws {
        isSubmitting = true
        defer { isSubmitting = false }
        let receipt = try await OrderService.place(cartItems)
        cartItems = []
        print("Order placed: \(receipt.id)")
    }
}

// Annotate just one method when only that method touches the UI
class AnalyticsTracker {
    func trackEvent(_ name: String) { /* fires off-main */ }

    @MainActor
    func showInAppBanner(_ message: String) {
        BannerView.show(message)  // safe — only this method is main-isolated
    }
}

// Quirk: prefer annotating the function with @MainActor over calling
// MainActor.run { }. The annotation is checked at compile time;
// MainActor.run hides the missing annotation and is harder to audit.

actor

A reference type whose mutable state can only be accessed by one caller at a time. The compiler enforces this — any access from outside the actor requires await, because the runtime may need to queue the call until the actor is free.

KeywordWhatWhen
actorDefines an isolated reference type — one caller at a timeAny shared mutable state touched from multiple concurrent tasks (cache, rate limiter, connection pool)
// A token-bucket rate limiter — safe to call from many concurrent tasks
actor RateLimiter {
    private let capacity: Int
    private var tokens: Int
    private var lastRefill = Date.now

    init(capacity: Int) {
        self.capacity = capacity
        self.tokens = capacity
    }

    func acquire() async throws {
        refillIfNeeded()
        guard tokens > 0 else { throw RateLimitError.exceeded }
        tokens -= 1
    }

    private func refillIfNeeded() {
        let elapsed = Date.now.timeIntervalSince(lastRefill)
        guard elapsed >= 1 else { return }
        tokens = capacity
        lastRefill = .now
    }
}

// From outside, every call requires await
let limiter = RateLimiter(capacity: 10)
try await limiter.acquire()    // await — isolated property

// Inside the actor, methods call each other without await
// (refillIfNeeded is called from acquire() — no await needed)

// Quirk: actors are NOT threads. Multiple callers queue up and run
// one at a time, but that queuing can itself create bottlenecks.
// Don't reach for a custom actor unless the state genuinely needs
// independent isolation from @MainActor.

nonisolated

Opts a method or property out of its enclosing actor’s isolation. The method becomes synchronously callable from anywhere — but in exchange, it cannot touch any of the actor’s isolated state.

KeywordWhatWhen
nonisolatedRemoves actor isolation from a specific memberPure computed properties, protocol conformances that must be synchronous, utility methods with no actor state
nonisolated(unsafe)Declares a stored property as explicitly non-isolatedWrapping a legacy reference type you guarantee is safe but the compiler cannot verify
actor SessionStore {
    private var sessions: [String: Session] = [:]
    let storeName: String  // constant — safe to read anywhere

    init(name: String) { self.storeName = name }

    // Touching actor state — callers outside need await
    func insert(_ session: Session) {
        sessions[session.id] = session
    }

    // Pure computation: no actor state involved, no await needed from outside
    nonisolated func redactedName() -> String {
        String(storeName.prefix(3)) + "***"
    }
}

let store = SessionStore(name: "PrimaryStore")
let label = store.redactedName()   // no await — nonisolated ✓
await store.insert(newSession)     // await — isolated ✓

// Conforming to CustomStringConvertible without requiring await at callsites
extension SessionStore: CustomStringConvertible {
    nonisolated var description: String { "SessionStore(\(storeName))" }
}

Sendable

The compile-time guarantee that a value is safe to pass across actor or concurrency boundaries. Value types (structs, enums) with Sendable stored properties automatically conform. Reference types need explicit opt-in.

ConceptWhatWhen
Sendable protocolMarks a type as safe to share across isolation boundariesAny type passed to a Task, across actor boundaries, or captured in a @Sendable closure
Implicit conformanceStructs and enums with Sendable properties conform automaticallyNo annotation needed — the compiler infers it
final class + immutable propertiesA class can be Sendable if final and all stored properties are constantsRead-only model types that are reference types for identity reasons
@unchecked SendableTells the compiler “I own the thread-safety here”Wrapping a lock-protected legacy class the compiler cannot inspect
// Struct — implicitly Sendable because all fields are Sendable value types
struct InvoiceItem {
    let sku: String
    let quantity: Int
    let unitPrice: Decimal
}

// Final class with only let properties — qualifies as Sendable
final class RouteConfiguration: Sendable {
    let baseURL: URL
    let retryLimit: Int
    init(base: URL, retries: Int) { baseURL = base; retryLimit = retries }
}

// Wrapping a dispatch-queue-protected legacy type — manual guarantee required
final class MetricsBuffer: @unchecked Sendable {
    private var events: [String] = []
    private let queue = DispatchQueue(label: "metrics.buffer")

    func record(_ event: String) {
        queue.sync { events.append(event) }
    }

    func flush() -> [String] {
        queue.sync { defer { events = [] }; return events }
    }
}

// Quirk: @unchecked Sendable silences the compiler entirely.
// The "checked" in the alternative name refers to compiler-enforced
// checking — @unchecked removes that guarantee. One wrong lock
// and you have a silent data race.

@concurrent

New in Swift 6.2. The explicit way to say “run this function on a background thread.” Replaces most legitimate uses of Task.detached and fixes the common mistake of doing CPU-heavy work in @MainActor async functions.

AnnotationWhatWhen
@concurrentRuns the function on the cooperative thread pool, not the caller’s actorCPU-bound work: compression, format conversion, heavy data parsing
// Swift 6.2+: mark the function, not the call site
@concurrent
func compressLog(_ entries: [LogEntry]) async -> Data {
    let joined = entries.map(\.rawLine).joined(separator: "\n")
    return (joined.data(using: .utf8) ?? Data()).compressed()
}

// The caller stays on @MainActor — no boilerplate needed
@MainActor
func archiveLogs(_ entries: [LogEntry]) async {
    progressView.isHidden = false
    let compressed = await compressLog(entries)  // runs off main, returns to main
    try? compressed.write(to: archiveURL)
    progressView.isHidden = true
}

// Quirk: @concurrent is an opt-out from the default actor isolation,
// not a way to make all async code faster.
// Reach for it only when Instruments shows the main thread blocked
// on work that has no UI dependency whatsoever.

Approachable Concurrency (Xcode 26+)

Two build settings that ship on by default in new Xcode 26 projects. Together they eliminate the majority of Sendable warnings and make @MainActor the implicit isolation for all code in the module.

SettingWhatWhen
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActorEvery type and function is implicitly @MainActor unless it opts outNew projects in Xcode 26; existing apps migrating to Swift 6
SWIFT_APPROACHABLE_CONCURRENCY = YESClosures inherit caller isolation; Sendable checking is relaxedPaired with the above to smooth out the Swift 6 adoption
// Without approachable concurrency — explicit annotations everywhere
@MainActor
class NotificationViewModel: ObservableObject {
    @Published var unreadCount = 0
}

// With SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor
// @MainActor is inferred — no annotation required
class NotificationViewModel: ObservableObject {
    @Published var unreadCount = 0  // implicitly @MainActor
}

// Opt individual methods out when they are CPU-bound
class NotificationViewModel: ObservableObject {
    @Published var notifications: [Notification] = []

    @concurrent
    func parsePayload(_ data: Data) async throws -> [Notification] {
        // runs off main even though the class is implicitly @MainActor
        try JSONDecoder().decode([Notification].self, from: data)
    }
}

Common Traps & Quirks

Patterns that look correct but cause subtle bugs. These come up in code review repeatedly.

TrapWhat goes wrongWhat to do instead
async ≠ backgroundCPU work in @MainActor async still blocks the UIMark the heavy function @concurrent or nonisolated
Blocking the cooperative thread poolDispatchSemaphore.wait() inside async code can deadlockUse withCheckedContinuation to stay fully async
Task { } fire-and-forgetUnmanaged tasks leak, can’t be cancelled, race with view teardownStore the handle, or use .task in SwiftUI
Unnecessary MainActor.runHides a missing @MainActor annotationAnnotate the function itself with @MainActor
Actor overuseOne actor per shared variable creates contention and complexityMost UI code belongs on @MainActor; custom actors only for state that truly needs independent isolation
@unchecked Sendable overuseSuppresses every compiler check for that typeAdd it only when wrapping externally-synchronized code you cannot change
Assuming TaskGroup orderChild task results arrive in completion order, not insertion orderTag results with an index and sort after collecting
// TRAP 1 — @MainActor async does NOT move work off the main thread
@MainActor
func buildSearchIndex() async {
    let documents = corpus.allDocuments   // 50,000 entries
    searchIndex = documents.map { IndexEntry($0) }  // still on main — UI freezes
}

// Fix: opt out of the main actor for the heavy step
@MainActor
func buildSearchIndex() async {
    let documents = corpus.allDocuments
    searchIndex = await Task.detached {
        documents.map { IndexEntry($0) }
    }.value
}

// TRAP 2 — DispatchSemaphore.wait() inside async code
func waitForHandshake() async -> Bool {
    let sem = DispatchSemaphore(value: 0)
    connectionManager.onHandshake { sem.signal() }
    sem.wait()   // blocks a cooperative thread — can deadlock the pool
    return true
}

// Fix: use withCheckedContinuation
func waitForHandshake() async -> Bool {
    await withCheckedContinuation { continuation in
        connectionManager.onHandshake { continuation.resume(returning: true) }
    }
}

// TRAP 3 — TaskGroup insertion order ≠ completion order
func renderPages(_ pages: [Page]) async throws -> [RenderedPage] {
    try await withThrowingTaskGroup(of: (Int, RenderedPage).self) { group in
        for (index, page) in pages.enumerated() {
            group.addTask { (index, try await Renderer.render(page)) }
        }
        var results = [(Int, RenderedPage)]()
        for try await pair in group { results.append(pair) }
        return results.sorted { $0.0 < $1.0 }.map(\.1)  // restore original order
    }
}

Callback APIs → async/await

Legacy delegate callbacks and completion handlers don’t fit the async/await model. Use withCheckedContinuation and withCheckedThrowingContinuation to bridge them once, then call the wrapper like any other async function.

APIWhatWhen
withCheckedContinuationWraps a completion-handler API into an async functionThe handler only delivers success, never an error
withCheckedThrowingContinuationSame, but the continuation can throwThe handler delivers either a value or an error
// Wrapping a UIKit photo picker that uses a completion handler
func pickPhoto(from controller: UIViewController) async throws -> UIImage {
    try await withCheckedThrowingContinuation { continuation in
        let picker = UIImagePickerController()
        picker.sourceType = .photoLibrary
        picker.completion = { result in
            switch result {
            case .selected(let image): continuation.resume(returning: image)
            case .cancelled:           continuation.resume(throwing: PickerError.cancelled)
            }
        }
        controller.present(picker, animated: true)
    }
}

// Now the callsite is just another await
let photo = try await pickPhoto(from: self)

// Wrapping an older network client that uses callbacks
func legacyFetch(endpoint: String) async throws -> Data {
    try await withCheckedThrowingContinuation { continuation in
        LegacyHTTPClient.get(endpoint) { data, error in
            if let error { continuation.resume(throwing: error) }
            else if let data { continuation.resume(returning: data) }
        }
    }
}

// Quirk: continuation.resume must be called exactly once.
// Calling it twice crashes immediately (that's the "checked" guarantee).
// Never calling it leaves the awaiting task suspended forever — a silent leak.