Swift Actors: 6 pitfalls that will catch even experienced developers
When Apple introduced actors in Swift 5.5, they promised us the end of data races. "Just replace class with actor and problem solved" - that was the narrative. Reality turned out to be much more complex.
After a series of experiences with actors, I've gathered a list of pitfalls that even seniors regularly fall into. Some of them are poorly documented, and others actively mislead.
1. Reentrancy: an actor is not a serial queue
This is probably the most underappreciated pitfall. Most developers think of an actor like a class with a built-in DispatchQueue(label: "serial"). That's wrong.
An actor guarantees only one thing: only one piece of code executes at a time. But between await points, it can handle a completely different call.
actor BankAccount {
var balance: Int = 1000
func withdraw(_ amount: Int) async -> Bool {
// Check balance
guard balance >= amount else { return false }
// ⚠️ SUSPENSION POINT - here the actor can handle another call
await authorizeTransaction()
// After returning, balance might have changed!
balance -= amount // Can go below zero
return true
}
private func authorizeTransaction() async {
try? await Task.sleep(for: .milliseconds(100))
}
}
If two tasks call withdraw(800) almost simultaneously:
- Task A: checks
balance >= 800→ true - Task A: waits for
authorizeTransaction() - Task B: enters the actor, checks
balance >= 800→ true (still 1000!) - Task B: waits for
authorizeTransaction() - Task A: returns, subtracts 800 → balance = 200
- Task B: returns, subtracts 800 → balance = -600 💥
Why was it designed this way?
Apple chose reentrancy deliberately to avoid deadlocks. Imagine two actors waiting for each other - without reentrancy you have a classic deadlock. With reentrancy you have… subtle state bugs.
Solution: Task cache pattern
Instead of checking state before await, save the Task before the first suspension point:
actor BankAccount {
var balance: Int = 1000
private var pendingWithdrawals: [UUID: Task<Bool, Never>] = [:]
func withdraw(_ amount: Int, id: UUID = UUID()) async -> Bool {
// If we're already processing this transaction, wait for result
if let existing = pendingWithdrawals[id] {
return await existing.value
}
// Check synchronously BEFORE any await
guard balance >= amount else { return false }
// Reserve funds synchronously
balance -= amount
let task = Task {
await authorizeTransaction()
return true
}
pendingWithdrawals[id] = task
let result = await task.value
pendingWithdrawals[id] = nil
if !result {
balance += amount // Rollback
}
return result
}
}
Key change: state mutation happens synchronously, before the first await.
Note: This is one of the possible patterns for solving reentrancy - not the only one and not always the best. Alternatives include splitting into actor + pure async service, optimistic locking, or in some cases
nonisolated+ lock. The choice depends on the specific use case.
2. Actor hopping: the hidden performance killer
Every transition between actors is a potential context switch. In loops, this can be catastrophic.
actor Database {
func loadUser(id: Int) -> User {
// Heavy operation
User(id: id)
}
}
@MainActor
class DataModel {
let database = Database()
var users: [User] = []
func loadUsers() async {
for i in 1...100 {
// ❌ 200 context switches!
let user = await database.loadUser(id: i)
users.append(user)
}
}
}
Each iteration is:
- Hop from MainActor to Database actor,
- Hop from Database actor back to MainActor.
With 100 iterations you have 200 hops. Apple showed in WWDC 2021 ("Swift Concurrency: Behind the Scenes") what this looks like on CPU - a "sawtooth" pattern with constant interruptions.
Solution: batching
actor Database {
func loadUsers(ids: [Int]) -> [User] {
ids.map { User(id: $0) }
}
}
@MainActor
class DataModel {
let database = Database()
var users: [User] = []
func loadUsers() async {
let ids = Array(1...100)
// ✅ One hop there, one back
let newUsers = await database.loadUsers(ids: ids)
users.append(contentsOf: newUsers)
}
}
When does it really hurt?
Hopping between actors in the cooperative pool is cheap. The problem appears when hopping to/from MainActor, because the main thread is outside the cooperative pool and requires a real context switch.
Rule: if you're doing more than 10 hops to MainActor in a single operation, something is probably wrong with the architecture.
3. @MainActor: false sense of security
This is a pitfall that caught hundreds of developers after transitioning to Swift 6. The @MainActor annotation doesn't always guarantee execution on the main thread.
@MainActor
class ViewModel {
var data: String = ""
func updateData() {
// In Swift 5: might NOT be on main thread!
data = "updated"
}
}
// Somewhere in code...
DispatchQueue.global().async {
let vm = ViewModel()
vm.updateData() // ⚠️ Executes on background thread!
}
Why does this happen?
Two things need to be distinguished:
- Isolation to MainActor -
@MainActorguarantees that state access is isolated to MainActor (and MainActor is tied to the main thread), - Enforcing async boundary - but this guarantee only works when the call goes through the isolation boundary (async boundary).
The problem appears when code bypasses this boundary - especially when interacting with legacy APIs from Objective-C. A callback from an Apple framework doesn't "know" about Swift Concurrency and calls your method directly, without going through the async boundary.
In other words: @MainActor is a compile-time contract that the compiler enforces only where it "sees" the entire call path. Legacy APIs are a black box to it.
Swift 6 language mode catches most of these cases, but not all - especially when interacting with Objective-C.
Cases where @MainActor fails
1. Callbacks from Apple frameworks:
@MainActor
class BiometricManager {
var isAuthenticated = false
func authenticate() {
let context = LAContext()
context.evaluatePolicy(
.deviceOwnerAuthentication,
localizedReason: "Login"
) { success, _ in
// ❌ This callback is ALWAYS on a background thread!
self.isAuthenticated = success // Data race!
}
}
}
2. Delegate pattern from Objective-C:
@MainActor
class LocationHandler: NSObject, CLLocationManagerDelegate {
var lastLocation: CLLocation?
func locationManager(
_ manager: CLLocationManager,
didUpdateLocations locations: [CLLocation]
) {
// ❌ Can be called from any thread!
lastLocation = locations.last
}
}
Solution: explicit dispatch
@MainActor
class BiometricManager {
var isAuthenticated = false
func authenticate() async {
let context = LAContext()
// Use async/await instead of callbacks
do {
let success = try await context.evaluatePolicy(
.deviceOwnerAuthentication,
localizedReason: "Login"
)
isAuthenticated = success // ✅ Now on MainActor
} catch {
isAuthenticated = false
}
}
}
For delegates without async API:
func locationManager(
_ manager: CLLocationManager,
didUpdateLocations locations: [CLLocation]
) {
Task { @MainActor in
lastLocation = locations.last // ✅ Explicit hop
}
}
4. Sendable: the compiler won't catch everything
Sendable is a protocol marking types safe to pass between isolated domains. The problem is that the compiler often lets unsafe code through.
class UnsafeCache {
var items: [String: Data] = [:] // Mutable state, not thread-safe
}
actor DataProcessor {
func process(cache: UnsafeCache) async {
// ⚠️ In Swift 5 this compiles without warnings!
cache.items["key"] = Data()
}
}
@unchecked Sendable: a double-edged sword
Many developers, to silence the compiler, add @unchecked Sendable:
extension UnsafeCache: @unchecked Sendable {}
This tells the compiler: "trust me, I know what I'm doing". The problem is that most often you don't.
Cases where @unchecked Sendable is justified:
- Types that are immutable but for technical reasons marked as mutable (e.g., lazy initialization),
- Types with internal synchronization (lock, atomic),
- Singletons initialized once at startup.
Cases where NOT to use @unchecked Sendable:
- "To make it compile",
- Classes with mutable state without synchronization,
- Types you don't control.
Better solution: refactoring to actor
// Instead of
class UnsafeCache: @unchecked Sendable {
var items: [String: Data] = [:]
}
// Use
actor SafeCache {
var items: [String: Data] = [:]
func get(_ key: String) -> Data? { items[key] }
func set(_ key: String, _ value: Data) { items[key] = value }
}
5. nonisolated: doesn't mean "thread-safe"
The word nonisolated only means that the method/property doesn't require actor isolation. It doesn't mean it's thread-safe.
actor Counter {
private var count = 0
nonisolated var description: String {
// ❌ You can't use count here!
"Counter instance" // OK, because we don't touch state
}
nonisolated func badIdea() {
// ❌ COMPILE ERROR: Actor-isolated property 'count'
// can not be referenced from a non-isolated context
print(count)
}
}
Typical mistake: nonisolated for conformance
actor Wallet: CustomStringConvertible {
let name: String
var balance: Double = 0
// Must be nonisolated for the protocol
nonisolated var description: String {
// ❌ WON'T WORK:
// "\(name): \(balance)"
// ✅ Only immutable state:
name
}
}
nonisolated in Swift 6.2 with MainActor by default
In the new model with the MainActorIsolationByDefault flag, nonisolated takes on a new meaning: it means "inherits the caller's isolation".
// With MainActorIsolationByDefault = true
class DataManager {
// @MainActor by default
func processOnMain() { }
// Inherits caller's context
nonisolated func processAnywhere() { }
// Explicit: run on background
@concurrent
func processInBackground() async { }
}
This is a paradigm shift - nonisolated stops meaning "without isolation" and starts meaning "flexible isolation".
6. An actor is not a serial queue: no ordering guarantee
This surprises many developers transitioning from GCD. Actors don't guarantee the order of execution of calls from outside.
actor Logger {
private var logs: [String] = []
func log(_ message: String) {
logs.append(message)
}
func getLogs() -> [String] { logs }
}
let logger = Logger()
// From nonisolated context
for i in 0..<10 {
Task {
await logger.log("Message \(i)")
}
}
// Logs might be: [0, 2, 1, 4, 3, 6, 5, 8, 7, 9]
// or any other permutation!
Why is this the case?
Two things need to be distinguished:
- Actor mailbox is FIFO - the actor processes messages in the order they arrived in its mailbox,
- Task scheduling is NOT FIFO - but the order in which tasks send messages to the actor is not deterministic.
In other words: enqueue order ≠ execution order. Each Task is an independent unit of work. The scheduler can run them in any order, so messages arrive at the actor's mailbox in an unpredictable sequence. The actor only guarantees that log() won't execute in parallel - but not in what order messages will arrive.
Solution: explicit ordering
If you need ordering, you must enforce it:
actor OrderedLogger {
private var logs: [String] = []
private var pendingTask: Task<Void, Never>?
func log(_ message: String) async {
// Wait for previous task
await pendingTask?.value
// Save this task as next in queue
pendingTask = Task {
logs.append(message)
}
await pendingTask?.value
}
}
Or use a dedicated library like swift-async-queue:
import AsyncQueue
actor OrderedLogger {
private var logs: [String] = []
private let queue = FIFOQueue()
nonisolated func log(_ message: String) -> Task<Void, Never> {
Task(on: queue) {
await self.appendLog(message)
}
}
private func appendLog(_ message: String) {
logs.append(message)
}
}
Checklist before using an actor
Before you replace a class with an actor, go through this list:
✅ Use an actor when:
- You have mutable state shared between tasks,
- You need thread-safety without manual synchronization,
- Operations on state are mostly synchronous.
❌ Don't use an actor when:
- You need guaranteed ordering of operations,
- All operations are asynchronous (reentrancy will be a problem),
- You have performance-critical code with many small operations,
- You require synchronous access to state.
🔍 Control questions:
- Do I have await inside methods that mutate state? → reentrancy risk,
- Am I calling an actor in a loop? → actor hopping risk,
- Am I using @MainActor with delegates/callbacks? → thread-safety risk,
- Am I using @unchecked Sendable? → Why exactly?
- Do I care about operation ordering? → Actor doesn't guarantee it.
Summary
Actors are a powerful tool, but not a magic wand. Understanding their limitations is crucial for writing correct, efficient code.
Key takeaways:
- Reentrancy - state can change between
await, always assume that, - Actor hopping - expensive with MainActor, batch operations,
- @MainActor - it's a compile-time hint, not a runtime guarantee (especially with legacy APIs),
- Sendable -
@uncheckedis a last resort, - nonisolated - doesn't mean thread-safe,
- Ordering - actors don't guarantee it (enqueue order ≠ execution order).
Swift Concurrency is still an evolving system. Swift 6.2 introduces MainActorIsolationByDefault and @concurrent, which change default behaviors. Stay up to date with Swift Evolution proposals - that's the only way not to be caught off guard.

Rafał Dubiel
Senior iOS Developer z ponad 8-letnim doświadczeniem w tworzeniu aplikacji mobilnych. Pasjonat czystej architektury, Swift i dzielenia się wiedzą ze społecznością.
Udostępnij artykuł: