Swift & iOS

Review Swift concurrency, structured tasks, actor isolation, Sendable, cancellation, and Swift 6.4 updates.

~14 min reading
Suggest content

Swift Concurrency 6.4 — Quick Reference

🟪 Syntax · 🟦 Types/APIs · 🟩 Correct pattern · 🟥 Bug · 🟨 Gotcha · 🧠 Explanation

Quick Recap — The fast mental model

  • async means a function may suspend. It does not mean the function runs on a background thread or in parallel.
  • await marks a possible suspension point and an actor hop.
  • An actor protects its isolated mutable state by serializing access to it.
  • @MainActor isolates UI-facing state and work to the main actor.
  • Sendable describes values safe to transfer across concurrency boundaries.
  • Cancellation is cooperative: code must check or pass cancellation through.
  • Structured tasks are scoped to their parent; unstructured and detached tasks need deliberate lifetime management.

Structured concurrency first

Use async let for a small, fixed number of independent operations. Use a task group for a dynamic number of child operations. Child work finishes or is cancelled with its scope.

SWIFT
func dashboard() async throws -> Dashboard {
    async let profile = loadProfile()
    async let messages = loadMessages()
    return try await Dashboard(profile: profile, messages: messages)
}
SWIFT
func loadAll(_ ids: [UUID]) async throws -> [User] {
    try await withThrowingTaskGroup(of: User.self) { group in
        for id in ids {
            group.addTask { try await loadUser(id: id) }
        }
​
        var users: [User] = []
        for try await user in group {
            users.append(user)
        }
        return users
    }
}

Bound task-group concurrency when the input can be large. Starting one child for every unbounded record can overwhelm memory, sockets, or the server.

Actor isolation and reentrancy

SWIFT
actor TokenStore {
    private var token: String?
​
    func currentToken() -> String? { token }
    func replace(with newValue: String?) { token = newValue }
}
​
let store = TokenStore()
let token = await store.currentToken()

Calls from outside the actor cross an isolation boundary, so an await is normally required. The actor protects its state; it does not promise a dedicated thread.

An actor can process another message when one of its methods suspends at await. This is actor reentrancy. Re-check state after suspension before relying on an invariant:

SWIFT
actor ImageCache {
    private var images: [URL: Image] = [:]
​
    func image(for url: URL) async throws -> Image {
        if let cached = images[url] { return cached }
​
        let downloaded = try await download(url)
        // Another call may have populated this entry while download suspended.
        if let cached = images[url] { return cached }
        images[url] = downloaded
        return downloaded
    }
}

If duplicate work is expensive, store an in-flight task or use a single-flight operation. Do not assume an actor method is an uninterruptible transaction across await.

UI isolation with @MainActor

SWIFT
import Observation
​
@MainActor
@Observable
final class InboxModel {
    private(set) var messages: [Message] = []
    private(set) var errorText: String?
    private let service: any InboxService
​
    init(service: any InboxService) {
        self.service = service
    }
​
    func refresh() async {
        do {
            messages = try await service.messages()
            errorText = nil
        } catch is CancellationError {
            // Let view disappearance or a newer request cancel this work.
        } catch {
            errorText = "Couldn't load messages."
        }
    }
}

SwiftUI's .task is a good place to call await model.refresh(). Do not use DispatchQueue.main.async as a routine substitute for correct actor isolation.

Sendable and strict concurrency

SWIFT
struct SearchRequest: Sendable {
    let query: String
    let page: Int
}
​
protocol SearchService: Sendable {
    func search(_ request: SearchRequest) async throws -> [SearchResult]
}

Sendable is a concurrency-safety contract, not JSON encoding. Value types containing only sendable fields often receive conformance automatically. A mutable class generally needs synchronization or actor isolation; avoid using @unchecked Sendable unless you can prove and document the safety manually.

Move a project toward Swift 6 language mode and complete concurrency checking deliberately. Fix ownership and isolation boundaries instead of suppressing diagnostics. @preconcurrency and nonisolated(unsafe) are migration/interoperability escape hatches, not default fixes.

How do Sendable and Codable differ?

Sendable describes whether a value can safely cross a concurrency boundary; Codable describes conversion to and from an external representation. A type can conform to either, both, or neither. A network DTO may need both, but one conformance does not imply the other.

When is @unchecked Sendable legitimate?

Use it only when the compiler cannot verify a real synchronization guarantee that the type author can prove—for example, all mutable state is protected by a lock or another audited mechanism. Document the invariant near the conformance and test the synchronization boundary. It suppresses checking; it does not add a lock, actor isolation, or safety by itself.

How do you bridge a callback API with a continuation?

Use withCheckedThrowingContinuation when one callback completes exactly once with one success or failure. Resume on every terminal path and never resume twice. If the callback API can emit multiple values or cancellation matters, use an async sequence or a cancellation-aware adapter instead of forcing a stream into a one-shot continuation. A production bridge should propagate task cancellation when the legacy API supports cancelling its operation.

What is priority inversion?

Priority inversion occurs when high-priority work waits on a resource held by lower-priority work, while intermediate-priority work delays the holder from running. Keep critical sections short, avoid blocking waits on the main thread, and prefer structured async coordination over ad-hoc locks where practical. Measure before changing QoS or task priorities; raising every task's priority can hide starvation rather than fix the dependency.

Cancellation patterns

SWIFT
func process(_ records: [Record]) async throws {
    for record in records {
        try Task.checkCancellation()
        try await process(record)
    }
}
  • Prefer APIs that propagate task cancellation to URLSession, task groups, and child work.
  • Check cancellation in long loops or between expensive phases.
  • Treat CancellationError as control flow, not necessarily a user-facing failure.
  • Do not swallow cancellation and then overwrite newer UI state with stale results.
  • Avoid Task.sleep polling when a clock, async sequence, or event notification expresses the wait more directly.

Task lifetime choices

ConstructInherits parent context?Use
async letYesA fixed number of scoped child operations
withTaskGroupYesA dynamic set of scoped child operations
Task { ... }Inherits actor context and priority; unstructured lifetimeStart work from synchronous UI/event code and retain/cancel it deliberately
Task.detached { ... }Does not inherit actor context, task-local values, or priorityRare low-level work where independence is intentional

Do not create a new Task at every layer of an async call chain. Prefer async functions and let the caller own the task lifetime. Avoid detached tasks for normal networking or UI refreshes.

Swift 6.2+ approachable concurrency settings

Swift 6.2 introduced clearer controls for async isolation. Exact behavior depends on enabled language mode and upcoming-feature settings; review project build settings before relying on it.

  • With NonisolatedNonsendingByDefault, a nonisolated async function runs on the caller's actor by default.
  • @concurrent explicitly opts a function into concurrent execution away from the caller's actor.
  • Some targets can opt into main-actor default isolation, which fits UI-focused code but changes the isolation assumptions of the module.
SWIFT
// Under the Swift 6.2+ concurrency mode that enables this behavior:
nonisolated(nonsending) func parse(_ data: Data) async throws -> Model {
    try JSONDecoder().decode(Model.self, from: data)
}
​
@concurrent
func computeThumbnail(from image: ImageData) async -> Thumbnail {
    // CPU work that should not run as main-actor-isolated work.
    Thumbnail(image)
}

Do not annotate every function with @concurrent; use it when parallel execution is intended and the data boundary is safe. async by itself is not a background-work annotation.

Swift 6.4 language/library updates

  • Swift 6.4 is the current Swift release at the date this folder was reviewed. Xcode 27 includes Swift 6.4 and the iOS 27 SDK.
  • Swift 6.4 supports async cleanup inside defer; cleanup runs before the async function exits.
  • withTaskCancellationShield is available for narrowly scoped cleanup that must finish even after parent cancellation. Keep shielded work short and bounded.
  • @Observable has finer-grained change observation APIs, but ordinary SwiftUI state should generally use the normal model and view integration.
  • New low-level ownership/collection APIs such as Span, InlineArray, UniqueArray, and noncopyable generics are valuable in specialized performance-sensitive libraries, not a default requirement for app feature code.
SWIFT
func export(_ document: Document) async throws {
    let writer = try await openWriter(for: document)
​
    defer {
        await withTaskCancellationShield {
            await writer.finishAndClose()
        }
    }
​
    try await writer.write(document.contents)
}

Use cancellation shielding only when abandoning cleanup would corrupt a resource or lose required finalization. It is not a way to make an entire request uncancellable.

Common traps

  • await marks a potential suspension; the function may continue synchronously.
  • A task running on the main actor can still perform work that suspends and later resumes on the main actor.
  • An actor prevents unsynchronized access to its isolated state, not logical races across suspension points.
  • A lock or serial queue can be correct but should not be mixed casually with actor-isolated state.
  • Task.detached is not just a “background task.” It drops useful parent context.
  • Task {} is not automatically cancelled when an arbitrary object deallocates. Store its handle or use structured lifetime when appropriate.

Key Takeaways

  • async permits suspension; it does not promise a background thread or parallel execution.
  • Use structured tasks when possible, and make task lifetime and cancellation ownership explicit.
  • Actors protect isolated mutable state, but code must still account for reentrancy across await.
  • Treat Sendable as a data-transfer safety contract; do not silence a boundary with @unchecked Sendable without a reviewed invariant.
  • Choose @MainActor, @concurrent, and other isolation explicitly for the work and toolchain you support.

References