Swift 6.2 Approachable Concurrency: `@concurrent`, Default Main Actor, and What Changed
When Swift 6 strict concurrency shipped, teams enabling it faced a brutal reality: a simple ObservableObject view
model with a single network call could generate 20 to 50 compiler errors overnight. The model was sound — eliminating
data races at compile time is the right goal — but the annotation burden made migration feel like rewriting a working
app from scratch.
Swift 6.2 doesn’t abandon that model. It changes the defaults so that the common case is safe without extra ceremony. In
this post, you’ll learn what actually changed, how @concurrent, default main actor isolation, and
nonisolated(nonsending) work together, and how to decide when to update existing code versus leave it alone. This post
assumes you’re already familiar with actors and have attempted (or completed) a
Swift 6 migration.
Note: The features covered here require Swift 6.2, available with Xcode 16.3+. Use
@available(swift 6.2, *)guards where needed.
Contents
- The Problem
- Default Main Actor Isolation
- The
@concurrentAttribute nonisolated(nonsending)- Advanced Usage
- When to Use (and When Not To)
- Summary
The Problem
Consider a straightforward FilmViewModel — a type you’ve written a hundred times. It fetches a list of Pixar films
from a remote API and publishes the result for a SwiftUI view to display. Under Swift 6 strict concurrency, this
innocent-looking code produces a cascade of errors:
// Swift 6 strict concurrency — this generates multiple errors
import SwiftUI
struct PixarFilm: Sendable {
let title: String
let year: Int
}
// ❌ Error: Main actor-isolated class 'FilmViewModel' cannot conform
// to 'Sendable' from another module
// ❌ Error: Call to main actor-isolated initializer in
// a synchronous nonisolated context
// ❌ Error: Property 'films' isolated to global actor 'MainActor'
// can not be mutated from a non-isolated context
@Observable
class FilmViewModel {
var films: [PixarFilm] = []
var isLoading = false
func loadFilms() async {
isLoading = true
// ❌ Error: Passing argument of non-sendable type to
// @MainActor-isolated context
let fetched = await FilmService().fetchAll()
films = fetched
isLoading = false
}
}
struct FilmListView: View {
@State private var viewModel = FilmViewModel()
var body: some View {
List(viewModel.films, id: \.title) { film in
Text(film.title)
}
.task { await viewModel.loadFilms() }
}
}
The fix under Swift 6.0 required sprinkling @MainActor on the view model class, marking FilmService as Sendable,
adding nonisolated to anything that didn’t need main-actor isolation, and passing @Sendable closures explicitly.
Each annotation was logically correct, but the cumulative weight made simple patterns feel unnatural.
Default Main Actor Isolation
The biggest quality-of-life change in Swift 6.2 is that
SwiftUI views and their member functions are now isolated to @MainActor by default.
More importantly, types that conform to protocols already marked @MainActor — including View, ObservableObject,
and the @Observable macro’s synthesized code — inherit that isolation automatically without an explicit annotation.
Here is the same FilmViewModel under Swift 6.2:
// Swift 6.2 — no explicit @MainActor annotation required
import SwiftUI
struct PixarFilm: Sendable {
let title: String
let year: Int
}
// ✅ Implicitly @MainActor — no annotation needed
@Observable
class FilmViewModel {
var films: [PixarFilm] = []
var isLoading = false
func loadFilms() async {
isLoading = true
let fetched = await FilmService().fetchAll()
films = fetched // ✅ Safe: we're on @MainActor
isLoading = false
}
}
struct FilmListView: View {
@State private var viewModel = FilmViewModel()
var body: some View {
List(viewModel.films, id: \.title) { film in
Text(film.title)
}
.task { await viewModel.loadFilms() }
}
}
Zero concurrency-related errors. The @Observable macro knows its synthesized storage must be accessed on the main
actor, so Swift 6.2 propagates that isolation to the entire class without requiring you to state it explicitly.
This isn’t a relaxation of the rules — the isolation is still there, enforced at compile time. The difference is that the default matches the intent for the most common SwiftUI pattern.
What Counts as “Implicitly Main-Actor Isolated”?
Swift 6.2 applies default main-actor isolation to:
- Types that conform to
@MainActor-isolated protocols (e.g.,View,UIViewControllersubclasses when using@MainActorUIKit annotations) - Types annotated with
@Observablewhen used in a SwiftUI context - Closures passed to
@MainActor-isolated parameters
It does not automatically isolate:
- Plain classes and structs with no protocol conformance
- Types used in background contexts
- Types you’ve explicitly marked
nonisolated
Tip: If you annotate a type
@MainActorexplicitly in Swift 6.2, it still works exactly as before — the explicit annotation is never wrong, just often unnecessary for SwiftUI types.
The @concurrent Attribute
Default main actor isolation solves the common case, but it creates a new question: how do you escape to a background
context from a type that is now implicitly @MainActor? Previously, you used nonisolated — but nonisolated carries
the semantics of “this function has no actor isolation at all,” which is different from “please run this function
concurrently off the main actor.”
Swift 6.2 introduces @concurrent to express that
second intent cleanly:
import Foundation
struct PixarFilm: Sendable {
let title: String
let year: Int
let studio: String
}
@Observable
class FilmViewModel {
var films: [PixarFilm] = []
var errorMessage: String?
func loadFilms() async {
do {
// fetchFilms() runs off the main actor — result returned safely
let fetched = try await fetchFilms()
films = fetched
} catch {
errorMessage = error.localizedDescription
}
}
// @concurrent: explicitly opts this function into concurrent execution
// Runs on the cooperative thread pool, not the main actor
@concurrent
func fetchFilms() async throws -> [PixarFilm] {
let url = URL(string: "https://api.pixar.example.com/films")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode([PixarFilm].self, from: data)
}
}
The key semantic difference from nonisolated:
| Keyword | Meaning | Callable from @MainActor? |
|---|---|---|
nonisolated | No actor isolation — caller’s context determines thread | Yes, but with caveats |
@concurrent | Explicitly runs on the cooperative thread pool | Yes — return must be Sendable |
@concurrent is particularly valuable when you have a long computation — decoding a large JSON payload, resizing an
image, running a Core ML inference — that would block the main actor if called directly. Marking it @concurrent makes
the intent explicit and moves it off the main thread without requiring a manual Task.detached.
@concurrent with Closures
@concurrent also applies to closure parameters, letting you mark a callback as one that must run off the main actor:
// Simplified for clarity
func processRenderJob(
asset: PixarAsset,
@concurrent completion: @escaping (RenderResult) -> Void
) {
// completion will always be called on a concurrent context
Task {
let result = await render(asset: asset)
completion(result)
}
}
This is a lighter-weight alternative to wrapping the completion in Task.detached at every call site.
nonisolated(nonsending)
The third major addition targets a common false positive: closures that look like they escape across isolation boundaries but actually don’t. Consider a completion handler used entirely within the scope of an actor-isolated function:
@Observable
class RenderQueueViewModel {
var completedFrames: [RenderedFrame] = []
func processQueue(_ jobs: [RenderJob]) async {
for job in jobs {
// ❌ Swift 6.0: closure inferred as @Sendable, which requires
// RenderedFrame to be Sendable even though it never escapes
await renderEngine.process(job) { frame in
self.completedFrames.append(frame)
}
}
}
}
Under Swift 6.0, the closure passed to renderEngine.process is inferred as @Sendable because the compiler
conservatively assumes it could escape to another isolation context. If RenderedFrame isn’t Sendable, you get a
compiler error — even though the closure is called synchronously within the current isolation domain and never actually
crosses an isolation boundary.
Swift 6.2 introduces
nonisolated(nonsending) for closure
parameters that do not escape the current isolation context:
// Simplified for clarity — library function signature
func process(
_ job: RenderJob,
completion: nonisolated(nonsending) (RenderedFrame) -> Void
) async {
let frame = await performRender(job)
completion(frame) // Called within the same isolation domain
}
With this annotation on the library side, the caller’s closure no longer needs RenderedFrame to be Sendable. The
compiler understands the closure stays in the current isolation domain and doesn’t produce a false-positive Sendable
error.
Note:
nonisolated(nonsending)is a library-author API. If you’re consuming Apple frameworks, this benefit is already baked into the updated SDK annotations in Swift 6.2.
Advanced Usage
Custom Global Actors and Default Isolation
When you define a custom global actor, the default main
actor isolation rules don’t apply to types that use it. A type isolated to @RenderActor is not implicitly
@MainActor:
@globalActor
actor RenderActor: GlobalActor {
static let shared = RenderActor()
}
// Isolated to RenderActor — not @MainActor
// You must be explicit here
@RenderActor
class PixarRenderPipeline {
var frameBuffer: [CGImage] = []
func renderScene(_ scene: Scene) async -> CGImage {
// Runs on RenderActor
return await performRender(scene)
}
// Use @concurrent to escape to the thread pool for heavy CPU work
@concurrent
func compressOutput(_ images: [CGImage]) async -> Data {
// Runs off RenderActor on the cooperative thread pool
return compress(images)
}
}
The rule is: default main actor isolation applies when the protocol or macro establishing the type’s behavior is itself
@MainActor. For custom global actors, explicit annotation remains required.
The sending Keyword for Safe Cross-Isolation Transfer
When you transfer a value between isolation domains, Swift 6.2 refines the older @Sendable closure requirement with
the sending ownership modifier on parameters and
return types:
// sending parameter: value is consumed by the callee,
// preventing aliased mutation across isolation boundaries
func submitToRenderFarm(scene: sending Scene) async -> RenderResult {
// scene ownership transferred here — caller cannot access it again
return await RenderActor.shared.render(scene)
}
sending is stricter than Sendable in one important way: it transfers ownership rather than requiring the type to be
safe to share concurrently. This matters for large, mutable reference types (like complex Scene graphs) that you want
to pass across actors without making them fully Sendable.
The Evolution Path: Swift 5 → Swift 6 → Swift 6.2
Understanding where these changes fit in the broader migration story prevents surprises when working in a mixed-version codebase:
| Era | Default Isolation | Migration Impact |
|---|---|---|
| Swift 5 | None — all concurrency opt-in | No errors; latent data races possible |
| Swift 6.0 strict | None — @MainActor must be explicit | High annotation burden; 50+ errors common |
| Swift 6.2 | Implicit @MainActor for SwiftUI types | Most SwiftUI code compiles cleanly |
Existing Swift 6.0 code with explicit @MainActor annotations continues to compile correctly in Swift 6.2 — the new
defaults are non-breaking. You can adopt them incrementally by removing redundant @MainActor annotations from
@Observable classes and SwiftUI views, then using @concurrent where you need background execution.
Before vs. After: FilmViewModel in Full
Here is the same production-grade view model written under Swift 6.0 strict and then under Swift 6.2, side by side:
// Swift 6.0 strict — explicit annotations required everywhere
@MainActor
@Observable
final class FilmViewModel {
var films: [PixarFilm] = []
var isLoading = false
var errorMessage: String?
nonisolated func fetchFilms() async throws -> [PixarFilm] {
let url = URL(string: "https://api.pixar.example.com/films")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode([PixarFilm].self, from: data)
}
func loadFilms() async {
isLoading = true
defer { isLoading = false }
do {
films = try await fetchFilms()
} catch {
errorMessage = error.localizedDescription
}
}
}
// Swift 6.2 — default isolation handles @MainActor; @concurrent replaces nonisolated
@Observable
final class FilmViewModel { // ← @MainActor implicit via @Observable
var films: [PixarFilm] = []
var isLoading = false
var errorMessage: String?
@concurrent // ← Intent is clearer than nonisolated
func fetchFilms() async throws -> [PixarFilm] {
let url = URL(string: "https://api.pixar.example.com/films")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode([PixarFilm].self, from: data)
}
func loadFilms() async {
isLoading = true
defer { isLoading = false }
do {
films = try await fetchFilms()
} catch {
errorMessage = error.localizedDescription
}
}
}
The logic is identical. The Swift 6.2 version is shorter, and @concurrent communicates intent better than
nonisolated — a function that runs on the thread pool is concurrent, not isolated-to-nothing.
When to Use (and When Not To)
| Scenario | Recommendation |
|---|---|
New SwiftUI view models with @Observable | Omit @MainActor — it’s implicit. Use @concurrent for background work. |
Existing Swift 6.0 code with explicit @MainActor | Leave it. Removing annotations is cosmetic, not necessary. |
Background computation in a @MainActor type | Replace nonisolated with @concurrent to clarify intent. |
| Custom global actor types | Keep explicit actor annotations — defaults don’t apply. |
| Library code with non-escaping callbacks | Add nonisolated(nonsending) to avoid false-positive Sendable errors. |
| Large value types crossing isolation boundaries | Use sending instead of requiring Sendable conformance. |
| UIKit codebases, no SwiftUI | Fewer implicit isolations apply — explicit annotations remain the right choice. |
Warning: Don’t rely solely on implicit isolation when writing code intended to run in both SwiftUI and UIKit contexts. The implicit
@MainActorinference is tied to SwiftUI protocol conformances; UIKit types have different annotation rules.
Summary
- Swift 6.2 makes
@MainActorisolation implicit for SwiftUI types (@Observable,View, etc.), eliminating most of the annotation boilerplate that made Swift 6.0 migration painful. - The new
@concurrentattribute explicitly opts a function into concurrent (off-main-actor) execution — clearer in intent thannonisolatedfor background work. nonisolated(nonsending)on closure parameters tells the compiler a closure won’t cross isolation boundaries, eliminating false-positiveSendableerrors for non-escaping callbacks.- The
sendingownership modifier on parameters and return types enables safe cross-isolation transfer without requiring fullSendableconformance. - Existing Swift 6.0 code with explicit annotations compiles unchanged — Swift 6.2 changes defaults, not rules.
- Custom global actor types still require explicit annotations; default isolation is tied to
@MainActor-isolated protocols.
Now that your view models are clean, the natural next step is understanding how these isolation rules interact with SwiftUI’s rendering pipeline — explored in depth in Concurrency in SwiftUI.