The first time actor reentrancy really clicked for me, I was looking at two identical token-refresh requests in the network log. I had moved the token store into an actor, the compiler was happy, and I was convinced duplicate refreshes were no longer possible. Yet there they were, starting only milliseconds apart.
My first reaction was to blame the caller. After adding request IDs and a short artificial delay, I found the real problem inside the actor method itself. One task checked the token, suspended at await, and another task entered before the first one came back. Nothing was racing at the memory level. My assumption about the whole method being “locked” was simply wrong.
This is the explanation I wish I had when I started using actors. I will use the small token example that exposed the bug, then show the two fixes I now reach for in real code.
The Assumption That Sent Me in the Wrong Direction
An actor owns its isolated properties. Code outside the actor must use await to interact with isolated methods or properties, and the compiler rejects many unsafe cross-actor accesses. Within a synchronous portion of an actor method, only one task executes on that actor at a time. That removes the ordinary lock-level data race where two threads simultaneously mutate the same memory.
However, an asynchronous actor method can be split into several executable fragments. The fragment before an await runs, then the method may suspend. During suspension, the actor is available to process other queued work. When the original operation resumes, the actor’s state may differ from the state it observed earlier. This behavior is called actor reentrancy.
Reentrancy is valuable. If actors stayed blocked while waiting for a slow server, one suspended request could freeze every unrelated operation on that actor. The trade-off is that values read before suspension must be treated as snapshots, not permanent facts.
The Tiny Test That Exposed the Duplicate Request
Consider a token store that refreshes an expired access token. The code looks reasonable because all mutable properties are isolated inside an actor:
actor TokenStore {
private var token: Token?
func validToken() async throws -> Token {
if let token, !token.isExpired {
return token
}
let refreshed = try await api.refreshToken()
token = refreshed
return refreshed
}
}
Now start two tasks almost simultaneously when the stored token is expired. Task A checks the property and begins refreshToken(). At that await, Task A leaves the actor. Task B enters, sees the same expired state, and starts a second refresh. The actor protected the property from simultaneous memory access, but it did not combine “check” and “refresh” into an atomic transaction.
You can reproduce the sequence by adding an artificial delay to the API and logging a request identifier. Two identifiers in the log demonstrate duplicate work. This test is more useful than relying on timing in a full application because the delay makes the interleaving deterministic enough to inspect.
What I Now Mark During Code Review
A reliable review habit is to draw a boundary around each await. List every actor property read before the boundary and ask whether the code assumes that value is still current afterward. If the post-await logic writes state, makes a decision, or emits an event based on the old value, the method deserves closer inspection.
The most dangerous pattern is “check, await, then commit.” Examples include checking available inventory before a reservation, confirming that a user is still logged in before fetching a profile, or recording the current generation of a cache before loading data. The compiler cannot reject these cases because each individual access is isolated and legal. The problem is semantic rather than a raw data race.
Not every snapshot is wrong. A method may intentionally operate on the value that existed when the request began. The important part is to make that decision explicit. Name snapshots clearly, document the intended semantics, and avoid presenting an old result as if it describes current actor state.
My Usual Fix: Keep the In-Flight Task
For work that should be shared, such as token refresh or image loading, store the task itself. Later callers can await the same task instead of launching duplicates:
actor TokenStore {
private var token: Token?
private var refreshTask: Task<Token, Error>?
func validToken() async throws -> Token {
if let token, !token.isExpired { return token }
if let refreshTask { return try await refreshTask.value }
let task = Task { try await api.refreshToken() }
refreshTask = task
do {
let value = try await task.value
token = value
refreshTask = nil
return value
} catch {
refreshTask = nil
throw error
}
}
}
The key state transition—installing refreshTask—happens before suspension. A reentrant caller sees that task and joins it. Both success and failure paths clear the property, preventing a failed task from becoming a permanent poisoned cache. In production code, also decide how cancellation should behave: one caller cancelling should not necessarily cancel shared work needed by other callers.
When Sharing the Task Is the Wrong Fix
Sometimes work cannot be shared. A search actor may launch a request for every query, but only the newest response should update the UI-facing result. Give each request a generation number, then verify that number after the await:
actor SearchModel {
private var generation = 0
private var results: [Item] = []
func search(_ query: String) async throws {
generation += 1
let requestGeneration = generation
let response = try await api.search(query)
guard requestGeneration == generation else { return }
results = response
}
}
This approach does not prevent older requests from finishing, but it prevents them from overwriting newer state. A UUID request identifier works as well. The general rule is to capture an identity before suspension and compare it with the current identity before committing.
Revalidation is also appropriate for authorization and configuration. If a permission, account, or selected environment can change while the request is running, confirm the relevant state again before applying the response.
Cancellation and Error Paths Are Part of the State Machine
Reentrancy fixes often focus only on the happy path. That is risky because cancellation and thrown errors may leave “loading” flags, task references, or reservations behind. Treat the actor as a small state machine. For every transition into an in-progress state, identify all exits: success, failure, cancellation, timeout, and replacement by a newer request.
A defer block can help with unconditional cleanup, but use it carefully when cleanup depends on identity. An old task should not clear a task reference that has already been replaced. Compare identifiers before changing shared state. Tests should cover two callers, a cancelled caller, an API error, and a newer request completing before an older one.
Also avoid assuming that Task.isCancelled automatically stops child operations. Cancellation is cooperative. Check cancellation at useful boundaries and ensure the underlying API responds to it when cancellation is part of the design.
The Short Checklist I Keep Beside the Code
- Mark every
awaitin an actor method as a reentrancy boundary. - Identify properties read before suspension and used after resumption.
- For duplicate work, store and share an in-flight
Task. - For last-request-wins behavior, use a generation or request identifier.
- Revalidate permissions, account identity, and configuration before committing.
- Test success, failure, cancellation, and out-of-order completion.
- Keep synchronous state transitions small and explicit.
The useful lesson for me was not “actors are unsafe.” It was that actors solve a narrower problem than I had imagined. I still use them heavily, but now I read every await as a place where the story can change. Since adopting that habit, duplicate-work bugs have become much easier to spot before they reach the network log.
Where I Verified the Behavior
The concurrency model and actor-isolation rules are documented in The Swift Programming Language: Concurrency. Apple’s Task documentation covers task values and cancellation. The examples here are intentionally minimal: add controlled delays and request identifiers in a test target to verify the interleavings in your own toolchain. Swift diagnostics and behavior can evolve, so treat the compiler version used by your project as the final check.
Download the Complete Runnable Project
The complete code for this article is available in its own GitHub repository: https://github.com/2252408699/swift-actor-reentrancy-demo.
Download it with git clone, then follow the repository README to build and run the example locally.
