Stopping the Race: Using Swift Actors for Shared Mutable State
Stop chasing non-deterministic data races. Learn how Swift Actors replace manual locking with compile-time isolation to protect shared mutable state.
19 Jan 2026, 17:06 UTC

The Non-Deterministic Bug
You have a shared cache or a session manager that multiple background tasks access simultaneously. Everything works during local development, but in production, you see occasional crashes or inconsistent UI states. These are data races: two or more threads accessing the same memory location concurrently, where at least one access is a write.
Historically, Swift developers solved this with DispatchQueue or NSLock. However, manual locking is a runtime discipline; if one developer forgets to wrap a property access in a lock, the compiler won't stop them, and the race returns. The goal is to move this safety from a "developer promise" to a compile-time guarantee.
Actor Isolation as a Design Choice
Swift Actors provide a structured way to isolate mutable state. An actor is a reference type (like a class) that ensures only one task can access its internal state at a time. This is called actor isolation.
When you define a type as an actor, the Swift compiler prevents any external code from directly reading or writing its properties. To interact with the actor, you must use await, signaling that the calling code may be suspended while the actor finishes its current task. This transforms concurrency from a manual locking problem into a type-system requirement.
From Locked Classes to Actors
Consider a simple image cache. In a traditional class-based approach, you might use a concurrent queue with a barrier for writes. With an actor, the synchronization is implicit.
The Actor Implementation
actor ImageCache {
private var cache: [URL: UIImage] = [:]
func insert(_ image: UIImage, for url: URL) {
cache[url] = image
}
func image(for url: URL) -> UIImage? {
return cache[url]
}
}
Interacting with the Actor
Because the state is isolated, you cannot access cache directly from a View Controller or a ViewModel. You must call the actor's methods asynchronously:
let imageCache = ImageCache()
Task {
// This requires 'await' because it crosses the actor boundary
await imageCache.insert(myImage, for: imageURL)
if let cached = await imageCache.image(for: imageURL) {
print("Found image!")
}
}
If you attempt to call imageCache.insert(...) without await or outside of an asynchronous context, the compiler will throw an error: "Actor-isolated property 'cache' can only be referenced from within the actor". This is the primary benefit: the bug is caught before the code ever runs.
The Reentrancy Trap
While actors prevent data races, they introduce a different challenge: reentrancy. When an actor hits an await point inside one of its methods, it suspends. While suspended, the actor is free to start processing other messages.
If your actor method performs an async network call and then updates its state, the state may have changed while the network call was pending. To handle this, always re-verify your assumptions or invariants immediately after an await call within an actor method.
Trade-offs and Limitations
- Async Propagation: Once you move state into an actor, every call to that state becomes
async. This often forces a "ripple effect" where calling functions must also becomeasyncor be wrapped in aTask. - Overhead: For extremely high-frequency, simple operations (like incrementing a single integer millions of times), the overhead of actor scheduling is higher than a low-level
os_unfair_lock. - Sendability: To pass data into or out of an actor, the types must be
Sendable(value types like Structs or final classes with immutable state). Passing a mutable class instance into an actor doesn't protect the class's internal state, only the actor's reference to it.
Actionable Implementation Path
To integrate actors into your project effectively, follow these steps:
- Enable Strict Concurrency: In Xcode, go to Build Settings and set Strict Concurrency Checking to Complete. This will highlight existing data races in your code as warnings.
- Identify Shared State: Look for singletons or manager classes that are accessed by multiple threads. These are your primary candidates for conversion to
actor. - Use @MainActor for UI: For state that must be updated on the main thread (like published properties in a ViewModel), use the
@MainActorattribute rather than a custom actor to ensure synchronization with the UI loop. - Verify with Thread Sanitizer: Run your app with the Thread Sanitizer (TSan) enabled in the Scheme diagnostics to ensure no legacy non-actor code is bypassing isolation.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.