Actors in practice: safe shared state without "actor everywhere" nonsense
A practical guide to Swift actors: where they help, where they hurt, and how to isolate shared state without turning your app into async soup.
Actors solve one specific problem very well: protecting shared mutable state that can be touched by overlapping tasks.
That is it.
They are not a commandment to wrap every service, model, and helper in actor and then spend two weeks fighting await at every call site.
If you apply actors where ownership is unclear, they turn normal code into slower, noisier code. If you apply them where state genuinely needs serialization, they remove an entire class of bugs.
The trick is to stop asking, “Should this type be an actor?” and start asking, “What mutable state is shared across concurrent callers, and who owns it?”
That is the boundary that matters.
The actual job of an actor
An actor gives you isolated mutable state. Only one task at a time can access that state through the actor’s isolated methods and properties.
That buys you two important guarantees:
- callers cannot race each other through the actor’s mutable state
- invariants inside the actor are easier to maintain because mutation is serialized
What it does not buy you:
- automatic performance wins
- immunity from bad architecture
- freedom from thinking about
Sendable - freedom from reentrancy problems
Treat actors as a state ownership tool, not a universal concurrency pattern.
Start with ownership, not syntax
Before introducing an actor, map the state.
Ask four questions:
- Is this state mutable?
- Can multiple tasks reach it concurrently?
- Does correctness depend on coordinating reads and writes?
- Is value semantics or confinement enough instead?
If the answer to 2 and 3 is “no”, you probably do not need an actor.
A lot of code becomes simpler with one of these instead:
- immutable structs passed between tasks
@MainActorisolation for UI-bound state- local state confined to one task or one object graph
- plain services with no internal mutable state
That is why actor everywhere is nonsense. It ignores the cheaper options.
Good candidates for actors
Actors shine when you have a small, important piece of shared state with clear invariants.
Real examples:
- auth token refresh coordination
- in-memory caches
- request deduplication
- rate limiters
- download registries
- analytics/event batching buffers
- shared stores that merge updates from multiple async sources
These all have the same shape: overlapping callers, mutable shared state, and correctness rules that matter.
Bad candidates for actors
Some types get turned into actors because the team is scared of concurrency diagnostics, not because the type needs isolation.
Usually bad fits:
- stateless API clients that just create requests and decode responses
- data models that could be immutable structs
- view models that are obviously UI-bound and should be
@MainActor - giant service types that already do five unrelated jobs
If your fix is “make the whole thing an actor” because the compiler complained, you are probably hiding a boundary problem instead of solving it.
Split responsibilities first. Then isolate the part that actually owns shared mutable state.
A practical actor: token refresh coordination
A classic production bug looks like this:
- three requests hit the API with an expired token
- all three notice the 401
- all three start a refresh flow
- one refresh succeeds, another invalidates it, the third stores stale state, and now your auth layer is chaos
That is a real actor use case.
You do not need your whole networking stack to become an actor. You need one component to own refresh coordination.
actor AuthSessionCoordinator {
private var accessToken: String?
private var refreshTask: Task<String, Error>?
func currentToken() -> String? {
accessToken
}
func store(token: String) {
accessToken = token
}
func validToken(
refresh: @Sendable @escaping () async throws -> String
) async throws -> String {
if let task = refreshTask {
return try await task.value
}
if let accessToken {
return accessToken
}
let task = Task {
try await refresh()
}
refreshTask = task
do {
let newToken = try await task.value
accessToken = newToken
refreshTask = nil
return newToken
} catch {
refreshTask = nil
throw error
}
}
func invalidateToken() {
accessToken = nil
}
}
The important part is not the syntax. It is the invariant:
- at most one refresh is in flight
- concurrent callers share that refresh work
- token state is updated in one place
That is exactly the kind of coordination actors are for.
The rest of your network client can stay ordinary.
Keep actor APIs narrow
A good actor usually has a small API.
That is not style purity. It is damage control.
When an actor exposes too much surface area, you get three problems fast:
- every call site becomes littered with
await - the actor turns into a bottleneck
- the type starts owning unrelated invariants
Prefer actor methods that express operations, not raw access.
Bad:
actor ImageStore {
var images: [URL: UIImage] = [:]
}
Now every caller wants direct dictionary semantics, and the actor leaks its internals.
Better:
actor ImageStore {
private var images: [URL: UIImage] = [:]
func image(for url: URL) -> UIImage? {
images[url]
}
func insert(_ image: UIImage, for url: URL) {
images[url] = image
}
func removeImage(for url: URL) {
images[url] = nil
}
}
Better still, define operations that match the workflow you need, such as cache lookup plus insertion policy, eviction, or request coalescing.
The smaller the API, the easier it is to preserve invariants.
Reentrancy is where the sharp edges live
This is the part people miss.
Actor isolation serializes access to state, but actors are still reentrant across suspension points. Once an actor method hits await, other work can enter the actor before the suspended method resumes.
That means this mental model is wrong:
“I entered the actor, so nothing can change until this function returns.”
No. Nothing else can run in the actor during a synchronous isolated section. Once you suspend, the door opens.
That matters whenever your logic depends on state remaining unchanged across an await.
Bad pattern:
actor Inventory {
private var stock: [String: Int] = [:]
func reserve(_ sku: String) async throws {
guard let count = stock[sku], count > 0 else {
throw InventoryError.outOfStock
}
try await auditReservation(for: sku)
stock[sku] = count - 1
}
}
Between await auditReservation(...) and the later write, another task could have changed stock.
Safer approaches:
- perform mutation before suspension if the invariant allows it
- copy the state you need, suspend, then re-check before committing
- split the workflow so the actor protects the critical section and outside code does the slow async work
Example with re-check:
actor Inventory {
private var stock: [String: Int] = [:]
func reserve(_ sku: String) async throws {
guard stock[sku, default: 0] > 0 else {
throw InventoryError.outOfStock
}
try await auditReservation(for: sku)
guard stock[sku, default: 0] > 0 else {
throw InventoryError.outOfStock
}
stock[sku, default: 0] -= 1
}
}
It is still not free. You have to design for reentrancy.
@MainActor is not the same thing
A lot of teams confuse these two moves:
- isolate UI state to
@MainActor - isolate shared non-UI mutable state to a custom actor
Those are different decisions.
If a type exists to drive views, publish UI state, handle navigation, or mutate observable state consumed by the interface, @MainActor is usually the right boundary.
@MainActor
final class ProfileViewModel: ObservableObject {
@Published private(set) var profile: Profile?
@Published private(set) var isLoading = false
private let client: APIClient
init(client: APIClient) {
self.client = client
}
func load() async {
isLoading = true
defer { isLoading = false }
profile = try? await client.fetchProfile()
}
}
Making this an actor instead would usually be worse:
- the type is UI-bound anyway
- the observation story gets uglier
- you now mix UI concerns with generic actor isolation for no gain
Use the main actor when the truth is “this belongs to the UI thread of execution.” Use a custom actor when the truth is “this shared state needs its own serialized owner.”
Stateless services should stay boring
This is where overuse gets expensive.
A typical API client like this does not need to be an actor:
struct APIClient {
func fetchProfile() async throws -> Profile {
let request = URLRequest(url: URL(string: "https://api.example.com/profile")!)
let (data, _) = try await URLSession.shared.data(for: request)
return try JSONDecoder().decode(Profile.self, from: data)
}
}
No shared mutable state. No actor needed.
If you later discover it has one mutable concern, such as a retry budget or request deduplicator, isolate that concern, not the entire client.
This keeps most of the code synchronous to reason about, even if it is async to execute.
Think about Sendable at the boundary
Actors do not replace Sendable. They make it more important.
When values cross isolation boundaries, you want them to be safe to share.
Good defaults:
- prefer immutable structs for data payloads
- keep reference types out of cross-actor APIs unless you have a strong reason
- avoid passing around bags of mutable shared state
- treat
@unchecked Sendablelike radioactive waste: only with a written safety invariant
If your actor API keeps forcing awkward non-Sendable values across the boundary, the problem may be your model design, not the actor.
Performance: actors serialize work, they do not optimize it
Actors can remove lock contention bugs and simplify correctness, but they can also become hotspots.
Common mistakes:
- putting CPU-heavy work inside the actor when it could happen outside
- building one giant actor that owns unrelated state
- making high-frequency read paths bounce through serialized access for no reason
A few practical rules:
- keep isolated critical sections small
- move expensive pure computation outside the actor
- split actors by ownership domain if contention shows up
- measure before “optimizing” away isolation
If one cache actor handles everything for the app, maybe that is fine. If it becomes a convoy point under load, shard it or redesign the ownership model.
Correctness first. Then measure.
A simple decision test
Before creating an actor, run this quick check:
- What exact mutable state will this type own?
- Which concurrent callers can touch it?
- What invariant breaks if two callers interleave badly?
- Could
@MainActor, value semantics, or simpler confinement solve it more cheaply? - Where are the suspension points, and what changes if reentrancy happens there?
If you cannot answer those questions clearly, you are not ready to introduce the actor.
If you can, the actor will probably pull its weight.
The practical rule
Do not use actors to make the compiler shut up.
Use actors when you need one place to own shared mutable state and enforce a small set of invariants under concurrency.
That is the real win: less fear, fewer race conditions, and code whose ownership model is obvious when you read it six months later.
That is enough. You do not need actor everywhere. You need the right actor in the right place.