Should you opt-in to Swift 6.2’s Main Actor isolation?

Published on: September 11, 2025

Swift 6.2 comes with a some interesting Concurrency improvements. One of the most notable changes is that there's now a compiler flag that will, by default, isolate all your (implicitly nonisolated) code to the main actor. This is a huge change, and in this post we'll explore whether or not it's a good change. We'll do this by taking a look at some of the complexities that concurrency introduces naturally, and we'll assess whether moving code to the main actor is the (correct) solution to these problems.

By the end of this post, you should hopefully be able to decide for yourself whether or not main actor isolation makes sense. I encourage you to read through the entire post and to carefully think about your code and its needs before you jump to conclusions. In programming, the right answer to most problems depends on the exact problems at hand. This is no exception.

We'll start off by looking at the defaults for main actor isolation in Xcode 26 and Swift 6. Then we'll move on to determining whether we should keep these defaults or not.

Understanding how Main Actor isolation is applied by default in Xcode 26

When you create a new project in Xcode 26, that project will have two new features enabled:

  • Global actor isolation is set to MainActor.self
  • Approachable concurrency is enabled

If you want to learn more about approachable concurrency in Xcode 26, I recommend you read about it in my post on Approachable Concurrency.

The global actor isolation setting will automatically isolate all your code to either the Main Actor or no actor at all (nil and MainActor.self are the only two valid values).

This means that all code that you write in a project created with Xcode 26 will be isolated to the main actor (unless it's isolated to another actor or you mark the code as nonisolated):

// this class is @MainActor isolated by default
class MyClass {
  // this property is @MainActor isolated by default
  var counter = 0

  func performWork() async {
    // this function is @MainActor isolated by default
  }

  nonisolated func performOtherWork() async {
    // this function is nonisolated so it's not @MainActor isolated
  }
}

// this actor and its members won't be @MainActor isolated
actor Counter {
  var count = 0
}

The result of your code bein main actor isolated by default is that your app will effectively be single threaded unless you explicitly introduce concurrency. Everything you do will start off on the main thread and stay there unless you decide you need to leave the Main Actor.

Understanding how Main Actor isolation is applied for new SPM Packages

For SPM packages, it's a slightly different story. A newly created SPM Package will not have its defaultIsolation flag set at all. This means that a new SPM Package will not isolate your code to the MainActor by default.

You can change this by passing defaultIsolation to your target's swiftSettings:

swiftSettings: [
    .defaultIsolation(MainActor.self)
]

Note that a newly created SPM Package also won't have Approachable Concurrency turned on. More importantly, it won't have NonIsolatedNonSendingByDefault turned on by default. This means that there's an interesting difference between code in your SPM Packages and your app target.

In your app target, everything will run on the Main Actor by default. Any functions that you've defined in your app target and are marked as nonisolated and async will run on the caller's actor by default. So if you're calling your nonisolated async functions from the main actor in your app target they will run on the Main Actor. Call them from elsewhere and they'll run there.

In your SPM Packages, the default is for your code to not run on the Main Actor by default, and for nonisolated async functions to run on a background thread no matter what.

Confusing isn't it? I know...

The rationale for running code on the Main Actor by default

In a codebase that relies heavily on concurrency, you'll have to deal with a lot of concurrency-related complexity. More specifically, a codebase with a lot of concurrency will have a lot of data race potential. This means that Swift will flag a lot of potential issues (when you're using the Swift 6 language mode) even when you never really intended to introduce a ton of concurrency. Swift 6.2 is much better at recognizing code that's safe even though it's concurrent but as a general rule you want to manage the concurrency in your code carefully and avoid introducing concurrency by default.

Let's look at a code sample where we have a view that leverages a task view modifier to retrieve data:

struct MoviesList: View {
  @State var movieRepository = MovieRepository()
  @State var movies = [Movie]()

  var body: some View {
    Group {
      if movies.isEmpty == false {
        List(movies) { movie in
          Text(movie.id.uuidString)
        }
      } else {
        ProgressView()
      }
    }.task {
      do {
        // Sending 'self.movieRepository' risks causing data races
        movies = try await movieRepository.loadMovies()
      } catch {
        movies = []
      }
    }
  }
}

This code has an issue: sending self.movieRepository risks causing data races.

The reason we're seeing this error is due to us calling a nonisolated and async method on an instance of MovieRepository that is isolated to the main actor. That's a problem because inside of loadMovies we have access to self from a background thread because that's where loadMovies would run. We also have access to our instance from inside of our view at the exact same time so we are indeed creating a possible data race.

There are two ways to fix this:

  1. Make sure that loadMovies runs on the same actor as its callsite (this is what nonisolated(nonsending) would achieve)
  2. Make sure that loadMovies runs on the Main Actor

Option 2 makes a lot of sense because, as far as this example is concerned, we always call loadMovies from the Main Actor anyway.

Depending on the contents of loadMovies and the functions that it calls, we might simply be moving our compiler error from the view over to our repository because the newly @MainActor isolated loadMovies is calling a non-Main Actor isolated function internally on an object that isn't Sendable nor isolated to the Main Actor.

Eventually, we might end up with something that looks as follows:

class MovieRepository {
  @MainActor
  func loadMovies() async throws -> [Movie] {
    let req = makeRequest()
    let movies: [Movie] = try await perform(req)

    return movies
  }

  func makeRequest() -> URLRequest {
    let url = URL(string: "https://example.com")!
    return URLRequest(url: url)
  }

  @MainActor
  func perform<T: Decodable>(_ request: URLRequest) async throws -> T {
    let (data, _) = try await URLSession.shared.data(for: request)
    // Sending 'self' risks causing data races
    return try await decode(data)
  }

  nonisolated func decode<T: Decodable>(_ data: Data) async throws -> T {
    return try JSONDecoder().decode(T.self, from: data)
  }
}

We've @MainActor isolated all async functions except for decode. At this point we can't call decode because we can't safely send self into the nonisolated async function decode.

In this specific case, the problem could be fixed by marking MovieRepository as Sendable. But let's assume that we have reasons that prevent us from doing so. Maybe the real object holds on to mutable state.

We could fix our problem by actually making all of MovieRepository isolated to the Main Actor. That way, we can safely pass self around even if it has mutable state. And we can still keep our decode function as nonisolated and async to prevent it from running on the Main Actor.

The problem with the above...

Finding the solution to the issues I describe above is pretty tedious, and it forces us to explicitly opt-out of concurrency for specific methods and eventually an entire class. This feels wrong. It feels like we're having to decrease the quality of our code just to make the compiler happy.

In reality, the default in Swift 6.1 and earlier was to introduce concurrency by default. Run as much as possible in parallel and things will be great.

This is almost never true. Concurrency is not the best default to have.

In code that you wrote pre-Swift Concurrency, most of your functions would just run wherever they were called from. In practice, this meant that a lot of your code would run on the main thread without you worrying about it. It simply was how things worked by default and if you needed concurrency you'd introduce it explicitly.

The new default in Xcode 26 returns this behavior both by running your code on the main actor by default and by having nonisolated async functions inherit the caller's actor by default.

This means that the example we had above becomes much simpler with the new defaults...

Understanding how default isolation simplifies our code

If we turn set our default isolation to the Main Actor along with Approachable Concurrency, we can rewrite the code from earlier as follows:

class MovieRepository {
  func loadMovies() async throws -> [Movie] {
    let req = makeRequest()
    let movies: [Movie] = try await perform(req)

    return movies
  }

  func makeRequest() -> URLRequest {
    let url = URL(string: "https://example.com")!
    return URLRequest(url: url)
  }

  func perform<T: Decodable>(_ request: URLRequest) async throws -> T {
    let (data, _) = try await URLSession.shared.data(for: request)
    return try await decode(data)
  }

  @concurrent func decode<T: Decodable>(_ data: Data) async throws -> T {
    return try JSONDecoder().decode(T.self, from: data)
  }
}

Our code is much simpler and safer, and we've inverted one key part of the code. Instead of introducing concurrency by default, I had to explicitly mark my decode function as @concurrent. By doing this, I ensure that decode is not main actor isolated and I ensure that it always runs on a background thread. Meanwhile, both my async and my plain functions in MoviesRepository run on the Main Actor. This is perfectly fine because once I hit an await like I do in perform, the async function I'm in suspends so the Main Actor can do other work until the function I'm awaiting returns.

Performance impact of Main Actor by default

While running code concurrently can increase performance, concurrency doesn't always increase performance. Additionally, while blocking the main thread is bad we shouldn't be afraid to run code on the main thread.

Whenever a program runs code on one thread, then hops to another, and then back again, there's a performance cost to be paid. It's a small cost usually, but it's a cost either way.

It's often cheaper for a quick operation that started on the Main Actor to stay there than it is for that operation to be performed on a background thread and handing the result back to the Main Actor. Being on the Main Actor by default means that it's much more explicit when you're leaving the Main Actor which makes it easier for you to determine whether you're ready to pay the cost for thread hopping or not. I can't decide for you what the cutoff is for it to be worth paying a cost, I can only tell you that there is a cost. And for most apps the cost is probably small enough for it to never matter. By defaulting to the Main Actor you can avoid paying the cost accidentally and I think that's a good thing.

So, should you set your default isolation to the Main Actor?

For your app targets it makes a ton of sense to run on the Main Actor by default. It allows you to write simpler code, and to introduce concurrency only when you need it. You can still mark objects as nonisolated when you find that they need to be used from multiple actors without awaiting each interaction with those objects (models are a good example of objects that you'll probably mark nonisolated). You can use @concurrent to ensure certain async functions don't run on the Main Actor, and you can use nonisolated on functions that should inherit the caller's actor. Finding the correct keyword can sometimes be a bit of a trial and error but I typically use either @concurrent or nothing (@MainActor by default). Needing nonisolated is more rare in my experience.

For your SPM Packages the decision is less obvious. If you have a Networking package, you probably don't want it to use the main actor by default. Instead, you'll want to make everything in the Package Sendable for example. Or maybe you want to design your Networking object as an actor. Its' entirely up to you.

If you're building UI Packages, you probably do want to isolate those to the Main Actor by default since pretty much everything that you do in a UI Package should be used from the Main Actor anyway.

The answer isn't a simple "yes, you should", but I do think that when you're in doubt isolating to the Main Actor is a good default choice. When you find that some of your code needs to run on a background thread you can use @concurrent.

Practice makes perfect, and I hope that by understanding the "Main Actor by default" rationale you can make an educated decision on whether you need the flag for a specific app or Package.

Expand your learning with my books

Practical Combine header image

Learn everything you need to know about Combine and how you can use it in your projects with Practical Combine. It contains:

  • Thirteen chapters worth of content.
  • Playgrounds and sample projects that use the code shown in the chapters.
  • Free updates for future iOS versions.

The book is available as a digital download for just $39.99!

Learn more