- Published on
Actors in Swift: The Problem They Solve and How it Works
- Authors

- Name
- Omar Elsayed
Introduction
A couple of days ago, I wrote an article about AsyncGate, built by Matt—a way to solve actor rentrency.
Writing it scratched an itch, but it also left me with a bigger question buzzing in the back of my head: what are actors actually for? How do they work under the hood? And why did Swift bother adding them in the first place? 🤔
So I did what any curious developer would do—I went straight to the source and read the actor proposal, SE-0306. And honestly? Best decision I've made in a while 😅.
Actors used to be a complete mystery to me—I'd use them, sort of trust them, and quietly hope I was holding them right. After reading the proposal, the fog lifted. Everything clicked.
This article is me sharing that "aha" moment with you and here's my one ask for you: if you spot anything in here that isn't accurate, please message me so I can fix it.
Knowledge is worthless if it just sits in one head—let's share what we've got.
So let's dive deep, before we touch a single line of actor syntax, we need to understand the thing actors were built to kill: The problem.
The Problem Actors solves
Before Actors, there was no clean way to share a class with mutable state across threads. If you wanted it, you had to roll up your sleeves and write manual synchronization yourself—locks, dispatch queues, the whole toolbox—just to keep data races at bay.
And if you were really lucky, that hand-written code might even work correctly. 😅
That's the trap. The compiler couldn't help you. A data race wasn't a build error you could catch over coffee—it was a runtime ghost that showed up in production, on a Tuesday, only on certain devices, and only sometimes.
Good luck reproducing that in a debugger 🤣.
So the goal was clear: give developers a way to share mutable state and get data-race protection baked in, instead of bolted on by hand. That's exactly where Actors come in.
Actors were made to let you create a shared object with mutable state without lying awake worrying about data races.
An actor can be called from more than one thread, and you don't have to write a single lock to keep it safe—because the actor protects its own data through isolation. (Don't worry about what that word means just yet—we'll dig into it in the next section.)
Right about now you might be thinking: if actors exist to protect mutable state, then an actor with no state—a stateless actor—is pointless, right?
Honestly, the answer is more interesting than a simple yes or no, Matt wrote an entire article on exactly this and it's worth your time.
But the short version: it depends on you. If you reach for a stateless actor intentionally—knowing precisely what an actor does and weighing the pros and cons—then it can be a perfectly valid choice.
If you ended up with one by accident, without really knowing why, then yeah, it's probably useless and a sign something went sideways in your design.
Now that we understand the problem actors were built to solve, let's pop the hood and see how they actually work.
How Actors Work Under the Hood
So here's the big question: how do Actors actually protect your state? The answer is simple—actor isolation.
Actor isolation has one core rule: stored and computed instance properties, methods and subscripts can only be accessed directly on self and they are isolated by default. That's it. So if you try something like this:
// Example adapted from SE-0306
// https://github.com/swiftlang/swift-evolution/blob/main/proposals/0306-actors.md
extension BankAccount {
enum BankError: Error {
case insufficientFunds
}
func transfer(amount: Double, to other: BankAccount) throws {
if amount > balance {
throw BankError.insufficientFunds
}
print("Transferring \(amount) from \(accountNumber) to \(other.accountNumber)")
balance = balance - amount
other.balance = other.balance + amount
// ❌ error: actor-isolated property 'balance' can only be referenced on 'self'
}
}
The compiler throws that error right in your face. 😅 And the reason is simple: balance is an actor-isolated property, which means it can only be touched directly from inside the actor it belongs to.
What we just did—reaching into other.balance from outside that actor—is called a cross-actor reference: an actor's isolated declaration being accessed from outside the actor. This is allowed in exactly two ways.
Case 1: The state is immutable (and a value type)
A cross-actor reference is allowed when the thing you're touching is immutable—a let property—and its type has value semantics, not reference semantics.
The distinction matters: with a reference type you could still mutate the object's properties even through it's a let, so the "immutable" promise leaks.
With a value type, reading it just hands you a copy, and nobody can race against your copy. (This also assumes you're in the same module as the actor's definition.)
When all of those line up, there's literally nothing left to protect—so the access is safe.
That's exactly why other.accountNumber in the example above is fine: it's a let of value-semantic type Int.
Case 2: The call is asynchronous
The second way a cross-actor reference is allowed is through asynchronous function invocation.
Under the hood, those calls get turned into partial tasks that run only when no other code is executing on that actor—coordinated by the actor's own serial executor (more on that in a minute).
So to actually fix the example, we route the mutation through an async method on other:
// Example adapted from SE-0306
// https://github.com/swiftlang/swift-evolution/blob/main/proposals/0306-actors.md
extension BankAccount {
enum BankError: Error {
case insufficientFunds
}
func transfer(amount: Double, to other: BankAccount) async throws {
if amount > balance {
throw BankError.insufficientFunds
}
print("Transferring \(amount) from \(accountNumber) to \(other.accountNumber)")
balance = balance - amount
await other.deposit(amount: amount)
}
}
extension BankAccount {
func deposit(amount: Double) {
assert(amount >= 0)
balance = balance + amount
}
}
Now you might be wondering: deposit is a synchronous function—why am I forced to await it?
Here's the thing: an actor's isolated function can be called synchronously when you're already on that actor's self. But the moment it's a cross-actor reference, the call must be asynchronous so the actor can schedule it safely and keep your state race-free.
That await is the compiler making you acknowledge the hop across the isolation boundary.
⚡️ Heads up: because we now use
await,transferitself had to becomeasync throws. You can't sneak anawaitinto a plainthrowsfunction—the compiler won't have it.
The Actor's Serial Executor
Every actor uses its own serial executor to queue up those asynchronous calls to its isolated functions.
Think of it like a DispatchQueue... but with a twist.
it's not strictly first-in-first-out, instead of running tasks in the order they arrived, the executor can favor higher-priority tasks—which exists specifically to help prevent priority inversion (a low-priority task hogging the actor while a high-priority one waits).
And this leads to the single most important thing to burn into your brain:
The order in which calls arrive at an actor is NOT the order they execute.
With Actors, execution order is not guaranteed—at allllll. Don't ever forget that. It's the source of more "wait, why did that run first?" bugs than anything else in actor land 😅.
Next up: actor reentrancy—what happens when an actor awaits in the middle of its own work and the world changes underneath it.
The Tricky Part: Actor Reentrancy
Here's where actors get spicy, every actor-isolated function is reentrant. What does that mean?
When an isolated function suspends—because its body hits an await—the actor hands off its serial executor. And the moment it does another call into that same actor can start running while the first call sits there, suspended, waiting to resume.
In Swift, this is called interleaving.
And yep—this means an actor's state can change between two awaits inside the same function. If you're not careful, that can quietly corrupt your invariants, picture the classic trap:
actor BankAccount {
var balance: Double = 100
func withdraw(_ amount: Double) async -> Bool {
guard balance >= amount else { return false }
// 😬 we suspend here... and another withdraw can sneak in
await someAsyncAuditLog(amount)
// by the time we resume, `balance` may NOT be what we checked above
balance -= amount
return true
}
}
You checked balance before the await, but you mutated it after. Between those two lines, the world moved on. Two concurrent withdrawals can both pass the guard and both deduct—hello, overdraft. 😅
So you might be wondering: why on earth would Swift design actors this way? And honestly? Thank god it did.
Actors were made reentrant on purpose, because reentrancy eliminates a whole class of deadlocks, if an actor blocked entirely while waiting on an await, two actors waiting on each other would lock up forever. Reentrancy makes that impossible.
On top of that, it improves overall performance and lets the runtime schedule higher-priority work instead of stalling behind a suspended call. Net result: reentrant is the right default.
The catch is on you to protect your invariants across suspension points. A few ways to do that:
- Re-check your assumptions after every
await—never trust that state survived the suspension. - Reach for a tool like AsyncGate when you genuinely need a critical section to stay mutually exclusive across awaits.
And for the curious: SE-0306's Future Directions section actually floats the idea of a future annotation to opt specific actors out of reentrancy—but it was never shipped, so for now, reentrancy is just how actors work.
Bottom line: be careful here.
Falling into the reentrancy trap without understanding it is one of the most confusing bugs you'll ever chase, precisely because the code looks correct.
Conclusion
Actors are a genuinely great tool—they hand you shared mutable state with compiler-enforced data-race protection, something that used to demand fragile, hand-written synchronization and a lot of luck.
Use them with caution. Respect the isolation boundary, never assume state survives an await, and remember that the order calls arrive is not the order they run.
Get those three things wrong and you'll hit a wall of baffling bugs with no obvious cause, get them right and actors quietly do the hard part for you.
That's the whole journey—from "actors were a mystery to me" to actually understanding the machinery underneath.
And like I said at the start: if I got anything wrong here, please reach out and correct me. Knowledge is worthless sitting in one head.
If this helped actors click for you, subscribe—I've got more Swift concurrency deep-dives coming. ⚡️