Why Swift Actors Need Better Documentation
The Hidden Complexity of Swift Actors
When Swift 5.5 shipped with actors, the pitch was irresistible: a language-level way to banish data races. Mark a class as an actor, and the compiler would guarantee thread-safe access to its mutable state. No more manual locks. No more sleepless nights debugging non-deterministic crashes. For a while, it felt like magic.
But magic tricks have secrets, and actors have sharp edges the manual barely mentions. Yuki Tanaka, a senior iOS engineer at a Tokyo fintech firm, learned this the hard way while refactoring a payment processing module. The team replaced a brittle lock-based queue with an actor, expecting cleaner code and fewer headaches. They got the cleaner code. The headaches just changed shape.
What the Official Docs Get Right
Let’s be fair. Apple’s Swift Programming Language book does a solid job introducing actors. It explains actor isolation, the await keyword for crossing actor boundaries, and how the compiler stops you from touching mutable state from the outside. For a textbook example—say, a simple counter or an image cache—the documentation is enough. You can copy the snippet, run it, and feel the warm glow of thread safety.
The trouble starts when you move beyond the textbook.
Protocols and Actors: The Missing Manual
One of the first walls Yuki’s team hit was protocol conformance. They had a perfectly good protocol for payment processing, and they wanted the new actor to adopt it. Straightforward, right? Not quite. The compiler threw errors that sent them searching through Swift Evolution proposals and obscure forum threads.
The issue: when an actor conforms to a protocol, the protocol’s methods need to respect actor isolation. If a method accesses mutable actor state, it must be marked async—even if the caller is already inside the actor. The documentation mentions this in passing, but doesn’t explain the why or the workarounds. You can mark a method nonisolated if it only reads immutable state, but the rules around what counts as “immutable” when actors contain reference types are fuzzy. The team spent hours experimenting before they understood the boundaries.
Reentrancy: The Silent State-Changer
Reentrancy is the actor feature that most often catches developers off guard. Here’s the scenario: an actor method hits an await and suspends. While it’s suspended, another task can enter the actor and modify its state. When the original method resumes, the world may have changed underneath it.
This isn’t a bug—it’s a deliberate design choice to avoid deadlocks. But the official guide treats it as a footnote. Yuki’s team discovered it the hard way. Their batch-processing method called processNext() in a loop, awaiting each call. Between iterations, other tasks snuck in and mutated the queue. Payments got processed twice. Balances went negative. The fix was to grab all pending payments in one synchronous sweep before any suspension points, then process them outside the actor’s isolated context. A simple pattern, but one that’s invisible unless you already know about reentrancy.
Testing: Where Actors Get Truly Opaque
If reentrancy is a trapdoor, testing is a fog bank. The documentation offers almost nothing on how to reliably test actor-based code. XCTest expectations work for simple async calls, but actors introduce timing dependencies that make tests flaky. You assert that an actor’s state has changed, but the change might not have happened yet because the actor’s serial executor hasn’t processed your request.
Yuki’s team resorted to sprinkling await Task.yield() throughout their tests to force the actor to process pending work. It felt hacky, and it was. A dedicated section on testing patterns—using expectations with predicted ordering, or custom executors for deterministic scheduling—would save teams from reinventing these wheels. Even a simple rule of thumb would help: after any call that mutates actor state, await a read of that state before asserting.
Global Actors and Sendable: The Foggy Frontier
Global actors like @MainActor are supposed to simplify UI-bound concurrency. And for basic cases, they do. But mix global actors with custom actors, and the compiler’s Sendable checks become a puzzle. When is a closure implicitly @Sendable? What happens when you pass a non-Sendable value from a global actor to a custom actor? The documentation gestures at the answers but doesn’t walk you through the reasoning.
The team ran into this when a @MainActor view model needed to hand off data to a background actor. The data was a struct containing a closure—not @Sendable by default. The compiler error was a wall of text. The solution, marking the closure @Sendable and auditing captured values, required stitching together three separate documents. A unified guide on isolation boundaries would have cut the debugging time in half.
Structured Concurrency Inside Actors
Using task groups or async let inside an actor adds another dimension. Child tasks inherit the actor’s isolation, so they can touch actor state synchronously—convenient, but dangerous if you’re not careful. Detach a child task, and it loses that inheritance. Pass data to a non-isolated context, and Sendable rules kick in.
The payment module needed to process multiple transactions in parallel. The team used a task group inside the actor, but each child task needed read access to a large configuration object. Marking the config as Sendable was easy. Ensuring the child tasks didn’t accidentally capture mutable actor state required line-by-line review. A documentation pattern—here’s how to safely fan out work inside an actor, here’s what can go wrong—would have prevented several review cycles.
What Better Docs Would Actually Look Like
Good documentation for actors needs layers. The current guide is a solid first layer: syntax and basic concepts. A second layer should tackle the patterns that trip up real teams: protocol conformance, reentrancy, testing strategies, and Sendable integration. Each topic deserves annotated code samples and, just as importantly, counterexamples showing the wrong way and explaining why it fails.
A third layer would cover advanced ground: custom executors, actor inheritance, and performance trade-offs. But most developers don’t need layer three. They need layer two, and it’s missing.
Every section should include a “Common Mistakes” box. For reentrancy, show the batch-processing bug, step through the suspension interleaving, and present the corrected code. For testing, demonstrate the premature assertion problem and show how to properly await state changes. Diagrams would help enormously—a sequence diagram of task interleaving at suspension points could prevent hours of confusion. A flowchart for choosing between nonisolated, async, and synchronous methods would clarify design decisions that currently rely on gut feeling.
FAQ
Why does my actor method need to be async even if it doesn’t use await?
If the method touches isolated state and is called from outside the actor, the caller must hop to the actor’s serial executor. The compiler enforces this by requiring async on the method signature. Inside the actor, you can call synchronous methods that access isolated state without awaiting—the executor guarantees you’re already there.
What is actor reentrancy and why should I care?
Reentrancy means that when an async actor method suspends at an await, the actor’s executor is free to run other tasks. Those tasks can mutate state before the original method wakes up. This prevents deadlocks, but it means you must re-validate any assumptions after every suspension point. Never assume the state is the same as it was before the await.
How do I test actor code without flaky tests?
Always await a state-reading call before asserting. For complex scenarios, use MainActor.run to serialize UI-bound work, or inject a custom executor so you can control scheduling deterministically. Avoid mixing unstructured concurrency (like DispatchQueue) with actors in tests—it makes ordering unpredictable.
When should I use a global actor versus a custom actor?
Use @MainActor for state that must update on the main thread—UI models, view state. Use custom actors for background work that needs isolation but not main-thread affinity. You can define your own global actor with @globalActor if multiple types need to share a single serial executor.
Closing the Gap
Swift actors are a genuinely good tool. They eliminate entire categories of concurrency bugs. But their documentation hasn’t kept up with their adoption, and the gap between the introductory material and production reality is wide enough to cause real pain. The core team’s focus on language evolution is understandable, but the community would benefit enormously from a dedicated effort to expand the official guide.
Until that happens, developers will keep piecing together knowledge from Swift Evolution proposals, WWDC sessions, and hard-won experience. Sharing those lessons—as this article tries to do—helps bridge the gap, but it’s no substitute for authoritative, comprehensive documentation. Yuki’s team eventually shipped their refactored payment module with confidence, but only after internalizing actor behavior through extensive code review and testing. That process shouldn’t require detective work. Better documentation would make actors accessible not just to early adopters, but to every Swift developer building concurrent, safe, and maintainable applications.


