Concurrency Deep Dive

Race Conditions: When Threads Collide

January 15, 2026
4 min read
Featured image for blog post: Race Conditions: When Threads Collide

You add 50 items to a shopping cart. You remove 25. You should have 25 left.

Run it again. Now you have 23. Run it again—27. Run it a third time and the app crashes with NSInvalidArgumentException. Welcome to race conditions.

Follow along with the code: iOS-Practice on GitHub

The Bug

Here's a shopping cart class that looks perfectly reasonable:

class ShoppingCart {
    private var items: [String: Int] = [:]
    private var totalPrice: Double = 0

    func addItem(_ name: String, price: Double, quantity: Int = 1) {
        if let existing = items[name] {
            items[name] = existing + quantity
        } else {
            items[name] = quantity
        }
        totalPrice += price * Double(quantity)
    }

    func removeItem(_ name: String, price: Double) {
        if let quantity = items[name] {
            if quantity > 1 {
                items[name] = quantity - 1
            } else {
                items.removeValue(forKey: name)
            }
            totalPrice -= price
        }
    }
}

And here's code that uses it from multiple threads:

let concurrentQueue = DispatchQueue(label: "cart.concurrent", attributes: .concurrent)

for i in 0..<50 {
    concurrentQueue.async {
        cart.addItem(product.name, price: product.price)
    }
}

for i in 0..<25 {
    concurrentQueue.async {
        cart.removeItem(product.name, price: product.price)
    }
}

Why It Breaks

The problem is the read-modify-write pattern:

if let existing = items[name] {    // READ
    items[name] = existing + quantity  // WRITE
}

Between the read and write, another thread can modify items[name]. Thread A reads 5, Thread B reads 5, both write 6. You just lost an item.

Same with totalPrice += price. That's not atomic—it reads, adds, then writes. Two threads can read the same value and both write their own result, losing one addition.

Worse, Swift's Dictionary isn't thread-safe at all. Concurrent mutations can corrupt its internal storage, causing crashes like unrecognized selector sent to instance—the dictionary's memory has been scrambled.

The Fix

Synchronize access to shared state:

class ShoppingCart {
    private var items: [String: Int] = [:]
    private var totalPrice: Double = 0
    private let queue = DispatchQueue(label: "cart.serial")

    func addItem(_ name: String, price: Double, quantity: Int = 1) {
        queue.sync {
            if let existing = items[name] {
                items[name] = existing + quantity
            } else {
                items[name] = quantity
            }
            totalPrice += price * Double(quantity)
        }
    }

    func removeItem(_ name: String, price: Double) {
        queue.sync {
            if let quantity = items[name] {
                if quantity > 1 {
                    items[name] = quantity - 1
                } else {
                    items.removeValue(forKey: name)
                }
                totalPrice -= price
            }
        }
    }
}

A serial queue ensures only one thread can access the data at a time. The operations become atomic.

Interview Tip

When reviewing code for race conditions, ask:

  • Is this state accessed from multiple threads?
  • Are there read-modify-write operations?
  • What's protecting the critical section?

If there's no synchronization mechanism (queue, lock, actor), there's probably a bug.

What About Actors?

Swift actors solve this problem by design—all access to an actor's state is automatically serialized. We'll cover thread-safe collections and actors in the next post.

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