← Back to list

Concurrency Safety in Android: Race Conditions, Mutex, and UI State

What happens when two coroutines update the same state at the same time

Rituraj Sambherao · 2026-06-06 09:12 · 0 claps · 5.0 min read
#android #kotlin #kotlin-coroutines #android-development #mobile-architecture
Open on Medium ↗
Wiki topics: SAF · Safety & Alignment SOC · Sociology & Politics 📱 · Mobile Development 🏛️ · Architecture

Concurrency Safety in Android: Race Conditions, Mutex, and UI State

What happens when two coroutines update the same state at the same time

The Race Condition You Probably Have Right Now

Consider this code. Two coroutines updating a list of orders in a ViewModel:

class OrderViewModel : ViewModel() {
    private val _orders = MutableStateFlow<List<Order>>(emptyList())
    val orders: StateFlow<List<Order>> = _orders.asStateFlow()

fun addOrder(order: Order) {
        viewModelScope.launch {
            val current = _orders.value
            val updated = current + order
            _orders.value = updated
        }
    }
}

This looks fine. But call addOrder twice in rapid succession and you have a race condition.

Coroutine 1 reads the current list. Coroutine 2 reads the current list before Coroutine 1 has written its update. Both coroutines see the same original list. Both append their order to it. The last one to write wins. One order is silently lost.

This is a classic read-modify-write race condition. It does not happen every time. It depends on timing. That is what makes it dangerous. It passes tests, passes code review, and shows up in production under load or on slower devices.

What a Mutex Is and How It Works

A Mutex, short for mutual exclusion, is a lock that ensures only one coroutine can execute a block of code at a time. While one coroutine holds the lock, any other coroutine that tries to acquire it suspends and waits.

A real world parallel: a single key for a shared office. Only one person can have the key at a time. If someone else wants in they wait at the door until the key is returned. Nobody forces their way in. Nobody gets lost. Access is orderly.

In coroutines the Mutex works the same way. The withLock function acquires the lock, runs the block, and releases the lock when done. If another coroutine tries to acquire the same lock while it is held, it suspends until the lock is free.

val mutex = Mutex()

mutex.withLock {
    // Only one coroutine can be here at a time
}

Critically, Mutex in Kotlin coroutines is not a blocking lock. It is a suspending lock. A waiting coroutine suspends rather than blocking its thread, which means it plays nicely with the coroutine scheduler and does not waste thread resources while waiting.

Mutex in Android: A Real Example

Applying Mutex to the race condition above:

class OrderViewModel : ViewModel() {
    private val _orders = MutableStateFlow<List<Order>>(emptyList())
    val orders: StateFlow<List<Order>> = _orders.asStateFlow()
    private val mutex = Mutex()

    fun addOrder(order: Order) {
        viewModelScope.launch {
            mutex.withLock {
                val current = _orders.value
                val updated = current + order
                _orders.value = updated
            }
        }
    }
}

Now when two coroutines call addOrder simultaneously, the second one suspends at withLock until the first has finished its read-modify-write sequence. Both orders are added correctly every time.

The Mutex instance is shared across all coroutines that need to protect the same state. One Mutex per shared resource, not one per coroutine call.

Keep the locked section as small as possible. Only the read-modify-write sequence needs protection. Network calls, database operations, and anything slow should happen outside the lock.

fun syncOrders() {
    viewModelScope.launch {
        val remoteOrders = withContext(Dispatchers.IO) {
            orderRepository.fetchOrders() // Outside the lock
        }
        mutex.withLock {
            _orders.value = remoteOrders // Only the write is locked
        }
    }
}

Atomic Operations and When They Are Enough

Not every concurrency problem needs a Mutex. For simple numeric counters and boolean flags, atomic operations are lighter and sufficient.

AtomicInteger and AtomicBoolean from the Java standard library provide thread-safe read and write operations without a lock:

class SyncManager {
    private val isSyncing = AtomicBoolean(false)

    fun startSync() {
        if (isSyncing.compareAndSet(false, true)) {
            viewModelScope.launch {
                try {
                    performSync()
                } finally {
                    isSyncing.set(false)
                }
            }
        }
    }
}

compareAndSet reads the current value and only sets the new value if the current value matches the expected one. This is an atomic operation meaning it cannot be interrupted between the read and the write. No two coroutines can both see false and both set it to true.

Use atomics for simple flags and counters. Use Mutex when you need to protect a multi-step read-modify-write sequence or any compound operation that must be treated as a single unit.

StateFlow and SharedFlow Under Concurrent Updates

StateFlow is thread-safe for individual reads and writes. Setting _state.value from multiple threads will not corrupt the StateFlow itself. But thread safety at the StateFlow level does not protect the read-modify-write pattern shown earlier. That is an application-level race condition and StateFlow does not solve it.

SharedFlow has similar characteristics. Emitting to a SharedFlow from multiple coroutines is safe. The ordering of emissions under high concurrency may not be deterministic, which matters if the order of events is significant to your UI logic.

For UI state that is derived from multiple sources updating concurrently, consider consolidating updates through a single coroutine using a channel or a flow operator rather than having multiple coroutines write directly to the same StateFlow:

class OrderViewModel : ViewModel() {
    private val updateChannel = Channel<OrderUpdate>(Channel.UNLIMITED)

    private val _orders = MutableStateFlow<List<Order>>(emptyList())
    val orders: StateFlow<List<Order>> = _orders.asStateFlow()

    init {
        viewModelScope.launch {
            for (update in updateChannel) {
                _orders.value = applyUpdate(_orders.value, update)
            }
        }
    }

    fun addOrder(order: Order) {
        updateChannel.trySend(OrderUpdate.Add(order))
    }
}

A single coroutine processes updates sequentially. No race condition is possible because only one coroutine ever writes to _orders. The channel buffers incoming updates so nothing is dropped.

Mutex in Action

In the first diagram both coroutines read the list at the same time before either has written their update. They both see the same original state. The second write overwrites the first and one item disappears silently.

In the second diagram Coroutine 2 cannot read until Coroutine 1 has finished its entire read-modify-write sequence and released the lock. By the time Coroutine 2 reads, the first update is already there. Both items make it into the final state.

The Mutex does not speed anything up. It makes the sequence of operations predictable. In concurrent code predictability is correctness.

The Practical Rule

Two questions determine what you need:

Is only one value being read or written in a single operation? An atomic is enough. Use AtomicBoolean or AtomicInteger.

Is the operation a sequence of steps that must complete without interruption? Use a Mutex. Wrap the entire sequence in withLock.

Are multiple sources updating the same state concurrently? Consolidate through a single coroutine using a channel. Eliminate the concurrent writes entirely.

Race conditions in Android UI state are preventable. They require recognising the read-modify-write pattern, understanding that coroutine scheduling makes interleaving possible, and applying the right tool before the bug shows up in production rather than after.

Without coordination, concurrent work can quickly become messy. Multiple coroutines may read and update the same shared state at the same time, and one update can silently overwrite another. A mutex gives that shared state a small protected area where only one coroutine can work at a time. Everyone else waits their turn, and the result stays predictable.


메타데이터
post_id
73132648eeff
slug
concurrency-safety-in-android-race-conditions-mutex-and-ui-state-73132648eeff
url
https://medium.com/@androidmeda/concurrency-safety-in-android-race-conditions-mutex-and-ui-state-73132648eeff
canonical_url
https://medium.com/@androidmeda/concurrency-safety-in-android-race-conditions-mutex-and-ui-state-73132648eeff
author_url
https://medium.com/@androidmeda
status
ok
fetched_at
2026-06-09 15:37:30