Opening

Here is a simple problem. You have a variable called counter, it starts at zero, and you have two threads. Each thread increments counter one thousand times. So the final value should be two thousand, right? Run this code and you will get something like one thousand seven hundred and forty-three. Or one thousand eight hundred and twelve. Or a different wrong number every single time. This is a race condition, and it is one of the most common and insidious bugs in concurrent programming. Today we are going to understand exactly why this happens, visualize the moment things go wrong, see it in real Kotlin code, and learn about the tools that fix it. If you have ever had a crash in production that you could not reproduce locally, there is a very good chance a race condition was to blame.

The Increment Is Not One Step

Let us start by understanding why counter plus plus is not as simple as it looks. When you write counter++, it looks like one operation. But it is not. The CPU has to do three things. First, read the current value of counter from memory into a register. Second, add one to that value in the register. Third, write the new value back to memory. This is called a read-modify-write operation, and the critical thing is that it is not atomic. Atomic means indivisible, something that happens as a single unit with no possibility of interruption. But a read-modify-write is three separate steps, and between any two of those steps, the kernel can interrupt your thread and switch to another one. This tiny gap between the read and the write is where race conditions live.

The Interleaving Problem

Let me show you exactly what goes wrong. Imagine counter is currently ten. Thread A reads counter and gets ten. Now, before thread A can add one and write eleven back, the scheduler interrupts it and switches to thread B. Thread B reads counter and also gets ten, because thread A has not written its new value yet. Thread B adds one, gets eleven, and writes eleven to memory. The scheduler switches back to thread A. Thread A adds one to the ten it read earlier, gets eleven, and writes eleven to memory. Both threads did an increment. The counter went from ten to eleven instead of ten to twelve. We lost an update. One increment simply vanished. This is the interleaving problem. When the steps of two operations can interleave, like shuffling two decks of cards together, the result depends on the specific timing of when each step happens. And that timing is controlled by the kernel's scheduler, which is effectively random from your code's perspective.

Seeing It in Kotlin

Let us see this in real code. Here is a Kotlin coroutine example that demonstrates the problem. We declare a variable counter equal to zero. Then we launch one hundred thousand coroutines, each of which simply increments counter. After they all finish, we print counter. If everything worked correctly, we would get one hundred thousand. But we do not. We get a different, smaller number every time we run it. The reason is exactly what we just described. Even though coroutines are lightweight and run on a thread pool, multiple coroutines can be running simultaneously on different threads. When two coroutines on two different threads both read counter at the same instant, both see the same value, both increment it, and both write back, one of those increments is lost. The bug is not obvious from reading the code. The code looks perfectly sequential and reasonable. But it is broken by concurrency.

Code Example: The Broken Counter

Here is what that code looks like. You create a coroutineScope, you set var counter to zero, you launch a hundred thousand coroutines using repeat, each one does counter++, and then you print counter. Simple, clean, and wrong. The output might be ninety-seven thousand two hundred and fourteen, or some other number less than a hundred thousand. Every run gives a different result. This is the hallmark of a race condition: non-deterministic behavior. Your code works sometimes and fails sometimes, depending on the exact timing of thread scheduling that particular run. And this is why race conditions are so hard to debug. They might not show up in your tests. They might not happen on your development device. But they will happen in production, on that one user's phone where the timing is just wrong enough.

This Happens in Real Android Code

Now you might be thinking, sure, that is a toy example, but does this happen in real Android apps? Absolutely. Here are three common patterns where I see race conditions in production Android code. First, multiple callbacks modifying the same list. You have an adapter backed by a mutableListOf. An API callback adds items to the list on a background thread. Meanwhile, the RecyclerView on the main thread is iterating through the same list to bind views. Crash: ConcurrentModificationException. Or worse, no crash but corrupted data. Second, loading state variables. You have a var isLoading boolean that gets set to true when a network request starts and false when it ends. If two requests happen concurrently, one might set it to false while the other is still loading, and your UI shows the wrong state. Third, caching. Your cache checks if a value exists, and if not, computes it and stores it. Two threads both check at the same time, both see the cache is empty, both compute the value, and both write. You have wasted work and might have stored inconsistent data.

Visualizing the Race

Let me give you a mental model for what a race condition looks like in time. Imagine a timeline with two lanes, one for thread A and one for thread B. Thread A starts reading the counter. Thread B starts reading the counter at almost the same time. Both reads complete and both see the same value. Thread A computes the new value. Thread B computes the new value. Thread A writes. Thread B writes. Thread B's write overwrites thread A's write. The increment from thread A is gone. It is like two people trying to edit the same Google Doc at the exact same time, but without Google's conflict resolution. One person's edit just disappears. The window where this can happen, the gap between the read and the write, is called the critical section. Any code where a race condition can occur is a critical section, and protecting critical sections is the fundamental challenge of concurrent programming.

Solution Preview: Synchronized

So how do we fix this? The first classic solution is mutual exclusion, often called a mutex. In Kotlin on the JVM, you use the synchronized keyword. Synchronized creates a lock around a block of code. Only one thread at a time can be inside a synchronized block that uses the same lock object. If thread A is inside the block, thread B has to wait until thread A finishes and releases the lock. This turns our interleaving problem into a sequential one. Thread A reads, modifies, writes, releases the lock. Then thread B reads, modifies, writes. No interleaving possible. The downside is performance. Threads that are waiting for the lock are blocked and doing nothing. If your synchronized block is large or contended, you can create a bottleneck where threads spend more time waiting than working.

Solution Preview: Atomics

The second solution is atomic operations. Remember how the problem is that read-modify-write is three steps? Atomic integers, like AtomicInteger in Java and Kotlin, provide an incrementAndGet method that performs all three steps as a single indivisible CPU instruction. No lock needed. No blocking. The CPU hardware guarantees that no other thread can see the value in a half-updated state. For simple counters and flags, atomics are the best solution. They are faster than synchronized because they do not involve locks or thread blocking. But they only work for simple operations. If you need to update two variables atomically, or perform a complex calculation, atomics alone cannot help.

Solution Preview: Confinement and Immutability

The third approach is to avoid shared mutable state entirely. If only one thread ever accesses a variable, there is no race condition. This is called thread confinement. In Android, the pattern of updating UI state only on the main thread is thread confinement. StateFlow and LiveData enforce this by dispatching updates to the main thread. Coroutines make confinement natural. You can confine mutable state to a single coroutine and use channels or flows to communicate with other coroutines. And then there is immutability. If a value cannot change, it is inherently safe to share across threads. This is why Kotlin encourages val over var. This is why data classes with val properties and immutable collections like listOf are the default idiom. When everything is immutable, race conditions become impossible. Not unlikely, not mitigated, impossible. That is the strongest guarantee you can get.

Recap and What is Next

Let us bring it together. A race condition occurs when two threads access shared mutable state and at least one of them is writing, without proper synchronization. The root cause is that operations like increment are not atomic: they are read-modify-write, three steps that can be interleaved. The results are non-deterministic, making these bugs incredibly hard to reproduce and debug. Solutions include synchronized blocks for mutual exclusion, atomic operations for simple counters and flags, thread confinement to limit who can access data, and immutability to eliminate the mutation entirely. In the next video, we are going to explore an even more subtle problem. Even if you fix all your race conditions, threads can still see stale data due to CPU caches and compiler optimizations. That is the Java Memory Model, and it is the topic of our next deep dive. See you there.