Swift Concurrency in production: actors at the edges
Coinlocally's iOS codebase went through the arc I've since watched other teams repeat once Swift Concurrency became viable: enthusiasm, actor-everything urges, the discovery that the first actor didn't fix anything, and finally a small set of patterns that actually held. The conclusion fits in a sentence: actors belong at the edges of your system, on the specific state that's actually contended; the middle should stay values and pure functions.
What an actor is actually for
An actor protects long-lived mutable state that must be shared. That's the entire job description. In a trading app, remarkably few things qualify: the market-data store, a session/token holder, a connection manager. What doesn't qualify is view models, domain logic, formatters, or anything that could be a struct, which in a well-factored app is nearly everything.
The instructive mistake here wasn't reading actor as "the new class" everywhere, it was narrower and easier to miss: the codebase's first actor isolated a small buffer used while paginating open-order results, real and correctly written, nowhere near the order-book state that was actually driving crashes. Adopting the language feature had happened. It hadn't happened to the problem. That gap sat in the code for months before the market-data store itself was moved into an actor.
An actor in your codebase is not the same claim as an actor on your bottleneck. Adoption and correctness are two different questions.
Reentrancy is the tax you forgot to price in
Once the store that mattered was actually isolated, a second, subtler failure mode showed up: actor methods can interleave at every await. Here's the shape of the bug, distilled to a market-data cache pattern:
actor MarketDataCache {
private var cached: OrderBookSnapshot?
func snapshot() async throws -> OrderBookSnapshot {
if let cached { return cached }
// ⚠️ Suspension point: another caller can enter here,
// see `cached == nil`, and fetch a second time.
let fresh = try await fetchLatestSnapshot()
cached = fresh
return fresh
}
}
Two concurrent callers, two redundant fetches racing to write the same cache, on a screen already sensitive to update frequency. The fix is to make the in-flight work part of the protected state, so check-and-act is atomic again:
actor MarketDataCache {
private var state: State = .empty
private enum State {
case empty
case fetching(Task<OrderBookSnapshot, Error>)
case ready(OrderBookSnapshot)
}
func snapshot() async throws -> OrderBookSnapshot {
switch state {
case .ready(let snap):
return snap
case .fetching(let task):
return try await task.value
case .empty:
let task = Task { try await self.fetchLatestSnapshot() }
state = .fetching(task)
do {
let snap = try await task.value
state = .ready(snap)
return snap
} catch {
state = .empty
throw error
}
}
}
}
The general rule: an actor's invariants must hold at every await, not just at every return. If a method can't promise that, the state machine, not the caller, needs to model the in-between. (The mechanics of getting to this store in the first place, replacing per-screen locks with one consolidated, actor-isolated store, is its own story: race conditions on a trading app.)
The architecture that shook out
The isolation map that held up looks like three rings:
- The main actor, at the UI edge. Views and view models are
@MainActor, full stop. UI state is inherently main-thread state; pretending otherwise buys complexity and nothing else. - A handful of actors at the resource edge. One per genuinely shared mutable resource: the market-data store, a session/token holder, a connection manager. Not one per type, not one per feature.
- Values everywhere between. Requests, responses, domain models, and business logic are
Sendablestructs and pure functions. They cross isolation boundaries freely because there is nothing about them to protect.
Data flows in one shape: a value enters from an edge, is transformed by pure code, and is handed to another edge. await appears where the architecture says an edge is, so every suspension point in a diff is either explainable or a design smell.
Structured means structured
The other habit worth keeping: unstructured tasks are a code review flag. Task {} severs the caller's cancellation, priority, and, most expensively, its reasoning. Child tasks via async let and task groups keep lifetime visible in the source text. An unstructured Task {} sitting in a socket-delegate callback is usually one of two things: a fire-and-forget that should have been an AsyncStream consumer, or a bug nobody has hit yet.
Where this leaves you
Swift Concurrency's real gift isn't the syntax, it's that isolation becomes a design surface you can reason about the way you'd reason about a module graph: strict boundaries at the edges, calm value-typed territory in the middle, every crossing visible in the source. Adding an actor is the easy part. Finding the actual bottleneck before you add it is the part that took longer, and mattered more.