# Swift Concurrency: From @escaping to MainActor

By Neda Khalaj

---

My Token Meter app has `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` in its build settings. That means all app code runs on the main actor unless I say otherwise. So I asked myself a simple question: if the main thread is the default, why does this topic still feel so hard?

I used to write `@escaping` completion handlers. Now everything says async/await. Tutorials mix `DispatchQueue.main.async`, `@MainActor`, `Sendable` and `@concurrent`, sometimes in the same file. It took me a while to see that these are not one system. They are five generations of answers to the same problem, and the confusing part is that all five are still around.

## The problem every generation solves

An iPhone app has one main thread. It handles the screen: drawing, touches, animations. That gives two rules.

1. **Never block the main thread.** If it waits two seconds for the network, the app freezes for two seconds.
2. **Only touch the UI from the main thread.** UIKit and SwiftUI are not built for other threads. Changing the screen from a background thread can cause crashes, glitches, and purple warnings from Xcode's Main Thread Checker.

Rule 1 says: do slow work somewhere else. Rule 2 says: but come back to the main thread to show the result. Every generation below is a different way of doing both.

## Generation 1: completion handlers and GCD (before 2021)

```swift
func fetchUsage(completion: @escaping (Result<Usage, Error>) -> Void) {
    URLSession.shared.dataTask(with: url) { data, _, error in
        // runs later, on a background thread
        if let error {
            DispatchQueue.main.async { completion(.failure(error)) }
            return
        }
        guard let data,
              let usage = try? JSONDecoder().decode(Usage.self, from: data) else {
            return  // bug: completion is never called, so the spinner never stops
        }
        DispatchQueue.main.async {  // rule 2: go back to main yourself
            completion(.success(usage))
        }
    }.resume()
}
```

`@escaping` means the closure is called after the function has already returned. `fetchUsage` starts the request and returns right away. The closure "escapes" and runs later, when the network answers.

GCD (Grand Central Dispatch) is the `DispatchQueue` part. `dataTask` calls its closure on a background thread, so I have to jump back to the main queue myself before anything touches the UI.

It works, but it is easy to get wrong:

- **Forget `DispatchQueue.main.async`**, and the UI gets updated from a background thread. The crash comes later, at random.
- **Forget to call `completion`** on one path, like the `guard` above, and the spinner turns forever. Nothing warns you.
- **Chain three requests**, and you get closures inside closures inside closures. People call this callback hell.

## Generation 2: async/await (Swift 5.5, 2021)

The same function, written from top to bottom:

```swift
func fetchUsage() async throws -> Usage {
    let (data, _) = try await URLSession.shared.data(from: url)
    return try JSONDecoder().decode(Usage.self, from: data)
}
```

`await` means "pause here, without blocking". The function steps aside while the network works, and the thread is free to do other things, like drawing the screen. When the answer arrives, the function continues from that line.

Errors use a normal `throw`. And I cannot forget to return something, because the compiler will not build a function that ends without returning or throwing. The spinner-forever bug from generation 1 cannot happen here.

This fixed rule 1. But it left one question open: after the `await`, which thread am I on?

## Generation 3: actors and @MainActor (also Swift 5.5)

Actors arrived in the same Swift version as async/await, but they solve a different problem, so it helps to look at them on their own.

An actor protects its data by letting only one piece of code in at a time. That prevents a **data race**: two threads touching the same data at the same moment, with at least one of them writing.

`@MainActor` is the special actor that is the main thread. Marking a type with it says: this code always runs on main. Now the compiler checks rule 2 for me, instead of me remembering `DispatchQueue.main.async`.

```swift
@MainActor
final class DashboardViewModel {
    var text = ""

    func refresh() async {
        do {
            let usage = try await fetchUsage()  // the main thread is free while this waits
            text = "\(usage.used)% used"        // back on the main actor, safe for the UI
        } catch {
            text = "Could not load"
        }
    }
}
```

## Generation 4: Swift 6 strict checking (2024)

Swift 6 turned many of these checks from warnings into errors. If the compiler cannot prove that your code is free of data races, it does not build.

That is very safe, but hard. Developers who switched got pages of errors about `Sendable`, isolation and actors. Adding `@MainActor` in one place often caused new errors somewhere else. This is where Swift concurrency got its reputation for being difficult.

## Generation 5: MainActor by default (Swift 6.2, Xcode 26, 2025)

Apple's answer to that pain was to flip the default. With `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`, everything in the target runs on the main actor unless you say otherwise. New projects in Xcode 26 and later have it turned on, and Token Meter is one of them.

- **Rule 2 is handled for you.** Code is already on main, so updating the UI is safe.
- **Rule 1 still holds for waiting.** `await` does not block, so network calls do not freeze the app.
- **Heavy work opts out.** If something needs a lot of CPU time, like parsing a big file, you mark it `@concurrent` and it runs off the main thread.

```swift
@concurrent
func parseHistory(_ data: Data) async throws -> [Usage] {
    try JSONDecoder().decode([Usage].self, from: data)
}
```

New projects also turn on "approachable concurrency" (`SWIFT_APPROACHABLE_CONCURRENCY = YES`). One effect: an async function that does not belong to an actor now runs on the caller's actor, instead of jumping to a background thread by itself. That is why `@concurrent` exists. Leaving the main thread is now something you ask for, not something that happens by surprise.

I wrote more about the setting itself in [Swift 6.2 MainActor by Default: What Changes](https://nedakhalaj.com/swift-6-2-default-actor-isolation-mainactor/).

## So why does it still feel hard?

This was my real question. The default makes most code simple, but it does not make the topic simple. I found five reasons.

### 1. Everything around you is older

The default is about a year old. Most tutorials, Stack Overflow answers and interview questions were written for generations 1 to 4. When I read three articles, I often read three different generations, and none of them say which one they are.

### 2. Old APIs still use completion handlers

Generation 1 is still in my own new project. WidgetKit's `TimelineProvider`, which my home screen widget uses, gives me an `@escaping` completion handler:

```swift
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
    let entry = SimpleEntry(date: Date(), accounts: AccountFileStore.load())
    // ask iOS to re-read the file in about 30 minutes
    let nextUpdate = Calendar.current.date(byAdding: .minute, value: 30, to: Date())!
    let timeline = Timeline(entries: [entry], policy: .after(nextUpdate))
    completion(timeline)
}
```

My Claude login screen also takes an `@escaping` closure, because the web view calls it later, when it captures the session. So `@escaping` is not dead. async/await replaced it for "do this and tell me when you are done". It is still the right tool when a closure is stored and called later.

### 3. The default is per target

The setting lives in each target's build settings. In Token Meter it is on for the app, but not for the widget or the tests:

| Target | Default actor isolation | Approachable concurrency |
|---|---|---|
| TokenMeter (app) | MainActor | Yes |
| TokenMeterWidget | not set (nonisolated) | Yes |
| TokenMeterTests | not set (nonisolated) | Yes |

So one project has two sets of rules. Code that is on main automatically in the app is not on main automatically in the widget. All three targets also use the Swift 5 language mode, so most concurrency problems show up as warnings, not the errors you see in Swift 6 tutorials.

### 4. The default has side effects

MainActor by default does not only move code to the main thread. It also makes your own types belong to the main actor, and that includes their protocol conformances, like `Decodable`.

If you mix generation 1 into a generation 5 project, you can hit this. Decoding a plain `struct Usage: Decodable` inside the old `dataTask` callback, which runs on a background thread, gives this warning:

```text
main actor-isolated conformance of 'Usage' to 'Decodable' cannot be used in nonisolated context; this is an error in the Swift 6 language mode
```

The fix is to say that the model type does not belong to any actor:

```swift
nonisolated struct Usage: Decodable {
    let used: Int
}
```

Token Meter does not hit this, because all its network calls use async/await. But it showed me that the default changes more than where code runs.

### 5. You still have to decide when to leave main

With the default, I rarely have to think about threads. Rarely is not never. If a function does heavy work, it now runs on the main thread unless I mark it `@concurrent`. The compiler does not warn me. The app just stutters. So I still need the two rules to know when to step off the main thread.

## Bridging generation 1 and generation 2

Sometimes an API only has a callback, and I want the rest of my app to use async/await. In Token Meter, Google sign-in uses `ASWebAuthenticationSession`, which reports back with a completion handler. My `GoogleAuthService` wraps it so the rest of the app can just `await` it:

```swift
private func present(url: URL) async throws -> URL {
    try await withCheckedThrowingContinuation { continuation in
        let session = ASWebAuthenticationSession(
            url: url,
            callbackURLScheme: redirectScheme
        ) { callbackURL, error in
            if let error {
                continuation.resume(throwing: error)
            } else if let callbackURL {
                continuation.resume(returning: callbackURL)
            } else {
                continuation.resume(throwing: GoogleAuthError.noCallback)
            }
        }
        session.presentationContextProvider = self
        session.prefersEphemeralWebBrowserSession = true
        session.start()
        self.session = session
    }
}
```

A continuation is a promise: pause the `await` here, and resume it when the old callback fires. It must be resumed **exactly once**. Zero times, and the `await` waits forever. Twice, and the app crashes. That is why every branch of the callback calls `resume`, including the last `else`, which throws `noCallback`.

I wrote about the rest of that sign-in flow in [Google Drive OAuth and PKCE in My iOS App](https://nedakhalaj.com/google-drive-oauth-pkce-swiftui-ios/).

## Interview Cheat Sheet

| Question | Short answer |
|---|---|
| What does `@escaping` mean? | The closure is called after the function returns, so it lives longer than the call. |
| Is `@escaping` outdated? | No. async/await replaced completion handlers for one-time results, but stored callbacks still need `@escaping`. |
| async/await or completion handlers? | Same job, written top to bottom. The compiler makes sure you return or throw, and there is no nesting. |
| Does `await` block the thread? | No. It suspends the function, and the thread is free to do other work, like drawing. |
| What is an actor? | A type that runs one piece of its code at a time, which prevents data races. |
| What is `@MainActor`? | The actor that is the main thread. UI code must run on it. |
| What is a data race? | Two threads touching the same data at the same time, with at least one of them writing. |
| What is default actor isolation? | In new Xcode 26 projects, app code runs on the main actor by default. It is set per target. |
| When do you use `@concurrent`? | For heavy work. With MainActor by default, it would otherwise run on the main thread and make the UI stutter. |
| How do you use a callback API with async/await? | Wrap it in `withCheckedThrowingContinuation` and resume the continuation exactly once. |

## What I would tell myself at the start

The five generations are not five things to learn at once. They are one problem solved five times: never block the main thread, and only touch the UI from it. Now, when I see `DispatchQueue.main.async` in an old tutorial, I read it as "the generation 1 way of saying rule 2", and translate it to what my project does.

## Frequently Asked Questions

### Is @escaping outdated in Swift 6?

No. async/await replaced completion handlers for one-time results, like "fetch this and tell me when you're done". A closure that is stored and called later, like a WidgetKit `TimelineProvider` completion or a web view callback, still needs `@escaping`.

### Does await block the main thread?

No. `await` suspends the function, not the thread. While the network call waits, the main thread is free to draw the screen and handle touches, and the function continues from the same line when the answer arrives.

### What does MainActor by default change in Xcode 26?

With `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`, all code in that target runs on the main actor unless you opt out. It's on by default in new Xcode 26 projects, but it's set per target, so a widget or test target can follow different rules.

### When should I use @concurrent?

Use it for heavy CPU work, like parsing a big file. With MainActor by default and approachable concurrency, an async function runs on the caller's actor, so without `@concurrent` that work stays on the main thread and the UI stutters.

### How do I use a callback API with async/await?

Wrap it in `withCheckedThrowingContinuation` and resume the continuation exactly once on every path. If you never resume it, the `await` waits forever. If you resume it twice, the app crashes.
