Architecting Shared State with Swift Actors
Learn how to implement Swift Actors to eliminate data races. This guide covers actor isolation, the Sendable protocol, and the critical risks of actor reentrancy in concurrent systems.
13 May 2026, 23:54 UTC

The Problem: Data Races in Concurrent State
When multiple threads attempt to read and write to the same memory location simultaneously, the application enters an undefined state known as a data race. In traditional Swift development, this was managed using DispatchQueue or NSLock. However, these manual locking mechanisms are prone to developer error, often leading to deadlocks or forgotten locks that cause intermittent crashes.
The takeaway: Swift Actors provide a compiler-enforced synchronization mechanism that ensures only one task can access an actor's mutable state at a time, shifting the burden of thread safety from the developer to the type system.
Requirements for Actor-Based Design
Actors are most effective when the following requirements are met:
- Shared Mutable State: You have a resource (like a cache, a database connection, or a user session) that must be accessed by multiple concurrent tasks.
- Isolation: The state must be protected from external mutation to maintain consistency.
- Asynchronous Access: The calling code can tolerate the suspension of execution while waiting for access to the resource.
The Smallest Suitable Design
The most efficient use of an actor is to wrap only the specific state that requires synchronization, rather than wrapping entire business logic layers. This minimizes the "actor hop" overhead—the cost of switching execution contexts between different actors.
Consider a shared image cache. Instead of making the entire network manager an actor, isolate only the storage mechanism:
// Run this in a Swift 5.5+ environment (iOS 15+, macOS 12+)
actor ImageCache {
private var cache: [URL: UIImage] = [:]
func getImage(for url: URL) -> UIImage? {
return cache[url]
}
func insert(_ image: UIImage, for url: URL) {
cache[url] = image
}
}
To interact with this actor from a Task or another async function, you must use await. This signals a potential suspension point where the system can pause the current task if the actor is already busy processing another request.
Trust and Data Boundaries
Actors rely on the Sendable protocol to ensure that data crossing the boundary between actors is safe. A type is Sendable if it can be shared across concurrent contexts without creating a data race.
| Type Category | Sendable Status | Reasoning |
|---|---|---|
| Value Types (Struct, Enum) | Yes (if members are Sendable) | Copied upon transfer; no shared reference. |
| Actors | Yes | Internal state is isolated; only Sendable data is exposed. |
| Classes (Standard) | No | Reference types allow multiple owners to mutate state. |
| Final Classes (Immutable) | Yes (via @Sendable) | No mutable state exists to race. |
Operational Checks and Verification
To verify that an actor is correctly isolating state, attempt to mutate a property directly from a non-isolated context:
let cache = ImageCache()
// This will trigger a compiler error: "Actor-isolated property 'cache' can only be referenced from within the actor"
cache.insert(myImage, for: myUrl)
The correct implementation requires an asynchronous context:
Task {
await cache.insert(myImage, for: myUrl)
}
Runtime Verification: Enable the Swift Concurrency Runtime Check in your Xcode scheme settings. This will cause the app to crash immediately upon detecting a data race during development, rather than allowing it to fail silently in production.
Failure Modes and Reentrancy
The most critical failure mode in actor design is Actor Reentrancy. When a task hits an await keyword inside an actor method, the actor suspends that task. While suspended, the actor is free to start processing other tasks.
If you assume the actor's state remains unchanged across an await call, you may introduce logic bugs. For example, if an actor checks if a file exists, calls await to download it, and then saves it, another task could have saved the file during the suspension point, leading to a duplicate write.
Conditions that Change the Design
- High-Frequency Synchronous Access: If a component requires thousands of synchronous reads per second, the overhead of
awaitmay be too high. In this case, apthread_rwlock_tor a specialized lock may be more performant. - UI Dependency: If the state must be updated on the main thread, use
@MainActor. This is a global actor that ensures all designated code runs on the main dispatch queue, preventing UIKit/AppKit threading crashes. - Complex Dependency Chains: If Actor A calls Actor B, which calls Actor A back, you risk a logical deadlock. If your architecture requires bidirectional synchronous-like communication, consider consolidating the state into a single actor or using a state machine.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.