Exploring concurrency changes in Swift 6.2
Published on: May 20, 2025It's no secret that Swift concurrency can be pretty difficult to learn. There are a lot of concepts that are different from what you're used to when you were writing code in GCD. Apple recognized this in one of their vision documents and they set out to make changes to how concurrency works in Swift 6.2. They're not going to change the fundamentals of how things work. What they will mainly change is where code will run by default.
In this blog post, I would like to take a look at the two main features that will change how your Swift concurrency code works:
- The new
nonisolated(nonsending)
default feature flag - Running code on the main actor by default with the
defaultIsolation
setting
By the end of this post you should have a pretty good sense of the impact that Swift 6.2 will have on your code, and how you should be moving forward until Swift 6.2 is officially available in a future Xcode release.
Understanding nonisolated(nonsending)
The nonisolated(nonsending)
feature is introduced by SE-0461 and it’s a pretty big overhaul in terms of how your code will work moving forward. At the time of writing this, it’s gated behind an upcoming feature compiler flag called NonisolatedNonsendingByDefault
. To enable this flag on your project, see this post on leveraging upcoming features in an SPM package, or if you’re looking to enable the feature in Xcode, take a look at enabling upcoming features in Xcode.
For this post, I’m using an SPM package so my Package.swift
contains the following:
.executableTarget(
name: "SwiftChanges",
swiftSettings: [
.enableExperimentalFeature("NonisolatedNonsendingByDefault")
]
)
I’m getting ahead of myself though; let’s talk about what nonisolated(nonsending)
is, what problem it solves, and how it will change the way your code runs significantly.
Exploring the problem with nonisolated in Swift 6.1 and earlier
When you write async functions in Swift 6.1 and earlier, you might do so on a class or struct as follows:
class NetworkingClient {
func loadUserPhotos() async throws -> [Photo] {
// ...
}
}
When loadUserPhotos
is called, we know that it will not run on any actor. Or, in more practical terms, we know it’ll run away from the main thread. The reason for this is that loadUserPhotos
is a nonisolated
and async
function.
This means that when you have code as follows, the compiler will complain about sending a non-sendable instance of NetworkingClient
across actor boundaries:
struct SomeView: View {
let network = NetworkingClient()
var body: some View {
Text("Hello, world")
.task { await getData() }
}
func getData() async {
do {
// sending 'self.network' risks causing data races
let photos = try await network.loadUserPhotos()
} catch {
// ...
}
}
}
When you take a closer look at the error, the compiler will explain:
sending main actor-isolated 'self.network' to nonisolated instance method 'loadUserPhotos()' risks causing data races between nonisolated and main actor-isolated uses
This error is very similar to one that you’d get when sending a main actor isolated value into a sendable closure.
The problem with this code is that loadUserPhotos
runs in its own isolation context. This means that it will run concurrently with whatever the main actor is doing.
Since our instance of NetworkingClient
is created and owned by the main actor we can access and mutate our networking
instance while loadUserPhotos
is running in its own isolation context. Since that function has access to self
, it means that we can have two isolation contexts access the same instance of NetworkingClient
at the exact same time.
And as we know, multiple isolation contexts having access to the same object can lead to data races if the object isn’t sendable.
The difference between an async and non-async function that’s nonisolated like loadUserPhotos
is that the non-async function would run on the caller’s actor. So if we call a nonisolated async
function from the main actor then the function will run on the main actor. When we call a nonisolated async
function from a place that’s not on the main actor, then the called function will not run on the main actor.
Swift 6.2 aims to fix this with a new default for nonisolated
functions.
Understanding nonisolated(nonsending)
The behavior in Swift 6.1 and earlier is inconsistent and confusing for folks, so in Swift 6.2, async functions will adopt a new default for nonisolated functions called nonisolated(nonsending)
. You don’t have to write this manually; it’s the default so every nonisolated async
function will be nonsending unless you specify otherwise.
When a function is nonisolated(nonsending)
it means that the function won’t cross actor boundaries. Or, in a more practical sense, a nonisolated(nonsending)
function will run on the caller’s actor.
So when we opt-in to this feature by enabling the NonisolatedNonsendingByDefault
upcoming feature, the code we wrote earlier is completely fine.
The reason for that is that loadUserPhotos()
would now be nonisolated(nonsending)
by default, and it would run its function body on the main actor instead of running it on the cooperative thread pool.
Let’s take a look at some examples, shall we? We saw the following example earlier:
class NetworkingClient {
func loadUserPhotos() async throws -> [Photo] {
// ...
}
}
In this case, loadUserPhotos
is both nonisolated
and async
. This means that the function will receive a nonisolated(nonsending)
treatment by default, and it runs on the caller’s actor (if any). In other words, if you call this function on the main actor it will run on the main actor. Call it from a place that’s not isolated to an actor; it will run away from the main thread.
Alternatively, we might have added a @MainActor
declaration to NetworkingClient
:
@MainActor
class NetworkingClient {
func loadUserPhotos() async throws -> [Photo] {
return [Photo()]
}
}
This makes loadUserPhotos
isolated to the main actor so it will always run on the main actor, no matter where it’s called from.
Then we might also have the main actor annotation along with nonisolated
on loadUserPhotos
:
@MainActor
class NetworkingClient {
nonisolated func loadUserPhotos() async throws -> [Photo] {
return [Photo()]
}
}
In this case, the new default kicks in even though we didn’t write nonisolated(nonsending)
ourselves. So, NetworkingClient
is main actor isolated but loadUserPhotos
is not. It will inherit the caller’s actor. So, once again if we call loadUserPhotos
from the main actor, that’s where we’ll run. If we call it from some other place, it will run there.
So what if we want to make sure that our function never runs on the main actor? Because so far, we’ve only seen possibilities that would either isolate loadUserPhotos
to the main actor, or options that would inherit the callers actor.
Running code away from any actors with @concurrent
Alongside nonisolated(nonsending)
, Swift 6.2 introduces the @concurrent
keyword. This keyword will allow you to write functions that behave in the same way that your code in Swift 6.1 would have behaved:
@MainActor
class NetworkingClient {
@concurrent
nonisolated func loadUserPhotos() async throws -> [Photo] {
return [Photo()]
}
}
By marking our function as @concurrent
, we make sure that we always leave the caller’s actor and create our own isolation context.
The @concurrent
attribute should only be applied to functions that are nonisolated. So for example, adding it to a method on an actor won’t work unless the method is nonisolated:
actor SomeGenerator {
// not allowed
@concurrent
func randomID() async throws -> UUID {
return UUID()
}
// allowed
@concurrent
nonisolated func randomID() async throws -> UUID {
return UUID()
}
}
Note that at the time of writing both cases are allowed, and the @concurrent
function that’s not nonisolated
acts like it’s not isolated at runtime. I expect that this is a bug in the Swift 6.2 toolchain and that this will change since the proposal is pretty clear about this.
How and when should you use NonisolatedNonSendingByDefault
In my opinion, opting in to this upcoming feature is a good idea. It does open you up to a new way of working where your nonisolated async
functions inherit the caller’s actor instead of always running in their own isolation context, but it does make for fewer compiler errors in practice, and it actually helps you get rid of a whole bunch of main actor annotation based on what I’ve been able to try so far.
I’m a big fan of reducing the amount of concurrency in my apps and only introducing it when I want to explicitly do so. Adopting this feature helps a lot with that. Before you go and mark everything in your app as @concurrent
just to be sure; ask yourself whether you really have to. There’s probably no need, and not running everything concurrently makes your code, and its execution a lot easier to reason about in the big picture.
That’s especially true when you also adopt Swift 6.2’s second major feature: defaultIsolation
.
Exploring Swift 6.2’s defaultIsolation options
In Swift 6.1 your code only runs on the main actor when you tell it to. This could be due to a protocol being @MainActor
annotated or you explicitly marking your views, view models, and other objects as @MainActor
.
Marking something as @MainActor
is a pretty common solution for fixing compiler errors and it’s more often than not the right thing to do.
Your code really doesn’t need to do everything asynchronously on a background thread.
Doing so is relatively expensive, often doesn’t improve performance, and it makes your code a lot harder to reason about. You wouldn’t have written DispatchQueue.global()
everywhere before you adopted Swift Concurrency, right? So why do the equivalent now?
Anyway, in Swift 6.2 we can make running on the main actor the default on a package level. This is a feature introduced by SE-0466.
This means that you can have UI packages and app targets and model packages etc, automatically run code on the main actor unless you explicitly opt-out of running on main with @concurrent
or through your own actors.
Enable this feature by setting defaultIsolation
in your swiftSettings
or by passing it as a compiler argument:
swiftSettings: [
.defaultIsolation(MainActor.self),
.enableExperimentalFeature("NonisolatedNonsendingByDefault")
]
You don’t have to use defaultIsolation
alongside NonisolatedNonsendingByDefault
but I did like to use both options in my experiments.
Currently you can either pass MainActor.self
as your default isolation to run everything on main by default, or you can use nil
to keep the existing behavior (or don’t pass the setting at all to keep the existing behavior).
Once you enable this feature, Swift will infer every object to have an @MainActor
annotation unless you explicitly specify something else:
@Observable
class Person {
var myValue: Int = 0
let obj = TestClass()
// This function will _always_ run on main
// if defaultIsolation is set to main actor
func runMeSomewhere() async {
MainActor.assertIsolated()
// do some work, call async functions etc
}
}
This code contains a nonisolated async
function. This means that, by default, it would inherit the actor that we call runMeSomewhere
from. If we call it from the main actor that’s where it runs. If we call it from another actor or from no actor, it runs away from the main actor.
This probably wasn’t intended at all.
Maybe we just wrote an async function so that we could call other functions that needed to be awaited. If runMeSomewhere
doesn’t do any heavy processing, we probably want Person
to be on the main actor. It’s an observable class so it probably drives our UI which means that pretty much all access to this object should be on the main actor anyway.
With defaultIsolation
set to MainActor.self
, our Person
gets an implicit @MainActor
annotation so our Person
runs all its work on the main actor.
Let’s say we want to add a function to Person
that’s not going to run on the main actor. We can use nonisolated
just like we would otherwise:
// This function will run on the caller's actor
nonisolated func runMeSomewhere() async {
MainActor.assertIsolated()
// do some work, call async functions etc
}
And if we want to make sure we’re never on the main actor:
// This function will run on the caller's actor
@concurrent
nonisolated func runMeSomewhere() async {
MainActor.assertIsolated()
// do some work, call async functions etc
}
We need to opt-out of this main actor inference for every function or property that we want to make nonisolated; we can’t do this for the entire type.
Of course, your own actors will not suddenly start running on the main actor and types that you’ve annotated with your own global actors aren’t impacted by this change either.
Should you opt-in to defaultIsolation?
This is a tough question to answer. My initial thought is “yes”. For app targets, UI packages, and packages that mainly hold view models I definitely think that going main actor by default is the right choice.
You can still introduce concurrency where needed and it will be much more intentional than it would have been otherwise.
The fact that entire objects will be made main actor by default seems like something that might cause friction down the line but I feel like adding dedicated async packages would be the way to go here.
The motivation for this option existing makes a lot of sense to me and I think I’ll want to try it out for a bit before making up my mind fully.