Swift Concurrency Migration
Migrate from evidence, one isolation boundary at a time. Preserve behavior before changing architecture, and keep every target buildable between migration waves.
Inspect before editing
Determine the compiler, language mode, deployment targets, and effective settings for every affected target. Do not infer them from the newest syntax in one file.
xcodebuild -version
xcrun swiftc --version
xcodebuild -list -project App.xcodeproj
xcodebuild -showBuildSettings -project App.xcodeproj -target App \
| rg 'SWIFT_VERSION|SWIFT_STRICT_CONCURRENCY|SWIFT_DEFAULT_ACTOR_ISOLATION|SWIFT_APPROACHABLE_CONCURRENCY|IPHONEOS_DEPLOYMENT_TARGET|MACOSX_DEPLOYMENT_TARGET'
For a package, inspect // swift-tools-version, each target's swiftSettings, and the resolved compiler:
swift package dump-package
swift --version
Record separately:
- Xcode and Swift toolchain versions.
- Swift language mode and strict-concurrency level.
- Default actor isolation and approachable-concurrency setting.
- App, extension, test, and package deployment targets.
- Existing global actors, actors, locks, queues, callbacks, and suppression annotations.
If the request is diagnostic-driven, reproduce one exact compiler message before changing code. Read the migration and diagnostic playbook before making a multi-target plan.
Choose the isolation model
Use these defaults deliberately:
| Code | Preferred ownership | Reason |
|---|---|---|
| UI and UI-observed mutable state | @MainActor |
Keep presentation state in one domain |
| Independent shared mutable subsystem | Custom actor |
Serialize its state without a manual queue |
| Immutable values crossing domains | Sendable struct or enum |
Make the boundary explicit and reviewable |
| CPU-heavy async operation that must leave the caller's actor | @concurrent |
State the execution intent explicitly |
| General library operation whose caller chooses isolation | nonisolated where appropriate |
Avoid imposing an app-specific actor |
Swift 6.2 approachable concurrency and main-actor default isolation can reduce annotations in app targets. Do not enable main-actor default isolation blindly in reusable library targets. Change one target at a time and capture the before/after effective settings.
async does not mean background. It permits suspension; actor isolation determines where synchronous portions execute. A main-actor-isolated async function still performs its synchronous CPU work on the main actor. Use @concurrent only with a Swift 6.2-or-newer toolchain and only for work that genuinely must run on the concurrent executor.
Migrate in controlled waves
- Establish a clean behavioral baseline and representative tests.
- Enable warnings or complete checking for one leaf target.
- Make boundary values
Sendable; avoid sending framework objects or mutable reference types. - Isolate UI state to
@MainActorand cohesive mutable services to actors. - Replace related queue/group work with
async letor task groups. - Bridge remaining callbacks and delegates at narrow edges.
- Add cooperative cancellation and explicit task ownership.
- Enable Swift 6 language mode only after the target is clean, then repeat for dependents.
Prefer a small number of coherent isolation domains. Do not turn every class into an actor or add Task merely to silence an error.
Resolve diagnostics by cause
- For a non-Sendable capture, send an immutable snapshot, keep the object inside its actor, or redesign ownership.
- For main-actor access from a nonisolated context, decide whether the whole caller belongs on
@MainActoror whether only a narrow update should hop there. - For captured mutable locals, establish a
letsnapshot or move mutation behind an actor. - For task-isolated transfer errors, stop using the value after transfer or replace shared ownership with an actor/value boundary.
- For actor reentrancy, treat state as potentially changed after every
awaitand revalidate invariants.
Treat @unchecked Sendable, nonisolated(unsafe), and @preconcurrency as documented, temporary bridges. Require a concrete safety argument, the narrowest possible scope, a removal condition, and a test. Never apply them in bulk.
Preserve structured cancellation
Use async let for a fixed number of sibling operations and task groups for a dynamic number. Their parent scope owns completion, errors, and cancellation. Use unstructured Task only at synchronous-to-asynchronous lifecycle boundaries, store long-lived handles, and cancel them explicitly.
Cancellation is cooperative. Check Task.checkCancellation() in CPU loops, allow cancellation-aware suspension points to throw, and do not convert CancellationError into a user-visible failure. A timeout race must cancel the losing child.
Bridge legacy APIs narrowly
Use a checked continuation for exactly-one callbacks and an AsyncStream for repeated callbacks. Cover success, failure, cancellation, and the callback-never-arrives path. Resume a continuation exactly once.
For delegates, first establish the callback's documented executor. Capture Sendable values before hopping to an actor. Use MainActor.assumeIsolated only when the API contract guarantees main-actor delivery; it is a runtime assertion that traps when wrong. Read testing and legacy bridge patterns before changing a delegate or continuation.
Verify each wave
Compile using the real scheme or package settings, not only an isolated snippet. Add focused strict-concurrency typechecks for dependency-free examples, but treat them as a supplement to the full build.
Test:
- success, thrown error, and cancellation;
- actor state after suspension and reentrancy;
- delegate delivery from the executors the framework can use;
- task teardown when the owning feature disappears;
- the oldest supported OS and every affected app/extension target.
Use runtime race tools as supporting evidence, never as proof that unchecked isolation is safe.
OS 27 beta boundary
Treat all OS 27-cycle APIs as beta unless the user explicitly opts into a beta toolchain. Keep beta code in an availability-gated branch or beta-only source file, compile it with that beta SDK, and retain a stable Xcode 26 fallback. An availability check cannot make an unknown symbol compile in an older SDK. Do not replace a stable cancellation or bridging pattern with an unverified beta API.
Resources
- Migration workflow and diagnostic playbook
- Testing, cancellation, and legacy bridges
- Strict-concurrency example
- Positive and negative prompt scenarios