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.
- Never block the main thread. If it waits two seconds for the network, the app freezes for two seconds.
- 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)
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
completionon one path, like theguardabove, 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:
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.
@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.
awaitdoes 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
@concurrentand it runs off the main thread.
@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.
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:
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:
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:
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:
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.
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.