Swift 6.2 Approachable Concurrency
Use this skill to write or move code to the Swift 6.2 concurrency model.
The main rule is simple:
- Keep code on the caller's actor by default.
- Use
@concurrentonly for work that should run at the same time. - Use
@MainActorfor UI state and shared app state. - Use an isolated conformance when a protocol must follow an actor.
This model helps the compiler find data races before the app runs.
Use This Skill When
Use these steps when:
- Moving a Swift 5.x, 6.0, or 6.1 project to Swift 6.2
- Fixing compiler errors about sending values or data races
- Building an app around
MainActor - Moving slow CPU work off the main actor
- Making a
MainActortype follow a protocol - Turning on approachable concurrency in Xcode 26
Check the Build Settings First
Swift 6.2 features can depend on target settings. Before changing code, check the settings for each app, test, and package target.
Look for these Swift compiler options:
- Default actor isolation
- Nonisolated async functions that stay on the caller's actor
- Strict concurrency checks
- Swift language mode
For an app target, MainActor is often a good default actor. A library may need a different default because its code can be called from many actors.
Do not assume all targets use the same settings.
Async Does Not Mean Background Work
An async function can pause. It does not always move work to a background thread.
With the Swift 6.2 setting for caller actor isolation, a plain async function stays on the caller's actor unless its code asks to run elsewhere.
@MainActor
final class StickerModel {
let photoProcessor = PhotoProcessor()
func extractSticker(
from item: PhotosPickerItem
) async throws -> Sticker? {
guard let data = try await item.loadTransferable(
type: Data.self
) else {
return nil
}
return await photoProcessor.extractSticker(
data: data,
id: item.itemIdentifier
)
}
}This can prevent errors caused by sending non-Sendable state to another actor.
It does not make slow work safe for the main actor. A long loop can still freeze the UI.
Use @MainActor for UI and Shared App State
Put UI state and app-wide mutable state on MainActor.
@MainActor
final class StickerLibrary {
static let shared = StickerLibrary()
private(set) var stickers: [Sticker] = []
func add(_ sticker: Sticker) {
stickers.append(sticker)
}
}Code outside MainActor must use await to read or change this state.
await StickerLibrary.shared.add(sticker)A global actor protects access. It does not make each stored value Sendable.
Default MainActor Isolation
Swift 6.2 can make MainActor the default isolation for a target.
final class StickerModel {
var selection: [PhotosPickerItem] = []
}In a target with default MainActor isolation, this type is treated as main actor code unless it says otherwise.
This setting is useful for:
- UI apps
- Scripts
- Other executable targets
Use care in library targets. A library often needs types that work from many actors.
Do not rely on this setting when code is shared across targets. Add @MainActor when the rule must be clear in the source.
Use an Isolated Protocol Conformance
A MainActor type may follow a protocol only on MainActor.
protocol Exportable {
func export()
}
@MainActor
final class StickerModel {
let photoProcessor = PhotoProcessor()
}
extension StickerModel: @MainActor Exportable {
func export() {
photoProcessor.exportAsPNG()
}
}Use the value from a matching actor:
@MainActor
struct ImageExporter {
private var items: [any Exportable] = []
mutating func add(_ item: StickerModel) {
items.append(item)
}
}A nonisolated caller cannot use this conformance without moving to MainActor.
func exportOnMain(_ item: StickerModel) async {
await MainActor.run {
item.export()
}
}Check generic code and stored protocol values. They must keep the same actor rule.
Use @concurrent for Slow CPU Work
Use @concurrent when work should run away from the caller's actor.
Good uses include:
- Image changes
- File compression
- Audio work
- Large math tasks
- Parsing a large block of data
The function should use values that are safe to send. Its result should also be safe to send.
struct Sticker: Sendable {
let pixels: [UInt8]
}
enum StickerProcessor {
@concurrent
static func findSubject(
in pixels: [UInt8]
) async -> Sticker {
let output = runSlowImageWork(pixels)
return Sticker(pixels: output)
}
}Call it with await:
let sticker = await StickerProcessor.findSubject(
in: imagePixels
)@concurrent does not make shared mutable state safe. Do not read or write a normal class property from several calls at once.
Use an actor for a cache:
actor StickerCache {
private var values: [String: Sticker] = [:]
func value(for id: String) -> Sticker? {
values[id]
}
func store(_ sticker: Sticker, for id: String) {
values[id] = sticker
}
}Full Usage Example
This example keeps UI state on MainActor, runs image work with @concurrent, and protects the cache with an actor.
struct Sticker: Sendable {
let pixels: [UInt8]
}
actor StickerCache {
private var values: [String: Sticker] = [:]
func value(for id: String) -> Sticker? {
values[id]
}
func store(_ sticker: Sticker, for id: String) {
values[id] = sticker
}
}
enum StickerProcessor {
@concurrent
static func process(_ pixels: [UInt8]) async -> Sticker {
let output = runSlowImageWork(pixels)
return Sticker(pixels: output)
}
}
@MainActor
final class StickerViewModel {
private let cache = StickerCache()
private(set) var currentSticker: Sticker?
func load(id: String, pixels: [UInt8]) async {
if let saved = await cache.value(for: id) {
currentSticker = saved
return
}
let sticker = await StickerProcessor.process(pixels)
await cache.store(sticker, for: id)
currentSticker = sticker
}
}How to Add @concurrent
Before adding it:
- Find a slow CPU task with a profiler.
- Make the input safe to send.
- Make the result safe to send.
- Remove access to unsafe shared state.
- Mark the async function with
@concurrent. - Add
awaitat each call. - Test cancellation, errors, and repeated calls.
A type does not always need to be marked nonisolated. Use nonisolated only when the type or member must not use the target's default actor.
Migration Steps
- Move to Swift 6.2 one target at a time.
- Turn on strict concurrency checks.
- Pick the target's default actor isolation.
- Fix shared mutable state with
MainActoror another actor. - Use isolated conformances for actor-bound types.
- Keep normal async work on the caller's actor.
- Add
@concurrentonly to proven slow CPU work. - Check that sent inputs and results are
Sendable. - Build and test all app, test, and package targets.
- Run Thread Sanitizer tests when useful.
Edge Cases
Long Work Can Still Block the UI
An async function on MainActor can freeze the UI if it does long work without a pause. Move that CPU work to an @concurrent function.
@concurrent Is Not a Lock
It does not protect global variables, static variables, caches, or class fields. Use an actor, a global actor, or another safe design.
Sendable Does Not Protect Mutable State
A value marked Sendable must truly be safe to move between actors. Do not add unchecked conformance only to silence the compiler.
Cancellation Must Be Checked
Long loops should stop when the task is cancelled.
@concurrent
func processItems(_ items: [Item]) async throws -> [Result] {
var output: [Result] = []
for item in items {
try Task.checkCancellation()
output.append(process(item))
}
return output
}Actor Reentry Can Change State
An actor method may pause at await. Other work can then enter the actor and change its state. Check key state again after an await when needed.
Old APIs May Need a Bridge
Some older APIs use callbacks or DispatchQueue. Wrap them with async tools when needed, but keep the actor rule clear. Do not remove a queue until you know what state it protects.
Tests May Use Different Isolation
A test target may not use the app target's default actor. Add @MainActor to a test or test type when it reads main actor state.
Good Rules
- Start with simple actor-bound code.
- Keep UI state on
MainActor. - Use actors for shared mutable state.
- Use
@concurrentonly for slow CPU work. - Measure before moving work.
- Make sent values
Sendable. - Check cancellation in long tasks.
- Migrate one target at a time.
- Treat compiler race errors as real design warnings.
Avoid These Patterns
- Adding
@concurrentto every async function - Using
nonisolatedonly to hide an error - Adding
@unchecked Sendablewithout a full safety review - Sharing a mutable class across concurrent calls
- Assuming
asyncmeans background work - Assuming
@concurrentmakes state thread-safe - Using
Task.detachedwhen normal actor rules will work - Keeping old queue code without knowing what it protects
- Reading main actor state from nonisolated code without
await - Relying on target defaults in code shared by many targets