Swift 6.2 Concurrency in Practice: Default to MainActor, Escape on Purpose

Swift 6.0 made data races a compile error and, for a year, made everyone pay for it. The strict-concurrency checker turned ordinary UI code into a wall of Sendable violations and “main actor-isolated property can not be referenced from a nonisolated context” errors. The diagnosis was correct (that code really could race), but the volume buried the signal, and a lot of teams either stayed on Swift 5 mode or sprinkled @MainActor until the errors went quiet.

Swift 6.2 changes the default instead of the rules. The data-race safety guarantee is the same; what changed is where the compiler starts. Adopt the new defaults and most of the wall disappears, because the compiler now assumes what your app was already true: most of your code runs on the main actor, and you leave it on purpose, in named places. This is the model I ship across the 941-family apps1. Here is how it works, and the six errors that still bite once you turn it on.

TL;DR

  • SE-0466 lets you default an entire module to @MainActor. Set SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor and stop annotating every view, model, and view-model2.
  • SE-0461 makes nonisolated async functions run on the caller’s actor by default (nonisolated(nonsending)), so calling async code no longer forces an actor hop and the Sendable-across-the-boundary errors that came with it3.
  • @concurrent is the escape hatch. Mark a function @concurrent to push heavy CPU work (decoding, image processing) onto a background thread, deliberately and visibly4.
  • Xcode 26 turns both defaults on for new projects. The umbrella switch is SWIFT_APPROACHABLE_CONCURRENCY = YES5.
  • The model inverts the burden: instead of proving every line is safe to run off-main, you keep everything on-main and prove the few places you leave.
  • Six concrete errors survive the switch. All six have one-line fixes once you see the pattern.

The inversion, and why it matters

Old model (Swift 6.0): code is nonisolated until you isolate it. Every type that touched UI state needed @MainActor, every async call across an isolation boundary needed Sendable conformance, and the compiler flagged each gap. For an app where 95 percent of the code already runs on the main thread, you spent your time annotating the 95 percent to describe a fact that was never in doubt.

New model (Swift 6.2): code is on the main actor until you leave it. SE-0466 lets you declare main-actor isolation as the module default, so a view, its model, and its helpers are all @MainActor without a single annotation2. SE-0461 then removes the second tax: a nonisolated async function now runs on whatever actor called it instead of hopping to the global executor, so awaiting it does not drag you across an isolation boundary or demand Sendable on everything in scope3.

The mental model is the one that matches how UI apps actually behave. Main-thread by default is not a compromise; it is the truth of an app whose state is its views. Concurrency becomes the exception you reach for, named and contained, rather than the ambient condition you defend against on every line. The compiler’s job flips from “prove this is safe to run concurrently” to “you said this runs concurrently, so prove it is safe,” and the second question is asked in far fewer places.

Turning it on

Two build settings, both in Xcode 26’s defaults for new projects and both worth setting explicitly on an existing one5:

SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor
SWIFT_APPROACHABLE_CONCURRENCY = YES

The first is SE-0466: the module defaults to main-actor isolation. The second is the umbrella that enables the approachable-concurrency feature set, including the SE-0461 caller-runs behavior. In a Swift package, you set the same defaults through swiftSettings with the matching upcoming-feature flags rather than the Xcode build setting6.

Flip both on an existing project and the error count drops hard, because most of what the checker was flagging was main-thread code it could not previously assume was main-thread. What remains is a short list of genuine boundary cases. They are worth knowing by name, because each one is a place where your code really does leave the main actor, and the fix is to say so precisely.

The six errors that survive the switch

These are the strict-concurrency errors that still appear under SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor, taken from migrating the 941-family apps to the model1. Each is a real boundary, not a false positive, which is why the fix is a precise annotation rather than a suppression.

1. Pure functions on Sendable types. A pure method on a Sendable enum (a URL builder, a formatter) inherits main-actor isolation under the module default, then errors when called from a nonisolated context: “Call to main actor-isolated instance method in a synchronous nonisolated context.” The method touches no state, so isolating it to the main actor is wrong. Mark it nonisolated7:

nonisolated func searchURL(for query: String) -> URL? { ... }

2. Singleton statics in default parameters. static let shared = Foo() is main-actor-isolated under the default, but default-parameter values are evaluated in the caller’s context, which is often nonisolated: “Main actor-isolated static property ‘shared’ can not be referenced from a nonisolated context.” Make the static nonisolated. If the type is Sendable (or @unchecked Sendable because you guard its state yourself), you need no unsafe qualifier:

final class KeychainProxySecretStore: @unchecked Sendable {
    nonisolated static let shared = KeychainProxySecretStore()
}

3. Immutable primitive constants. The same default-parameter problem hits a plain constant: a static let defaultInterval referenced from a nonisolated default argument. The fix is identical and the constant is trivially safe to share:

nonisolated static let defaultInterval: TimeInterval = 15 * 60

4. A Task body reading captured self. An outer closure captures [weak self]; inside, a Task { @MainActor in self?.foo() } reads that captured optional: “Reference to captured var ‘self’ in concurrently-executing code.” The Task reads a var binding from the enclosing scope concurrently, which is the race. Re-capture self at the Task boundary so the Task owns an immutable binding:

NotificationCenter.default.addObserver(...) { [weak self] _ in
    Task { @MainActor [weak self] in
        self?.value = next
    }
}

5. KVO callbacks reading main-actor state. A webView.observe(\.canGoBack) { wv, _ in ... } callback is @Sendable and therefore nonisolated, but WKWebView.canGoBack is main-actor-isolated: “Main actor-isolated property ‘canGoBack’ can not be referenced from a Sendable closure.” KVO delivers synchronously on the thread that mutated the value, and WKWebView navigation state mutates only on the main thread, so the read is sound. Assert it with MainActor.assumeIsolated, which removes the Task hop entirely and stays synchronous8:

let pushNav: @Sendable (WKWebView?) -> Void = { [weak self] webView in
    MainActor.assumeIsolated {
        guard let self else { return }
        // safe to read webView?.canGoBack synchronously
    }
}

assumeIsolated is a promise to the compiler, not a question. Use it only where the runtime invariant genuinely holds (a documented main-thread callback), because a wrong promise is a crash, not a warning.

6. Heavy work that should not be on main. This one the checker will not flag, and it is the most important to catch yourself. Under a main-actor default, a synchronous CPU-bound method (JSON decoding a large payload, resizing an image) runs on the main actor and janks your UI. The default keeps you on-main; @concurrent is how you leave on purpose4:

@concurrent
func decodeLargePayload(_ data: Data) async throws -> Report {
    try JSONDecoder().decode(Report.self, from: data)
}

@concurrent offloads the function to the global executor and is mutually exclusive with @MainActor, a custom global actor, and nonisolated(nonsending), by design: a function either runs where its caller is or deliberately runs away from it, never ambiguously4. The discipline the new model asks for lives entirely in this pattern. Stay on main for everything that touches UI, and reach for @concurrent only for the work that measurably needs a background thread.

When the defaults are wrong for you

Main-actor-by-default fits apps: SwiftUI and UIKit code is overwhelmingly main-thread, and the default matches reality. It fits less well in two cases, and pretending otherwise wastes your time.

  • A library or framework target with no UI. A networking layer, a parser, or a data engine has no reason to default to the main actor, and doing so forces @concurrent or nonisolated on nearly everything. Leave SWIFT_DEFAULT_ACTOR_ISOLATION unset for those targets and isolate deliberately, the old way around.
  • An actor-heavy concurrent system. If your design genuinely runs many things in parallel (a real pipeline, not an app with a few background tasks), the main-actor default fights you. You want explicit actors and nonisolated code, and the SE-0466 default is the wrong starting point.

For an app, though, the call is easy: turn both settings on, let the error count collapse, and treat the handful that remain as a map of exactly where your code leaves the main thread. That map is worth having. The old model gave you a thousand warnings and no map; the new one gives you six honest boundaries and a default that finally matches how the app runs.

One last note on noise versus signal. SourceKit will show cross-file index errors (“Cannot find type X in scope,” “No such module”) inside the editor while Xcode rebuilds its index, especially right after regenerating a project. Those are index artifacts, not concurrency errors. If xcodebuild reports BUILD SUCCEEDED, the concurrency model is satisfied and the editor is just catching up1. Chasing index ghosts is the fastest way to waste an afternoon on a migration that already worked.



  1. Author’s production code across the 941-family iOS apps (Ki, Return, Get Bananas), all shipping with SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor and SWIFT_APPROACHABLE_CONCURRENCY = YES. The six error patterns and fixes below were the complete strict-concurrency cleanup set for Ki 1.0.0. 

  2. Swift Evolution, SE-0466: Control default actor isolation inference. Lets a module default to @MainActor isolation, so UI and app targets run on the main actor unless code opts out via @concurrent or an explicit actor. 

  3. Swift Evolution, SE-0461: Run nonisolated async functions on the caller’s actor by default. A nonisolated async function defaults to nonisolated(nonsending), running on the caller’s actor instead of hopping to the global executor, which removes the boundary-crossing Sendable requirements that came with the hop. 

  4. Swift Evolution, SE-0461 introduces the @concurrent attribute to opt a function into running on the global executor (a background thread). @concurrent and nonisolated(nonsending) are the two opposing isolation modes for a nonisolated async function: a function either runs where its caller is or deliberately runs away from it. @concurrent cannot be combined with @MainActor or a custom global actor. 

  5. SWIFT_APPROACHABLE_CONCURRENCY is the umbrella Xcode build setting that enables the approachable-concurrency upcoming features (including SE-0461 behavior), and SWIFT_DEFAULT_ACTOR_ISOLATION selects the module’s default isolation. New Xcode 26 projects enable both with the main-actor default. Documented in Donny Wals, “Exploring concurrency changes in Swift 6.2”, and Paul Hudson, “What’s new in Swift 6.2”, both cross-referenced against the underlying proposals SE-0461 and SE-0466. 

  6. Swift, Swift Concurrency Migration Guide, “Enabling Complete Concurrency Checking” and language-mode configuration. In a Swift package, default isolation and the approachable-concurrency features are set through swiftSettings upcoming-feature flags rather than the Xcode build setting. 

  7. Swift, Migration Guide: global actor isolation and nonisolated. A @MainActor type’s methods inherit main-actor isolation; nonisolated opts a method out, which is correct for pure functions that touch no isolated state. 

  8. Apple Developer, MainActor.assumeIsolated(_:). Asserts that the current execution is already on the main actor and runs the closure synchronously without an actor hop. The assertion traps at runtime if the invariant does not hold, so it is valid only where the caller is guaranteed to be on the main thread. 

関連記事

MLX on Apple Silicon: When You Need Your Own Model, Not Apple's

Foundation Models gives you Apple's sealed on-device LLM. MLX runs your own: quantized open-weight models and LoRA fine-…

8 分で読める

Apple Foundation Models: The On-Device LLM Framework, Explained

Apple's Foundation Models framework: LanguageModelSession, @Generable guided generation, tool calling, availability, and…

11 分で読める

Two MCP Servers Made Claude Code an iOS Build System

XcodeBuildMCP and Apple's Xcode MCP give Claude Code structured access to iOS builds, tests, and debugging. Setup, real-…

19 分で読める