Swift 6.2 Approachable Concurrency: What Actually Changes During Migration
Approachable Concurrency in Swift 6.2 does not turn off data-race safety, and it does not automatically run an entire program on the main thread. It supplies project-level defaults that reduce isolation annotation in UI-oriented modules, while @concurrent identifies work intended to run concurrently. Migration can begin with a clearer question—who owns this state?—instead of trying to prove every type Sendable at once.
Default main-actor isolation is a module decision
With default actor isolation enabled, declarations without explicit isolation can belong to MainActor. That is often natural for an application target whose mutable state drives interface, navigation, and controllers. It is not automatically appropriate for networking models, algorithm packages, server code, or a reusable library called from several isolation domains.
Do not flip the setting at a workspace root and then repair the resulting diagnostics blindly. Inventory each target. Is most mutable state genuinely owned by the main actor? Will the public library be used in background tasks or on non-Apple platforms? Do tests rely on synchronous construction? Record the decision in build settings and architecture notes so a developer understands why diagnostics differ across modules.
@Observable
final class SearchModel {
var query = ""
var results: [Result] = []
func update(using service: SearchService) async throws {
results = try await service.search(query)
}
}
In an application module with main-actor default isolation, this UI model receives an ownership model aligned with its purpose. SearchService still needs its own boundary: immutable and sendable request and response values, an actor protecting shared cache state, or explicitly concurrent operations. A useful default removes boilerplate; it does not replace design.
Async does not mean automatic background execution
Swift 6.2’s approachable model allows a nonisolated async function to remain in the caller’s context unless it explicitly needs concurrent execution. Adding async alone does not move CPU-heavy work away from the current actor. This corrects a hazardous intuition: converting image processing to an async function does not make UI stalls disappear.
For work that should run on a concurrent executor, and whose inputs and result can safely cross the boundary, @concurrent expresses that intent:
@concurrent
func buildSearchIndex(from documents: [Document]) async -> SearchIndex {
var builder = SearchIndex.Builder()
for document in documents {
builder.add(document)
}
return builder.finish()
}
This is not a performance decoration. Verify that the values can be transferred, the function does not touch main-actor state, and the workload is large enough to justify scheduling. A small string transformation does not need to cross executors merely to look concurrent.
Migrate from boundaries inward
First upgrade the toolchain while preserving the existing language mode and establish a clean baseline. Then enable diagnostics and classify findings: genuinely shared mutable state, UI-owned state, immutable values crossing boundaries, and references that should never have been captured by another task. Repair public boundaries and task lifetimes before local annotation details. Only then enable new defaults in the targets that fit them.
Avoid using broad @unchecked Sendable conformances to empty the warning list. The annotation accepts a proof obligation without providing a lock, actor, or immutability. Every unchecked conformance needs a written invariant, narrowly controlled implementation, and concurrency tests. Avoid scattering Task { @MainActor in ... } as well. It can hide confused upstream ownership and silently queue heavy work onto the UI actor.
Treat detached tasks as exceptional. They lose inherited actor context and task-local structure, and they require an explicit lifetime owner. Structured child tasks or an actor-managed service usually express cancellation and shutdown more clearly.
Test behavior, not only compilation
A migration suite should exercise rapid input, cancellation, view disappearance, retry, and responses arriving out of order. Strict-concurrency diagnostics and Thread Sanitizer find different classes of problem; neither proves that logical ordering is correct. For an actor cache, test that repeated keys coalesce. For a UI model, prove that a stale response cannot overwrite a newer query. For @concurrent work, make cancellation and the hop back to UI state explicit.
Keep a performance baseline. Changing isolation can remove unnecessary hops, but it can also move work onto the main actor. Record interaction latency, main-thread time, and energy. Fewer warnings are an intermediate signal, not evidence that migration is complete.
A review checklist
For every changed declaration, identify its state owner, callers, values that cross isolation, task lifetime, cancellation path, and error destination. Review build settings alongside source changes. Prefer compiler-checked Sendable values, actors, and immutability over escape hatches. Document any exception with the invariant that makes it safe.
Approachable Concurrency changes the defaults used to express a sound model. UI modules can default to the main actor, ordinary async work can preserve caller context, and explicitly concurrent work can use @concurrent. Entry becomes less noisy without weakening race safety. Choose defaults per target, migrate from ownership boundaries, constrain exemptions, and verify cancellation and performance to obtain the benefit without replacing one set of annotations with another set of assumptions.