When Swift introduced actors in Swift 5.5, many developers breathed a sigh of relief. Finally, a language-level construct that would protect us from data races! But here's the uncomfortable truth: actors don't eliminate data races—they just change where they can occur.
The culprit? Re-entrancy.
💡
Article written by AI under human supervision & guidance.
The False Sense of Security
Consider this innocent-looking actor:
actor BankAccount {
private var balance: Double = 1000
func withdraw(_ amount: Double) async -> Bool {
guard balance >= amount else {
return false
}
await performAsyncValidation()
balance -= amount
return true
}
private func performAsyncValidation() async {
try? await Task.sleep(for: .milliseconds(100))
}
}
Looks safe, right? The actor should serialize access to balance. Let's test it:
let account = BankAccount()
async let withdrawal1 = account.withdraw(600)
async let withdrawal2 = account.withdraw(600)
let results = await [withdrawal1, withdrawal2]
print(results)
print(await account.getBalance())
Both withdrawals succeeded, and we've gone negative. This is a data race.
Why Does This Happen? The Mailbox Model
Actors only do one thing at a time—that's true. But actors also don't like to sit around doing nothing. Think of an actor as having a "mailbox" of pending work. When you call an actor method, you're dropping a message into that mailbox.
Here's the key insight: when an actor method hits an await, it suspends—and the actor immediately picks up the next message from its mailbox rather than waiting idle.
Here's the sequence that leads to our bug:
withdraw(600) #1 starts, checks balance >= 600 ✅ (balance is 1000)
withdraw(600) #1 hits await performAsyncValidation() and suspends
Actor picks up next message: withdraw(600) #2 starts (re-entrancy!)
#2 checks balance >= 600 ✅ (balance is still 1000—#1 hasn't modified it yet)
#2 hits await and suspends
#1 resumes, deducts 600 → balance is now 400
#2 resumes, deducts 600 → balance is now -200 💥
The guard check and the mutation are not atomic across the await boundary. The actor only guarantees that synchronouscode blocks don't interleave.
Two Flavors of Re-Entrancy Problems
Re-entrancy can cause two distinct types of issues:
Problem 1: Incorrect State (Data Corruption)
This is what we saw above—multiple operations interleave and corrupt shared state, leading to invalid results like a negative bank balance.
Problem 2: Wasteful Duplicate Work
Even when state doesn't get corrupted, re-entrancy can cause unnecessary work. Consider a cache that fetches from a remote server:
actor DataCache {
private var cache: [UUID: Data] = [:]
func read(_ key: UUID) async -> Data? {
if let data = cache[key] {
return data
}
guard let data = try? await fetchFromServer(key) else {
return nil
}
cache[key] = data
return data
}
}
When multiple concurrent reads request the same uncached key:
cache read called for DDFA2377-...
attempt to read remote cache for DDFA2377-...
cache read called for DDFA2377-... // Re-entrancy!
attempt to read remote cache for DDFA2377-... // Duplicate request!
cache read called for DDFA2377-... // Re-entrancy again!
attempt to read remote cache for DDFA2377-... // Another duplicate!
We made three network requests when one would have sufficed. The first request would have cached the result for the others—but they all started before any completed.
Now let's look at strategies to handle both types of problems.
Strategy 1: Bounce Concurrent Requests
The simplest approach: if an operation is already in progress, reject new requests immediately.
actor BankAccount {
private var balance: Double = 1000
private var isWithdrawing = false
enum WithdrawError: Error {
case operationInProgress
case insufficientFunds
}
func withdraw(_ amount: Double) async throws -> Double {
guard !isWithdrawing else {
throw WithdrawError.operationInProgress
}
isWithdrawing = true
defer { isWithdrawing = false }
guard balance >= amount else {
throw WithdrawError.insufficientFunds
}
await performAsyncValidation()
balance -= amount
return balance
}
}
When to use: Idempotent operations, UI button debouncing, preventing duplicate submissions.
Pros: Simple, explicit, fast failure.
Cons: Callers must handle rejection and potentially retry.
Strategy 2: Queue and Await Previous Operations
Instead of rejecting concurrent requests, queue them up and process them serially.
actor BankAccount {
private var balance: Double = 1000
private var pendingOperations: [CheckedContinuation<Void, Never>] = []
private var isOperationInProgress = false
private func acquireLock() async {
if isOperationInProgress {
await withCheckedContinuation { continuation in
pendingOperations.append(continuation)
}
}
isOperationInProgress = true
}
private func releaseLock() {
if let next = pendingOperations.first {
pendingOperations.removeFirst()
next.resume()
} else {
isOperationInProgress = false
}
}
func withdraw(_ amount: Double) async -> Result<Double, WithdrawError> {
await acquireLock()
defer { releaseLock() }
guard balance >= amount else {
return .failure(.insufficientFunds)
}
await performAsyncValidation()
balance -= amount
return .success(balance)
}
}
When to use: When all requests must eventually be processed, order matters, or you need transactional semantics.
Pros: No requests are dropped; guaranteed serialization.
Cons: Increased latency for queued requests; potential for queue buildup.
Strategy 3: Optimistic Execution with Rollback
Sometimes you want to proceed optimistically and verify/rollback if conditions changed during the async operation.
actor BankAccount {
private var balance: Double = 1000
private var transactionLog: [UUID: Double] = [:]
func withdraw(_ amount: Double) async -> Result<Double, WithdrawError> {
guard balance >= amount else {
return .failure(.insufficientFunds)
}
let transactionId = UUID()
let balanceBefore = balance
balance -= amount
transactionLog[transactionId] = amount
let validationPassed = await performAsyncValidation()
let stateCorrupted = balance < 0
let validationFailed = !validationPassed
if stateCorrupted || validationFailed {
if let reserved = transactionLog.removeValue(forKey: transactionId) {
balance += reserved
}
return .failure(validationFailed ? .validationFailed : .insufficientFunds)
}
transactionLog.removeValue(forKey: transactionId)
return .success(balance)
}
}
When to use: When async operations are expensive and you want to maximize throughput; when rollback is cheap.
Pros: Maximum concurrency, no blocking.
Cons: Rollback logic can be complex; may waste work on rolled-back transactions.
Strategy 4: Re-validate After Await
A simpler variant of Strategy 3—just re-check your preconditions after awaiting:
actor BankAccount {
private var balance: Double = 1000
func withdraw(_ amount: Double) async -> Result<Double, WithdrawError> {
guard balance >= amount else {
return .failure(.insufficientFunds)
}
let balanceSnapshot = balance
await performAsyncValidation()
guard balance == balanceSnapshot else {
return .failure(.stateChanged)
}
guard balance >= amount else {
return .failure(.insufficientFunds)
}
balance -= amount
return .success(balance)
}
}
When to use: When you can afford to fail and have the caller retry.
Pros: Very simple; no complex state tracking.
Cons: May require retry logic; can fail even when it theoretically could have succeeded.
Strategy 5: Coalesce with In-Progress Task Tracking
This elegant pattern solves the "duplicate work" problem by tracking in-flight operations. Subsequent requests for the same resource await the existing task rather than starting a new one.
The key insight is to store the task itself (not just a boolean flag) so concurrent callers can await the same result:
actor DataCache {
enum CacheEntry {
case inProgress(Task<Data?, Error>)
case loaded(Data)
}
private var cache: [UUID: CacheEntry] = [:]
func read(_ key: UUID) async -> Data? {
if case let .loaded(data) = cache[key] {
return data
}
if case let .inProgress(task) = cache[key] {
return try? await task.value
}
let task: Task<Data?, Error> = Task {
try await fetchFromServer(key)
}
cache[key] = .inProgress(task)
if let data = try? await task.value {
cache[key] = .loaded(data)
return data
} else {
cache[key] = nil
return nil
}
}
}
Now concurrent reads coalesce into a single network request:
cache read called for DDFA2377-...
cache read called for DDFA2377-... // Sees .inProgress, awaits same task
cache read called for DDFA2377-... // Sees .inProgress, awaits same task
attempt to read remote cache for DDFA2377-... // Only ONE network call!
remote cache HIT for DDFA2377-...
cache read finished for DDFA2377-...
cache read finished for DDFA2377-...
cache read finished for DDFA2377-...
When to use: Caching, token refresh flows, any idempotent fetch where duplicate requests are wasteful.
Pros: Eliminates duplicate work; all callers get the same result; elegant state machine.
Cons: Slightly more complex; requires thinking about task lifecycle.
This pattern is explained in depth in Donny Wals' excellent article on actor re-entrancy, which also covers practical applications like token refresh flows and image loaders.
Strategy 6: Immutable State + Version (Compare-and-Swap)
Instead of mutating state, use a version number to detect concurrent modifications:
actor BankAccount {
private var state: AccountState
struct AccountState: Sendable {
let balance: Double
let version: Int
}
init(balance: Double) {
self.state = AccountState(balance: balance, version: 0)
}
func withdraw(_ amount: Double) async -> Result<Double, WithdrawError> {
let snapshot = state
guard snapshot.balance >= amount else {
return .failure(.insufficientFunds)
}
await performAsyncValidation()
guard state.version == snapshot.version else {
return .failure(.stateChanged)
}
state = AccountState(
balance: snapshot.balance - amount,
version: snapshot.version + 1
)
return .success(state.balance)
}
}
When to use: Complex state objects; when you want clear semantics about what constitutes a "change."
Pros: Clear state transitions; easy to debug; works well with audit trails.
Cons: Requires immutable state design; version conflicts need retry logic.
Choosing the Right Strategy
Bounce → Debouncing, idempotent ops • Low complexity • High throughput (fails fast)
Queue → Ordered processing, transactions • Medium complexity • Low throughput (serialized)
Optimistic + Rollback → High-throughput systems, cheap rollback • High complexity • High throughput
Re-validate → Simple operations, retriable • Low complexity • Medium throughput
Coalesce (Task Tracking) → Caching, deduplication • Medium complexity • High throughput
Immutable + Version → Complex state, audit trails • Medium complexity • Medium throughput
Key Takeaways
Actors protect synchronous access, not sequences of operations spanning await.
Think "mailbox": when your method suspends, the actor immediately processes the next queued message.
Two problems to watch for: incorrect state (data corruption) and wasteful duplicate work.
The golden question: Every time you write await inside an actor, ask yourself: "What assumptions have I made about state before this await that I need to re-verify after?"
Choose your strategy based on your use case: correctness vs. throughput vs. simplicity.
Test concurrent access explicitly—create stress tests that hammer your actors from multiple tasks simultaneously.
The next time you write an actor method with an await, pause and ask yourself: "What could change while I'm suspended?" Your future self will thank you.
For more on this topic, I highly recommend Donny Wals' deep dive on actor re-entrancy, which includes practical examples like building token refresh flows and async image loaders.
What patterns have you found useful for managing actor re-entrancy? Share your experiences in the comments!
#Swift #iOSDevelopment #Concurrency #SwiftConcurrency #Programming #SoftwareEngineering