Default Actor Isolation: New Problems from Good Intentions

Published on

While Swift’s strict concurrency checking has good intentions, it significantly increases the burden on developers in many single-threaded scenarios. Developers are forced to add unnecessary Sendable, @MainActor, and other declarations to their code just to satisfy the compiler’s requirements. Swift 6.2’s new Default Actor Isolation feature will greatly improve this situation and reduce unnecessary boilerplate code. This article will introduce the Default Actor Isolation feature and point out some situations to be aware of when using it.

The Motivation

Many developers find that after enabling Swift’s strict concurrency checking, some previously well-functioning code will generate numerous warnings or even errors. This is especially true for single-threaded code that clearly runs on the MainActor, yet still fails to compile. Developers need to make considerable adjustments to this code to satisfy the compiler’s requirements.

This situation occurs because, prior to Swift 6.2, the compiler’s default inference strategy for concurrency was: if a function or type is not explicitly declared or inferred to have an isolation domain, it is treated as non-isolated, meaning it can be used concurrently. Even when you know that most of the code in a module only runs on MainActor, there was still no unified way to communicate this fact to the compiler.

The Default Actor Isolation feature (SE-0466) was created to solve this problem. This feature provides developers with the ability to uniformly indicate to the compiler that code within a Target scope runs on MainActor. Once configured, when compiling that Target’s code, the compiler will implicitly infer code without explicit isolation domain annotations to be isolated to @MainActor, thereby reducing the developer’s burden.

Usage

In Xcode 26, newly created projects have this option enabled by default and set the default isolation domain to MainActor. If you want to restore your project’s Default Actor Isolation to the previous behavior, you can modify it in Build Settings.

image-20250728145844766

For SPM, the default non-isolated setting is still used. You can set a Target’s Default Actor Isolation to MainActor in the following way:

Swift
.target(
    name: "CareLogUI",
    swiftSettings: [
        .defaultIsolation(MainActor.self), // set Default Actor Isolation 
    ]),

Escaping from the Isolation Domain

Some developers might notice that if you add the following code to an Xcode 26 SwiftData template project, it will generate an error:

Swift
@Model
final class Item {
    var timestamp: Date
    
    init(timestamp: Date) {
        self.timestamp = timestamp
    }
}

// New code using SwiftData ModelActor macro
@ModelActor
actor DataHandler {
    func createItem(timeStamp: Date) throws {
        let item = Item(timestamp: timeStamp)
        modelContext.insert(item)
        try modelContext.save()
    }
}

image-20250728151112896

This problem occurs because Xcode 26’s template code sets Default Actor Isolation to MainActor. In the above code, since DataHandler is an Actor, the Swift compiler respects its isolation domain (rather than using the default MainActor), but the Item declaration has no annotations, so the compiler implicitly infers it can only run on MainActor (effectively treating the type as if @MainActor were applied). Consequently, creating an Item in a non-MainActor isolation domain (DataHandler) violates the principles of safe concurrency.

The solution is simple. Add nonisolated before the Item declaration, so the Swift compiler won’t apply default isolation inference to Item. The Item type can then run in different isolation domains.

Swift
@Model
nonisolated final class Item { // Add nonisolated
    var timestamp: Date
    
    init(timestamp: Date) {
        self.timestamp = timestamp
    }
}

Of course, if you only want to remove a specific property or method from the default isolation domain, you can directly add nonisolated in front of it.

Swift
class RunInMainActor {    
    var name: String = "example" // Runs on MainActor
    
    nonisolated func processData() async {
        // Escapes MainActor isolation domain (at compile-time)
    }
    
    nonisolated var computedValue: String {
        // Non-isolated computed property (at compile-time)
        return "computed"
    }
}

Important note: Due to significant semantic changes to nonisolated in Swift 6.2 (introducing nonisolated(nonsending) as the default behavior when NonisolatedNonsendingByDefault is enabled), nonisolated async methods now inherit the caller’s isolation domain rather than forcibly switching to background execution as before. If you truly need to force a method to execute on a background thread, you should use the @concurrent annotation:

Swift
class RunInMainActor {        
    @concurrent
    func guaranteedBackground() async {
        // Guaranteed to execute on background thread
    }
}

If you find yourself needing to add nonisolated extensively in a project with Default Actor Isolation set to MainActor, you should consider whether to continue using this pattern. One solution is to change the project’s default isolation to nonisolated, another is to move this portion of the code into a dedicated Target Target that uses the previous nonisolated default isolation inference approach.

From one perspective, Default Actor Isolation, while reducing developer burden, also encourages more people to adopt modular programming.

Default Actor Isolation for MainActor vs @MainActor Are Not Exactly the Same

Although in most cases we can treat setting Default Actor Isolation to MainActor as automatically adding @MainActor to unmarked types, there are still some differences between the two in certain situations. For example, consider this code:

Swift
@MainActor
class UserMainActorClass {
    private final class DefaultsObservation: @unchecked Sendable {
        private var notificationObserver: NSObjectProtocol?
      
        deinit {
            if let observer = notificationObserver {
                NotificationCenter.default.removeObserver(observer)
            }
        }
    }
}

This code compiles correctly when Default Actor Isolation is nonisolated, but generates a compilation error when switched to MainActor:

image-20250728154754751

At this point, the compiler considers the deinit of the internal nested type to not be in the same isolation domain. We must add isolated or @MainActor before deinit to make the compiler treat this deinit as running on @MainActor.

Swift
class UserMainActorClass {
    private final class DefaultsObservation: @unchecked Sendable {
        private var notificationObserver: NSObjectProtocol?
        
        isolated // or @MainActor
        deinit {
            if let observer = notificationObserver {
                NotificationCenter.default.removeObserver(observer)
            }
        }
    }
}

I’m not sure whether this difference is intentionally designed or a current defect, but at least in scenarios involving nested type declarations, Default Actor Isolation set to MainActor is not completely equivalent to explicit @MainActor.

New Challenges for Macro Authors

A few days ago, my ObservableDefaults macro received an Issue. A user reported that in Xcode 26, when Default Actor Isolation is set to MainActor, a compilation error occurs:

Swift
 @Sendable // ERROR: Main actor-isolated synchronous instance method 'userDefaultsDidChange' cannot be marked as '@Sendable'

When I first saw this Issue, I was somewhat puzzled because the ObservableDefaults macro implementation specifically handles @MainActor. When it detects that a user-declared type is annotated with @MainActor, it responds accordingly in the generated code. The following code is used in the macro to determine whether a type has been annotated with @MainActor:

Swift
let hasExplicitMainActor = classDecl.attributes.contains(where: { attribute in
    if case let .attribute(attr) = attribute,
       let identifierType = attr.attributeName.as(IdentifierTypeSyntax.self)
    {
        return identifierType.name.text == "MainActor"
    }
    return false
})

Obviously, when Default Actor Isolation is set to MainActor, the corresponding branch is not taken.

I also discovered the same phenomenon when testing in Xcode 26. When Default Actor Isolation is set to MainActor, although the compiler uses default isolation domain inference, the macro cannot detect this situation.

Since I haven’t found a way to obtain Default Actor Isolation status within macros (and may never have one), I ultimately had to add a macro parameter, allowing users to explicitly indicate when Default Actor Isolation is set to MainActor, so the macro can make judgments based on this parameter state:

Swift
@ObservableDefaults(defaultIsolationIsMainActor: true)
class Settings {
    var name: String = "Fatbobman"
    var age: Int = 20
}

This presents a new kind of challenge for macro authors introduced by Default Actor Isolation.

Conclusion

Xcode 26’s default setting of Default Actor Isolation to MainActor may cause some confusion for developers in the short term, but once familiar with this feature, developers will appreciate this new change in Swift 6.2, as it facilitates the further adoption of Swift’s strict concurrency.

If this article helped you, feel free to buy me a coffee ☕️ . For sponsorship inquiries, please check out the details here.

Weekly Swift & SwiftUI highlights!