- Published on
Swift Actors Explained: From DispatchQueue to Compiler-Enforced Safety
- Authors

- Name
- Brahim El mssilha
- @siempay
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:
@MainActordoesn't prevent you from callingasyncfunctions that hop to a background thread. It just ensures methods on this class start and return on main. Useawaitto 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
| Situation | Use |
|---|---|
| Shared mutable state accessed from multiple tasks | actor |
| UI state / view model | @MainActor class |
| Pure data model, no concurrency | struct |
| Object that's only ever on main thread | @MainActor class |
| You need inheritance | class — actors can't be subclassed |
You need Equatable/Hashable by identity | class 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
| Old | New |
|---|---|
DispatchQueue + manual sync | actor keyword |
| "trust me it's thread-safe" | compiler error if you get it wrong |
DispatchQueue.main.async { } | @MainActor |
| Crashes at runtime | Caught at compile time |
| Deadlocks from nested sync | Structured 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.