Swift

Swift Actor Reentrancy Explained: Why Protected State Can Still Become Stale

By Seren  |  26 Aug, 2026  |  Leave a comment


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 old value is a bug. In a photo export feature, I wanted the export to use the options selected when the job began, even if the user changed the screen while it was running. I named that value exportOptionsAtStart and returned the result to the matching job instead of treating it as the actor’s newest state. The name made the choice much easier to understand during review.

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 important change in my token store was assigning refreshTask before the first suspension. The second caller then found the existing task instead of opening another request. I also learned not to cancel that shared refresh when one screen disappeared; another request could still be waiting for the same token. Success and failure both clear the stored task so a failed refresh does not remain in the cache.

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
    }
}

The older search requests still finish, which was fine for this screen. What mattered was stopping an old response from replacing results for the query the user was currently viewing. I first used UUIDs, then changed to an integer because it was easier to read in logs. In both versions I captured the identifier before await and compared it again before assigning the response.

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

My first fix stopped duplicate requests but introduced a smaller bug: after a timeout, the actor still believed a refresh was running. Every later caller joined a task that had already failed. I fixed it by tracing each way the operation could end—success, error, cancellation, timeout, or replacement—and clearing only the matching in-flight entry on each path.

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 await in 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.

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.

Seren
Seren

A developer exploring iOS, Objective-C, Swift, and other tech stacks. Here to document my learning process, pitfalls, and growing journey across new technologies.

Your email address will not be published. Required fields are marked *