Concurrency Deep Dive

Actor Reentrancy: State Changes During Await

February 9, 2026
5 min read
Featured image for blog post: Actor Reentrancy: State Changes During Await

Actors protect your state from concurrent access. So how did the balance go negative?

Follow along with the code: iOS-Practice on GitHub

The Promise of Actors

Actors guarantee that only one piece of code accesses their state at a time. No data races. No locks to manage. Safe by default.

actor BankAccountActor {
    private var balance: Double

    init(initialBalance: Double) {
        self.balance = initialBalance
    }

    func withdraw(amount: Double) async -> Bool {
        guard balance >= amount else {
            return false
        }

        // Simulate fraud check
        await performFraudCheck(amount: amount)

        balance -= amount
        return true
    }

    private func performFraudCheck(amount: Double) async {
        try? await Task.sleep(nanoseconds: 500_000_000)
    }
}

Account starts with $100. Two concurrent withdrawals of $60 each. Only one should succeed, right?

let account = BankAccountActor(initialBalance: 100)

async let withdrawal1 = account.withdraw(amount: 60)
async let withdrawal2 = account.withdraw(amount: 60)

let results = await [withdrawal1, withdrawal2]
let finalBalance = await account.getBalance()
// Both succeed! Balance: -$20

Both withdrawals succeed. Balance goes negative. The actor didn't protect us.

Reentrancy: The Catch

When an actor method hits an await, it suspends. While suspended, another call can enter the actor. This is reentrancy.

Here's what happens:

  1. Withdrawal 1 enters, checks balance >= 60 ✓ (balance is 100)
  2. Withdrawal 1 hits await performFraudCheck(), suspends
  3. Withdrawal 2 enters, checks balance >= 60 ✓ (balance is still 100!)
  4. Withdrawal 2 hits await performFraudCheck(), suspends
  5. Both fraud checks complete
  6. Withdrawal 1 resumes, sets balance = 100 - 60 = 40
  7. Withdrawal 2 resumes, sets balance = 40 - 60 = -20

The guard checked balance >= amount before the await. By the time the method resumed, another caller had changed the balance. The assumption was invalidated.

The Mental Model

Think of await as a "yield point." Your actor method pauses, and other work can run. When you resume, any state you read before the await might have changed.

Actors guarantee no simultaneous access. They don't guarantee state stays constant across await points.

Pattern 1: Re-check After Await

Validate your assumptions again after each suspension point:

func withdraw(amount: Double) async -> Bool {
    guard balance >= amount else {
        return false
    }

    await performFraudCheck(amount: amount)

    // Re-check after await!
    guard balance >= amount else {
        return false  // Someone else withdrew while we waited
    }

    balance -= amount
    return true
}

Now withdrawal 2 sees the updated balance after resuming and fails correctly.

Pattern 2: Optimistic Locking

Capture state before await, verify it hasn't changed:

func withdraw(amount: Double) async -> Bool {
    let balanceBefore = balance

    guard balanceBefore >= amount else {
        return false
    }

    await performFraudCheck(amount: amount)

    // Verify balance unchanged
    guard balance == balanceBefore else {
        return false  // State changed during await
    }

    balance -= amount
    return true
}

This catches any modification, not just ones that violate the original guard.

Pattern 3: Reserve-Then-Commit

For inventory or limited resources, reserve before the async operation:

actor InventoryManager {
    private var stock: [String: Int] = ["iPhone": 10]
    private var reserved: [String: Int] = [:]

    func reserveItem(_ item: String, quantity: Int) async -> Bool {
        let available = (stock[item] ?? 0) - (reserved[item] ?? 0)

        guard available >= quantity else {
            return false
        }

        // Reserve immediately, before async work
        reserved[item, default: 0] += quantity

        let approved = await checkExternalInventory(item: item)

        if approved {
            // Commit: move from reserved to actually sold
            stock[item]! -= quantity
            reserved[item]! -= quantity
            return true
        } else {
            // Rollback reservation
            reserved[item]! -= quantity
            return false
        }
    }
}

The reservation happens synchronously, before any await. Other callers see the reserved quantity immediately.

The Inventory Bug

The exercise code has the same problem:

func reserveItem(_ item: String, quantity: Int) async -> Bool {
    guard let available = stock[item], available >= quantity else {
        return false
    }

    let canReserve = await checkExternalInventory(item: item)

    guard canReserve else { return false }

    stock[item] = available - quantity  // 💥 Uses stale 'available'!
    return true
}

Two concurrent reserves of 6 iPhones (stock: 10):

  1. Both check available >= 6
  2. Both await external check
  3. Both set stock = 10 - 6 = 4

Final stock: 4 (should be -2 or one should fail). Actually worse—both succeed but only 6 were deducted instead of 12.

The Rule

State can change across await points. Every time you resume from an await, re-validate any assumptions you made before suspending. Actors protect against simultaneous access, not against time passing.

Originally published on pixelper.com

© 2026 Christopher Moore / Dead Pixel Studio

Let's work together

Professional discovery, design, and complete technical coverage for your ideas

Get in touch