Skip to content
C.W.K.
Stream
Lesson 05 of 07 · published

Swift 6.2's New Defaults: Main Actor First, Concurrency on Purpose

~16 min · concurrency, swift-6-2, default-isolation, nonisolated-nonsending, concurrent

Level 0Bundle Opener
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"Start single-threaded. Escape on purpose, and say so in the type."

What Changed in Swift 6.2

Swift 6.2, released in September 2025, answered the most common complaint about Swift 6: that a small app with no real parallelism still drowned in isolation errors. It added three connected pieces, collectively called approachable concurrency.

  • Default actor isolation. A module can declare that every declaration without an explicit isolation is @MainActor. In SwiftPM that is .defaultIsolation(MainActor.self); on the compiler command line -default-isolation MainActor. The exemptions are sensible: actors, explicitly isolated declarations, and declarations that inherit isolation from a superclass or protocol keep what they have.
  • nonisolated(nonsending). Before 6.2, a nonisolated async function always hopped to the global concurrent executor. With the NonisolatedNonsendingByDefault upcoming feature, it instead runs in the caller's isolation — call it from the main actor and it stays on the main actor.
  • @concurrent. The explicit way out: a function marked @concurrent runs on the concurrent executor, off whatever actor called it. Heavy decoding, image processing and scans get this annotation, visibly.

Xcode 26 turns these on for new app projects: SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor and SWIFT_APPROACHABLE_CONCURRENCY = YES. Existing projects keep their old settings until you change them.

These Are Meaning Changes, Not Warnings Switches

Flipping default isolation on an existing module changes what its code means: a helper that used to be nonisolated is now main-actor isolated, and a background caller must now await it. Turning on nonisolated(nonsending) changes where async code runs: an async function you relied on to leave the main actor now stays on it, and a long synchronous stretch inside it blocks the UI unless it becomes @concurrent. Neither change announces itself at the call site. Treat them as migrations, per target, with the same care as the language mode.

How the Family Reads It

The family's packages were written before these defaults existed, so they spell isolation out: @MainActor on UI types, actors for shared state, nonisolated and @Sendable where framework callbacks arrive. That style stays correct under 6.2. For a new, UI-heavy app target — Spark's Mac and iPhone apps, say — main-actor default isolation matches reality: nearly everything touches the UI. For a shared core library used by apps, extensions and a watch app, keep the nonisolated default and explicit annotations, because that code runs in many contexts and should not silently belong to one thread.

Code

Package.swift: main-actor default for the UI target only·swift
// swift-tools-version: 6.2
import PackageDescription

let package = Package(
    name: "Spark",
    platforms: [.macOS(.v14), .iOS(.v17), .watchOS(.v10)],
    targets: [
        .target(name: "SparkCore"),                 // runs everywhere: explicit isolation
        .target(
            name: "SparkUI",
            dependencies: ["SparkCore"],
            swiftSettings: [
                .defaultIsolation(MainActor.self),
                .enableUpcomingFeature("NonisolatedNonsendingByDefault"),
            ]
        ),
    ],
    swiftLanguageModes: [.v6]
)
Inside SparkUI: implicit @MainActor, explicit @concurrent·swift
import SparkCore

final class ScreenModel {          // implicitly @MainActor through defaultIsolation
    var title = "Spark"
    func rename(_ newTitle: String) { title = newTitle }
}

@concurrent
func checksum(of bytes: [UInt8]) async -> Int {   // leaves the caller's actor, on purpose
    bytes.reduce(0) { ($0 &* 31) &+ Int($1) }
}

func refresh(_ model: ScreenModel, bytes: [UInt8]) async {
    let sum = await checksum(of: bytes)            // off the main actor
    model.rename("Spark \(sum)")                   // back on it
}

External links

Exercise

Split Spark into two targets exactly as in the manifest: SparkCore with no default isolation and SparkUI with main-actor default isolation plus nonisolated(nonsending). Write one async function in SparkUI that does a deliberately slow synchronous loop, call it from a view model, and observe that the main actor is blocked. Then mark it @concurrent and observe the difference. Write two sentences on why SparkCore should not take the same default.
Hint
Thread.isMainThread is unavailable inside an async function in Swift 6, so call a small synchronous helper — nonisolated func runningOnMainThread() -> Bool { Thread.isMainThread } — from the slow function, before and after adding @concurrent. With nonsending semantics and main-actor default, a plain async function called from the main actor runs on the main thread.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.