~/writing/concurrency

Approachable Concurrency, nonisolated by default: the setup I use and why

What Approachable Concurrency actually turns on, why I keep default isolation at nonisolated instead of MainActor, and the one-line version Swift packages finally get in Xcode 27.

published updated read 7 min

#concurrency#tooling

Approachable Concurrency, nonisolated by default: the setup I use and why

Note

Updated for Xcode 27

When I first wrote this, Swift packages had no single switch for Approachable Concurrency: you had to list each flag yourself. With Xcode 27 (I tested it on Xcode 27.2, Swift 6.4), .enableUpcomingFeature("ApproachableConcurrency") works in Package.swift. I rewrote the post around that, and in my own words this time.

Why I care about this at all

The more Swift pushes strict concurrency, the more the defaults lean toward MainActor. New Xcode projects now make everything @MainActor unless you say otherwise. I get why: it makes Swift simpler for someone starting out, and most of the posts I’ve read about it say exactly that.

But I’m one of those people who likes to be in control. For years we’ve written Swift where code isn’t tied to any actor unless we put it there, and that’s still how I think about my apps. So when I set up a project or a package today, I keep default isolation at nonisolated and turn on Approachable Concurrency. I first saw this setup recommended by Julio César Fernández at Apple Coding Academy, and it’s the one I follow.

This post is about understanding the model well enough to pick between the two options. I’ll tell you which one I use, but both are valid.

Two knobs, not one

The part that confused me the most at first: Approachable Concurrency and default actor isolation are separate settings. It’s easy to read about them together and assume one includes the other. It doesn’t.

SettingWhat it decides
Default Actor IsolationWhere your types live when you don’t annotate them: on MainActor, or on no actor.
Approachable ConcurrencyA bundle of upcoming features that change how isolation is inferred.

In Xcode they’re the SWIFT_DEFAULT_ACTOR_ISOLATION and SWIFT_APPROACHABLE_CONCURRENCY build settings. In a package they’re .defaultIsolation(...) and, since Xcode 27, .enableUpcomingFeature("ApproachableConcurrency").

Approachable Concurrency turns on five upcoming features:

FeatureProposalIn Swift 6 mode
NonisolatedNonsendingByDefaultSE-0461new, this is the big one
InferIsolatedConformancesSE-0470new
InferSendableFromCapturesSE-0418already on
GlobalActorIsolatedTypesUsabilitySE-0434already on
DisableOutwardActorInferenceSE-0401already on

So if you’re already in the Swift 6 language mode, Approachable Concurrency really adds two things. Those are the two worth understanding.

Nonsending: async code stays where you called it

Before Swift 6.2, a nonisolated async function always ran on the global executor, off whatever actor called it. You could call it from the main actor and suddenly your code was somewhere else, which is where a lot of those “sending a non-Sendable value” errors came from.

With NonisolatedNonsendingByDefault, a nonisolated async function runs on the caller’s actor. Call it from the main actor, and it runs on the main actor. Nothing hops threads without you knowing. That’s the control I was talking about.

There’s one thing I had wrong in my head, and I think a lot of people do: await doesn’t mean “this runs in the background.” When you await a URLSession request, the network work happens inside URLSession and your thread isn’t blocked. But your own code around that await, like decoding the JSON, runs on the caller’s actor. If the caller is the UI, that’s the main thread.

When you really want to leave the caller’s actor, say so with @concurrent:

public final class FeedLoader: Sendable {
private let client: any HTTPClient
private let url: URL
public init(client: any HTTPClient, url: URL) {
self.client = client
self.url = url
}
// nonisolated + nonsending: runs on the caller's actor.
public func load() async throws -> [Post] {
let data = try await client.data(from: url)
return try await FeedParser.parse(data)
}
}
enum FeedParser {
// Heavy work: leave the caller's actor on purpose.
@concurrent
static func parse(_ data: Data) async throws -> [Post] {
try JSONDecoder().decode([Post].self, from: data)
}
}

And the UI side, the only place I put @MainActor:

@MainActor
@Observable
public final class FeedViewModel {
public private(set) var titles: [String] = []
private let loader: FeedLoader
public init(loader: FeedLoader) { self.loader = loader }
public func refresh() async {
// load() inherits the main actor; the parser hops off on its own.
let posts = (try? await loader.load()) ?? []
titles = posts.map(\.title)
}
}

The networking code doesn’t know or care about the main actor. The view model does, because it’s the one that touches the UI. And the one piece that’s actually expensive says it out loud.

Isolated conformances

I’ll be honest: this is the one I’m still getting comfortable with. The idea is that if your type is isolated to an actor and it conforms to a protocol, the conformance gets the same isolation as the type.

The classic case is a @MainActor class conforming to Equatable:

@MainActor
public final class FeedItem: Equatable {
public let title: String
public init(title: String) { self.title = title }
// Inferred as a @MainActor conformance: no nonisolated workaround needed.
public static func == (lhs: FeedItem, rhs: FeedItem) -> Bool {
lhs.title == rhs.title
}
}

Without InferIsolatedConformances, that code doesn’t compile: the compiler says the conformance “crosses into main actor-isolated code and can cause data races,” and you end up marking == as nonisolated or reaching for some other workaround. With it, the conformance is a main-actor conformance, and it just works.

The catch is that you can only use that conformance from the main actor. If you try to pass a FeedItem to generic code that needs Equatable & Sendable from somewhere else, the compiler stops you, which is exactly what it should do.

My config in a package

This is what I have in my Package.swift today. I list every flag on purpose, because it reminds me exactly what I’m turning on:

6.2
import PackageDescription
let sharedSwiftSettings: [SwiftSetting] = [
.enableUpcomingFeature("ExistentialAny"),
.enableUpcomingFeature("NonisolatedNonsendingByDefault"),
.enableUpcomingFeature("InferIsolatedConformances"),
.enableUpcomingFeature("MemberImportVisibility"),
.defaultIsolation(nil),
.swiftLanguageMode(.v6),
]

.defaultIsolation(nil) is the package version of “Default Actor Isolation: nonisolated.” The two features in the middle are what Approachable Concurrency adds in Swift 6 mode.

With Xcode 27, the same thing fits in one line:

let sharedSwiftSettings: [SwiftSetting] = [
.enableUpcomingFeature("ExistentialAny"),
.enableUpcomingFeature("ApproachableConcurrency"),
.enableUpcomingFeature("MemberImportVisibility"),
.defaultIsolation(nil),
.swiftLanguageMode(.v6),
]

I’ve checked that both build the same code. I’m still on the long version for now, but I’ll probably move to the short one once I stop needing the reminder.

In an app target, it’s the same pair of knobs in Build Settings: Default Actor Isolation set to nonisolated and Approachable Concurrency set to Yes. I keep the app and its packages on the same setup. I like an all-or-nothing standard, and so far I haven’t found a reason to mix them.

MainActor default or nonisolated?

Both work. Here’s how I see it:

MainActor defaultnonisolated default (mine)
Annotationsalmost none@MainActor on UI types and view models
Good forbeginners, small UI-heavy appspeople who want to see where code runs
Packageseverything ends up on the main actor unless you opt outnothing assumes the UI
Background workmark what’s not UI as nonisolated, and use @concurrentuse @concurrent

One more reason I like nonisolated: it makes @MainActor mean something. With MainActor as the default, writing @MainActor on a view model is just repeating the default. With nonisolated as the default, every @MainActor in the code is a decision someone made, and you can see it.

Two extras I always add

These aren’t concurrency features, but they’re in my settings, so here they are:

  • ExistentialAny (SE-0335). Using a protocol as a type requires the any keyword: any HTTPClient instead of HTTPClient. It makes the cost of an existential visible and pushes you to think about whether some or a generic would do. I picked it up from Paul Hudson’s Swift Accelerator on Hacking with Swift+.
  • MemberImportVisibility (SE-0444). A file only sees members from the modules it imports itself, instead of picking them up because some other file imported them. That’s just a personal preference: I like each file to say what it depends on.

Where I relearned all this

I relearned this whole model in Apple Coding Academy’s Swift Engineering Program, with Julio. The current edition started in October, and the next one should open next year. If you want to understand Swift concurrency properly, and not just make the warnings go away, I can’t recommend it enough.