Published on

Swift Actors Explained: From DispatchQueue to Compiler-Enforced Safety

Authors

You already know Swift. You already survived DispatchQueue. This is not a "what is a thread" tutorial. This is what changed, why it matters, and how to write it correctly today.


Before: The DispatchQueue Approach

You had shared mutable state. You needed it to be thread-safe. So you wrapped everything in a DispatchQueue.

// The classic thread-safe counter you wrote in 2019
class RequestCounter {
    private var count = 0
    private let queue = DispatchQueue(label: "com.app.counter")

    func increment() {
        queue.async { self.count += 1 }
    }

    func value(completion: @escaping (Int) -> Void) {
        queue.async { completion(self.count) }
    }
}

Looks fine. Ships fine. Works fine — until it doesn't.

The compiler has no idea what you're doing. It doesn't know count is only safe inside queue. It will happily let you read count from any thread, at any time, with zero warning:

let counter = RequestCounter()
counter.increment()
print(counter.count) // 🔴 Direct access — data race, compiler says nothing

That print is a data race. The compiler waves you through.


The Hidden Problem

Three things were always broken with the DispatchQueue approach:

1. You could skip the queue and the compiler wouldn't care.

func buggyReset() {
    count = 0 // 🔴 called from any thread — instant race condition
}

2. You could deadlock yourself.

func value() -> Int {
    return queue.sync { count } // fine
}

func doubleTrouble() {
    queue.sync {
        let v = value() // 🔴 queue.sync inside queue.sync = deadlock
    }
}

3. It didn't scale. Four shared objects. Four queues. Four sets of rules only you knew. One new teammate. One bug shipped to production at 2am.


After: Actors

An actor is a reference type — like a class — but the compiler enforces that its mutable state is only ever accessed by one caller at a time. No locks. No queues. No "trust me, I know what I'm doing."

actor RequestCounter {
    private var count = 0

    func increment() {
        count += 1 // ✅ safe — only one task runs inside this actor at a time
    }

    func value() -> Int {
        count // ✅ safe
    }
}

No queue. No async. The actor handles the serialization.

Now when you try the old mistake:

let counter = RequestCounter()
print(counter.count) // 🔴 compiler error: "Actor-isolated property 'count' can not be referenced from outside the actor"

The compiler stops you. This is the entire point.


Crossing the Boundary: Why You await an Actor

When you call a method on an actor from outside it, you have to await. Not because it's slow — because the actor might be in the middle of another operation and you have to suspend until it's free.

let counter = RequestCounter()

Task {
    await counter.increment()        // ✅ suspends until actor is free
    let v = await counter.value()    // ✅ same
    print("Count: \(v)")
}

Inside the actor, no await needed — you're already executing within its isolation:

actor RequestCounter {
    private var count = 0
    private var log: [String] = []

    func incrementAndLog() {
        count += 1            // ✅ no await — already inside
        log.append("hit")     // ✅ same
    }
}

The mental model: inside the actor, you have exclusive access to its state. Outside, you suspend and wait your turn.


@MainActor: The Actor You Already Use Every Day

@MainActor is a built-in actor that runs on the main thread. It replaces DispatchQueue.main.async for UI updates.

Before:

URLSession.shared.dataTask(with: url) { data, _, _ in
    DispatchQueue.main.async {
        self.label.text = "Done"
    }
}.resume()

After:

@MainActor
func updateUI(with text: String) {
    label.text = text  // ✅ guaranteed to run on main thread
}

let result = try await fetchData()
await updateUI(with: result)

Mark an entire view model @MainActor — common with ObservableObject:

@MainActor
class ProfileViewModel: ObservableObject {
    @Published var name: String = ""
    @Published var isLoading = false

    func load() async {
        isLoading = true
        name = try? await fetchProfile() ?? ""
        isLoading = false
        // ✅ all of this runs on main — no DispatchQueue.main.async anywhere
    }
}

Gotcha: @MainActor doesn't prevent you from calling async functions that hop to a background thread. It just ensures methods on this class start and return on main. Use await to re-enter main context when needed.


nonisolated: The Opt-Out

Methods on an actor that don't touch mutable state don't need isolation. Mark them nonisolated so callers don't have to await:

actor UserSession {
    private var token: String = ""

    func updateToken(_ t: String) { token = t }

    nonisolated func appVersion() -> String {
        Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0"
    }
}

let session = UserSession()
print(session.appVersion())        // ✅ no await
await session.updateToken("abc")   // requires await

Use nonisolated on anything that only reads constants or has no dependency on actor state.


Actor vs Class: When to Pick Which

SituationUse
Shared mutable state accessed from multiple tasksactor
UI state / view model@MainActor class
Pure data model, no concurrencystruct
Object that's only ever on main thread@MainActor class
You need inheritanceclass — actors can't be subclassed
You need Equatable/Hashable by identityclass or actor

The rule: if two Tasks could ever touch the same object at the same time, that object should be an actor.


A Real Pattern: Thread-Safe Cache

Here's what a practical actor looks like in production — replacing the old NSCache + DispatchQueue combo:

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

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

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

    func clear() {
        cache.removeAll()
    }
}

let cache = ImageCache()

Task {
    if let cached = await cache.image(for: url) {
        await MainActor.run { imageView.image = cached }
    } else {
        let downloaded = try await downloadImage(url)
        await cache.store(downloaded, for: url)
        await MainActor.run { imageView.image = downloaded }
    }
}

No locks. No queues. The compiler enforces the contract.


Summary

OldNew
DispatchQueue + manual syncactor keyword
"trust me it's thread-safe"compiler error if you get it wrong
DispatchQueue.main.async { }@MainActor
Crashes at runtimeCaught at compile time
Deadlocks from nested syncStructured suspension with await

Actors don't replace everything. Structs are still the default for pure data. @MainActor view models are the right move for SwiftUI. But anywhere you used to reach for a DispatchQueue to protect shared mutable state — that's an actor now.

The compiler knows. Use it.


Next Steps