Swift Concurrency: Actors, @MainActor, and the End of Data Races

Swift's concurrency model, introduced in Swift 5.5, replaced the callback-heavy world of Grand Central Dispatch with structured, compiler-enforced async/await. At the center of it all sits @MainActor — the mechanism that guarantees UI work happens on the main thread without you manually dispatching to it.

The Problem: Why Concurrency on iOS Is Hard

Every iOS app has a single main thread that drives UIKit/SwiftUI rendering. Touch events, layout passes, view updates — all main thread. Block it for more than a few frames and the system watchdog kills your app.

Pre-Swift concurrency, you had two options:

  • GCD (Grand Central Dispatch): DispatchQueue.global().async to offload, DispatchQueue.main.async to come back. Works, but the compiler can't verify you got it right. Data races are silent until they crash in production.
  • OperationQueue: Higher-level abstraction over GCD. Better for dependency management, but same fundamental thread-safety problems.
// The old world: GCD callback hell
func fetchProfile(userId: String, completion: @escaping (Result<Profile, Error>) -> Void) {
    URLSession.shared.dataTask(with: url) { data, response, error in
        if let error = error {
            DispatchQueue.main.async { completion(.failure(error)) }
            return
        }
        let profile = try? JSONDecoder().decode(Profile.self, from: data!)
        DispatchQueue.main.async {
            completion(.success(profile!)) // pray this is right
        }
    }.resume()
}

The compiler has zero visibility into thread safety here. Nothing stops you from updating a @Published property from a background thread, corrupting SwiftUI's state and producing heisenbugs that only appear under load.

async/await: The Foundation

Swift's async/await is syntactic sugar over continuations, but it fundamentally changes how you reason about concurrent code:

// The new world: structured and linear
func fetchProfile(userId: String) async throws -> Profile {
    let (data, _) = try await URLSession.shared.data(from: url)
    return try JSONDecoder().decode(Profile.self, from: data)
}

Key properties of async/await in Swift:

  • Suspension points are explicit: Every await is a point where execution can suspend and resume on a potentially different thread
  • No thread blocking: When a function suspends at await, the thread is freed to do other work. This is cooperative, not preemptive
  • Compiler-enforced: You can't call an async function without await. The type system tracks asynchrony

Actors: Data Isolation by Design

Actors are reference types (like classes) that serialize access to their mutable state. Only one task can execute on an actor at a time:

actor ImageCache {
    private var cache: [URL: UIImage] = [:]

    func image(for url: URL) -> UIImage? {
        return cache[url]
    }

    func store(_ image: UIImage, for url: URL) {
        cache[url] = image
    }
}

// Accessing actor state from outside requires await
let cache = ImageCache()
let image = await cache.image(for: someURL)  // suspends until actor is available

The compiler enforces this. You cannot access cache.cache directly from outside the actor — it's a compile-time error, not a runtime crash. This is the key difference from just using locks: the protection is structural.

Under the hood, each actor has a serial executor that processes one message at a time (conceptually similar to a serial DispatchQueue, but integrated into the cooperative thread pool). When you await an actor method call, you're enqueuing work on that actor's executor and suspending until it runs.

@MainActor: The Special One

@MainActor is a global actor — a singleton actor whose executor is the main thread. Annotating anything with @MainActor guarantees it runs on the main thread:

@MainActor
class ProfileViewModel: ObservableObject {
    @Published var profile: Profile?
    @Published var isLoading = false
    @Published var error: Error?

    func loadProfile(userId: String) async {
        isLoading = true              // safe: we're on @MainActor
        defer { isLoading = false }

        do {
            let (data, _) = try await URLSession.shared.data(from: url)
            // ^ suspends here, may resume on any thread, but @MainActor
            //   ensures we hop back to main before continuing
            profile = try JSONDecoder().decode(Profile.self, from: data)
        } catch {
            self.error = error
        }
    }
}

What happens at that await:

  1. Execution suspends. The main thread is freed
  2. The network request runs on the cooperative thread pool
  3. When data returns, the continuation is scheduled back on the main actor's executor (the main thread)
  4. The @Published property updates happen on the main thread. SwiftUI is happy

Without @MainActor, that property assignment could happen on any thread, and SwiftUI would emit the purple runtime warning: "Publishing changes from background threads is not allowed".

Global Actor Isolation in Practice

You can apply @MainActor at different granularities:

// Entire class isolated to main actor
@MainActor
class SettingsManager { ... }

// Single function isolated to main actor
class DataService {
    @MainActor
    func updateUI(with result: Result) {
        // guaranteed main thread
    }

    func fetchData() async throws -> Data {
        // runs on cooperative pool, NOT main thread
        return try await URLSession.shared.data(from: url).0
    }
}

// Closures can be isolated too
func doWork(completion: @MainActor @Sendable () -> Void) {
    Task {
        // ... background work ...
        await completion()  // hops to main
    }
}

nonisolated: Opting Out

Sometimes a method on a @MainActor class doesn't need main thread access. Use nonisolated to let it run anywhere:

@MainActor
class DocumentParser: ObservableObject {
    @Published var parsedContent: String = ""

    // This is pure computation, no UI state involved
    nonisolated func parse(raw: Data) throws -> String {
        // Runs on whatever thread the caller is on
        // Cannot access @Published properties here (compile error)
        return try JSONDecoder().decode(Document.self, from: raw).body
    }

    func loadAndParse(url: URL) async throws {
        let (data, _) = try await URLSession.shared.data(from: url)
        let content = try parse(raw: data)  // no await needed, nonisolated
        parsedContent = content              // back on main actor
    }
}

The nonisolated keyword is critical for performance. If parse() were isolated to @MainActor, a heavy parse operation would block the main thread. By marking it nonisolated, the compiler knows it's safe to run anywhere and doesn't require an actor hop.

Sendable: The Thread-Safety Contract

Sendable is the protocol that marks a type as safe to pass across concurrency boundaries (between actors, between tasks). Value types (structs, enums) with Sendable fields are implicitly Sendable. Classes are not, unless they're immutable or an actor.

// Implicitly Sendable (struct with Sendable fields)
struct Coordinate {
    let lat: Double
    let lng: Double
}

// Must be explicitly marked and enforced
final class APIConfig: Sendable {
    let baseURL: URL       // let = immutable, safe
    let apiKey: String     // let = immutable, safe
    // var timeout: Int    // ERROR: stored property is mutable
}

// Actors are always Sendable
actor SessionManager { ... }  // safe to pass around by definition

In strict concurrency mode (-strict-concurrency=complete, the default in Swift 6), the compiler rejects any attempt to send a non-Sendable type across an isolation boundary. This catches data races at compile time.

Structured Concurrency: TaskGroup and async let

Swift provides two patterns for parallel work within a structured scope:

// async let: fixed number of parallel tasks
func loadDashboard() async throws -> Dashboard {
    async let profile = fetchProfile()
    async let posts = fetchRecentPosts()
    async let notifications = fetchNotifications()

    // All three run concurrently, we await all results
    return try await Dashboard(
        profile: profile,
        posts: posts,
        notifications: notifications
    )
}

// TaskGroup: dynamic number of parallel tasks
func fetchAllImages(urls: [URL]) async -> [URL: UIImage] {
    await withTaskGroup(of: (URL, UIImage?).self) { group in
        for url in urls {
            group.addTask {
                let image = try? await downloadImage(from: url)
                return (url, image)
            }
        }

        var results: [URL: UIImage] = [:]
        for await (url, image) in group {
            if let image { results[url] = image }
        }
        return results
    }
}

Both patterns are structured — child tasks cannot outlive their parent scope. If the parent is cancelled, all children are cancelled. This prevents leaked tasks and makes resource management predictable.

The Actor Reentrancy Trap

One subtle gotcha: actors are reentrant. When an actor method hits an await, other callers can execute on that actor before the first call resumes:

actor BankAccount {
    var balance: Int = 1000

    func withdraw(_ amount: Int) async -> Bool {
        guard balance >= amount else { return false }

        // DANGER: another caller can run between these lines
        let newBalance = await processTransaction(amount)

        // balance may have changed while we were suspended!
        balance = newBalance
        return true
    }
}

The fix: never assume state is unchanged after an await inside an actor. Re-check conditions, or restructure so the critical section has no suspension points.

Swift 6 Strict Concurrency

Swift 6 makes strict concurrency checking the default. Code that compiled with warnings in Swift 5.10 becomes hard errors:

  • Sending non-Sendable types across isolation boundaries → error
  • Accessing actor-isolated state from outside without await → error
  • Mutable capture in @Sendable closures → error
  • Protocol conformances must respect isolation

Migration strategy: enable -strict-concurrency=complete in your Swift 5 project first. Fix warnings incrementally. Use @preconcurrency to silence warnings from third-party code that hasn't adopted Sendable yet. Then flip to Swift 6 when you're clean.

Practical Architecture Pattern

Here's a common pattern for a SwiftUI app with clean actor isolation:

// Domain layer: plain actor for thread-safe caching
actor ArticleRepository {
    private let api: APIClient
    private var cache: [String: Article] = [:]

    init(api: APIClient) { self.api = api }

    func article(id: String) async throws -> Article {
        if let cached = cache[id] { return cached }
        let article = try await api.fetchArticle(id: id)
        cache[id] = article
        return article
    }
}

// Presentation layer: @MainActor for UI state
@MainActor
class ArticleViewModel: ObservableObject {
    @Published private(set) var article: Article?
    @Published private(set) var state: LoadState = .idle

    private let repo: ArticleRepository

    init(repo: ArticleRepository) { self.repo = repo }

    func load(id: String) async {
        state = .loading
        do {
            article = try await repo.article(id: id)  // hops to actor, back to main
            state = .loaded
        } catch {
            state = .error(error)
        }
    }
}

// View layer: inherits @MainActor from SwiftUI
struct ArticleView: View {
    @StateObject private var viewModel: ArticleViewModel

    var body: some View {
        Group {
            switch viewModel.state {
            case .idle, .loading:
                ProgressView()
            case .loaded:
                Text(viewModel.article?.title ?? "")
            case .error(let error):
                Text(error.localizedDescription)
            }
        }
        .task { await viewModel.load(id: "123") }
    }
}

Key Takeaways

  • async/await makes suspension points explicit — the compiler tracks what's async and what isn't
  • Actors serialize access to mutable state. Data race protection is structural, not just convention
  • @MainActor is a global actor on the main thread — use it for all UI-facing state. No more DispatchQueue.main.async
  • Sendable marks types safe to cross concurrency boundaries. Swift 6 enforces this strictly
  • nonisolated lets you opt methods out of actor isolation for pure computation
  • Actors are reentrant — state can change across await points. Never assume continuity
  • Structured concurrency (async let, TaskGroup) ensures child tasks can't outlive their parent scope