DispatchQueue.main vs @MainActor

"Just use DispatchQueue.main.async" used to be the answer. Now there's @MainActor. When do you use which?
Follow along with the code: iOS-Practice on GitHub
DispatchQueue.main
The classic approach. Explicitly dispatch work to the main thread:
func loadData() {
URLSession.shared.dataTask(with: url) { data, _, _ in
// We're on a background thread here
let parsed = parse(data)
DispatchQueue.main.async {
// Now we're on main
self.viewModel.items = parsed
}
}.resume()
}
Pros:
- Works everywhere
- Explicit about when you're switching threads
- No Swift concurrency required
Cons:
- Easy to forget
- Verbose
- No compile-time checking
@MainActor
The Swift concurrency approach. Annotate types or functions to always run on the main actor:
@MainActor
class ViewModel: ObservableObject {
@Published var items: [Item] = []
func loadData() async {
let data = await fetchData()
items = parse(data) // Compiler guarantees this is on main
}
}
Pros:
- Compiler enforced
- Cleaner code
- Works with async/await
Cons:
- Requires Swift concurrency adoption
- Can require
nonisolatedannotations for non-main work - Async context required for cross-actor calls
When to Use Which
Use @MainActor when:
- Writing new SwiftUI code
- Your class is an
ObservableObject - You're already using async/await
- You want compile-time safety
Use DispatchQueue.main when:
- Working with completion handlers
- Integrating with callback-based APIs
- Maintaining legacy code
- You need synchronous dispatch (
DispatchQueue.main.sync)
Mixing Them
You'll often mix both in transitional codebases:
@MainActor
class ViewModel: ObservableObject {
@Published var status: String = ""
func startLegacyOperation() {
// Old callback-based API
LegacyService.fetch { [weak self] result in
// We're on unknown thread, need to get to main actor
DispatchQueue.main.async {
self?.status = result
}
}
}
}
Or wrap the callback API:
@MainActor
class ViewModel: ObservableObject {
@Published var status: String = ""
func startOperation() async {
let result = await withCheckedContinuation { continuation in
LegacyService.fetch { result in
continuation.resume(returning: result)
}
}
status = result // Safe - we're @MainActor
}
}
Common Gotcha
@MainActor doesn't mean "always on main thread." It means "isolated to main actor." If you call a @MainActor function from a non-isolated context, it becomes an async call:
@MainActor func updateUI() { ... }
func someBackgroundWork() {
// This won't compile without 'await'
await updateUI()
}
What's a non-isolated context? Any code that isn't explicitly bound to an actor. This includes:
- Regular functions and methods without
@MainActor - Closures passed to completion handlers
- Code inside
Task { }blocks (unless the enclosing type is actor-isolated) nonisolatedmethods on an actor-isolated class
When you're in a non-isolated context, the compiler doesn't know what thread you're on. It could be main, could be background—there's no guarantee. So calling into @MainActor code requires await to signal the potential thread hop.
The compiler forces you to acknowledge this. It's not being pedantic—it's preventing race conditions at compile time.
The Trend
New code should prefer @MainActor. It's safer, cleaner, and where Apple is heading. But DispatchQueue.main isn't going away—you'll need it for interop with older APIs.