Writing

Race conditions on a live trading app: locks, queues, barriers, actors

I joined Coinlocally's iOS team when crash-free sessions were stuck at 85%. The instinct, looking at a stack trace, is to find the one bad line and fix it. What I actually found was worse and more interesting: the futures screen, the spot screen, and the home screen each maintained their own order-book and price-list state, each with its own synchronization, written independently by whoever touched that screen last. Fixing a race condition on one screen proved nothing about the identical class of bug sitting on the other two. This is an account of the techniques I moved through to make that state safe, in the order I actually reached for them, and the one subtle mistake that made me stop trusting code that merely looked protected.

The blunt instrument: a lock

NSLock and NSRecursiveLock are the first tool anyone reaches for, and they're not wrong to. A lock around a mutation is easy to read, easy to audit, and easy to get right in isolation:

final class ThrottleState {
    private let lock = NSLock()
    private var lastFired: Date?

    func shouldFire(minimumInterval: TimeInterval) -> Bool {
        lock.lock()
        defer { lock.unlock() }
        let now = Date()
        if let last = lastFired, now.timeIntervalSince(last) < minimumInterval {
            return false
        }
        lastFired = now
        return true
    }
}

This is a real, honest tool for small, self-contained state, a throttle, a counter, a flag. Its weakness shows up at scale: every type that needs protecting gets its own lock, with no shared discipline about ordering or scope, and nothing stops two different screens from each declaring a lock for what is conceptually the same resource. That's exactly what had happened here. It wasn't one missing lock; it was three, none of them aware the others existed.

Funnel everything through one queue

The next step up is a serial DispatchQueue: instead of locking around each access, every read and write is dispatched onto the same queue, so GCD serializes them for you.

private let stateQueue = DispatchQueue(label: "com.exchange.orderBook")
private var book: OrderBook = .empty

func update(with delta: OrderBookDelta) {
    stateQueue.async {
        self.book.apply(delta)
    }
}

func snapshot(_ completion: @escaping (OrderBook) -> Void) {
    stateQueue.async {
        completion(self.book)
    }
}

This reads clean and is genuinely safe. Its cost is throughput: a high-frequency order book can receive many updates a second, and every read waits in the same line behind every write, even though reads don't conflict with each other, only with writes. On a screen where a user is watching the book update in real time, that queuing is a real, felt cost, not a theoretical one.

The reader/writer pattern: concurrent queue, barrier writes

The fix that actually shipped first was the classic GCD reader/writer pattern: a concurrent queue, where reads run in parallel with each other, and writes use .barrier to get exclusive access when they need it.

private let bidsQueue = DispatchQueue(
    label: "com.exchange.orderBook.bids",
    attributes: .concurrent
)
private var bids: [PriceLevel] = []

func write(_ newBids: [PriceLevel]) {
    bidsQueue.async(flags: .barrier) {
        self.bids = newBids
    }
}

func read() -> [PriceLevel] {
    bidsQueue.sync {
        self.bids
    }
}

This closed the worst of the crashes immediately, and it's a legitimate, durable pattern, not just a stopgap. But it has a sharp edge that I found the hard way, going screen by screen through the codebase: .barrier only does anything on a queue that's actually declared .concurrent. A queue created without that attribute defaults to serial, and .async(flags: .barrier) on a serial queue is a no-op flag, it behaves exactly like a plain serial write. One of the three duplicated implementations had gotten this exactly backward: the bids queue was concurrent and correctly barriered, but the adjacent asks and positions queues were declared serial with a barrier flag sitting on them doing nothing. The code read as though all three were protected the same way. Only one of them was.

A queue that looks barrier-protected and a queue that is barrier-protected are indistinguishable by reading the call site. You have to check the declaration.

That's the real lesson of this technique: it's correct, but it's correct only if you verify the attribute on every queue, every time, because the failure mode is silent. Nothing crashes when a barrier is a no-op. The state is just quietly less safe than it looks.

Consolidate before you modernize

Before reaching for anything newer, the highest-value fix wasn't a better primitive, it was removing the duplication. Three screens each protecting their own copy of conceptually the same state meant three places for this exact mistake to hide, and finding it on one screen said nothing about the other two. Pulling futures, spot, and home market-data state into a single store, with one queue, one barrier policy, and one set of call sites, turned an audit of three inconsistent implementations into an audit of one. This is the step that's easy to skip because it doesn't feel like "real" concurrency work, and it mattered more than any single primitive.

Swift actors, once you have the right target

Once the state lived in one place, moving it into a Swift actor was the natural next step, a language-level guarantee replacing a hand-maintained one:

actor MarketDataStore {
    private var book: OrderBook = .empty

    func apply(_ delta: OrderBookDelta) {
        book.apply(delta)
    }

    func snapshot() -> OrderBook {
        book
    }
}

Here's the part worth being honest about: this codebase had already adopted an actor once before I got to the order book. It isolated a small buffer used while paginating open orders, real, correct, and nowhere near the screens that were actually crashing. Adopting Swift's concurrency model had happened; it just hadn't happened to the problem. An actor in your codebase is not the same claim as an actor on your bottleneck, and the git history made that gap obvious once you knew where to look: the first actor arrived months before the one that mattered, on state nobody was crashing over.

Actors bring their own new failure mode once adopted for real: reentrancy. An actor's methods can suspend at every await, and another call can interleave during that suspension. A snapshot-then-mutate sequence that looked atomic in the old locked version needs re-checking against that possibility, not assumed to carry over for free. That's a different article's worth of detail; the point here is narrower, that actors are the right tool once you know what state actually needs them, and not a substitute for finding out.

The order I'd use again

Locks for small, self-contained state that one type owns outright. A serial queue when correctness matters more than throughput and the access pattern is genuinely light. A concurrent queue with barrier writes when reads are frequent, writes are less so, and you're willing to verify every queue's attributes by hand. An actor once you've found the state that's actually contended, ideally after consolidating duplicated copies of it into one place, so you're modernizing one thing instead of three.

None of these techniques failed here. What failed was assuming that three independent implementations of the same idea were three independent guarantees of safety. They weren't. They were three chances for the same subtle mistake to hide, and it only took hiding in one of them for crash-free sessions to sit at 85% for longer than they should have.