Concurrency Deep Dive

DispatchQueue.main vs @MainActor

January 25, 2026
4 min read
Featured image for blog post: 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 nonisolated annotations 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)
  • nonisolated methods 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.

Originally published on pixelper.com

© 2026 Christopher Moore / Dead Pixel Studio

Let's work together

Professional discovery, design, and complete technical coverage for your ideas

Get in touch