Concurrency and Race Conditions in Kotlin: A Complete Guide from Threads to Coroutines
21 min read

Concurrency and Race Conditions in Kotlin: A Complete Guide from Threads to Coroutines

Kotlin is a language that grew on the JVM ecosystem, inheriting Java’s entire threading model — platform threads, synchronized, java.util.concurrent, volatile — all usable directly from Kotlin. But Kotlin doesn’t stop there. JetBrains designed Coroutines as a first-class concurrency solution that’s more expressive, safer, and far lighter than traditional threads. A coroutine isn’t a language feature in the sense of a new compiler keyword — it’s a library (kotlinx.coroutines) built on top of the suspension mechanism provided by the Kotlin compiler. The result is a concurrency model that feels like ordinary synchronous code, but can run concurrently without blocking OS threads.

Understanding concurrency in Kotlin means understanding two worlds at once: the Java inheritance that remains relevant (especially for interop with Java libraries and CPU-bound code), and Coroutines with its ecosystem (Channel, Flow, CoroutineScope, structured concurrency) which is the idiomatic Kotlin way for I/O-bound concurrency. This article discusses both in depth — including the race conditions that can happen in both, how to detect them, and how to prevent them with the right synchronization mechanisms.

Threads in Kotlin: The Java Inheritance

Because Kotlin runs on the JVM, all Java threading primitives are directly available. Kotlin just adds slightly cleaner syntax:

// Creating a thread with a lambda (Kotlin idiom)
val thread = Thread {
    println("Running on: ${Thread.currentThread().name}")
}
thread.start()

// Or with Kotlin's built-in extension function
val t = thread(name = "worker-1") { // from kotlin.concurrent
    println("Running on: ${Thread.currentThread().name}")
}

// A thread with the daemon flag
thread(isDaemon = true, name = "background") {
    while (true) {
        doBackgroundWork()
        Thread.sleep(1000)
    }
}

The entire java.util.concurrentExecutorService, ReentrantLock, AtomicInteger, ConcurrentHashMap — can be used directly from Kotlin thanks to full Java interop. However, in modern Kotlin, the need to manually create threads is very rare; there’s almost always a better solution via Coroutines.


Race Conditions in Kotlin: Just as Dangerous as in Java

Race conditions in Kotlin work exactly like in Java — because both share the JVM memory model. Code accessing shared mutable state from multiple threads without synchronization produces non-deterministic behavior.

The Classic Example: An Unsafe Counter

// ANTI-PATTERN: race condition on a shared counter
var counter = 0 // shared mutable state

val threads = (1..10).map {
    Thread {
        repeat(1000) {
            counter++ // NOT SAFE: read-add-write is not an atomic operation
        }
    }
}

threads.forEach { it.start() }
threads.forEach { it.join() }

println(counter) // not 10000 — the result is unpredictable every run

The Visibility Problem in Kotlin

// ANTI-PATTERN: visibility problem
class TaskRunner {
    private var running = true // no visibility guarantee across threads

    fun stop() {
        running = false // another thread may never see this
    }

    fun run() {
        while (running) { // might loop forever from the CPU cache
            doWork()
        }
    }
}

// CORRECT: @Volatile annotation (equivalent to Java volatile)
class SafeTaskRunner {
    @Volatile
    private var running = true // guaranteed visible to all threads

    fun stop() { running = false }

    fun run() {
        while (running) {
            doWork()
        }
    }
}

Synchronizing with synchronized and Lock

Kotlin supports the @Synchronized annotation and the synchronized() function:

// Using a synchronized block
class SafeCounter {
    private var count = 0
    private val lock = Any() // object as the monitor

    fun increment() {
        synchronized(lock) {
            count++
        }
    }

    fun getCount(): Int = synchronized(lock) { count }
}

// Using the @Synchronized annotation (equivalent to a Java synchronized method)
class SynchronizedCounter {
    private var count = 0

    @Synchronized
    fun increment() { count++ }

    @Synchronized
    fun getCount(): Int = count
}

// Using ReentrantLock from Java
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock // Kotlin extension function

class LockCounter {
    private var count = 0
    private val lock = ReentrantLock()

    fun increment() = lock.withLock { count++ } // withLock auto-unlocks in finally

    fun getCount(): Int = lock.withLock { count }
}

Atomic Classes

import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.LongAdder

// AtomicInteger for a thread-safe counter without locks
val atomicCounter = AtomicInteger(0)
atomicCounter.incrementAndGet()
atomicCounter.addAndGet(5)
atomicCounter.compareAndSet(10, 20) // CAS

// LongAdder for a high-contention increment-only counter
val adder = LongAdder()
adder.increment()
val total = adder.sum()

Introducing Coroutines: Concurrency Without New Threads

A coroutine is an execution unit that can be suspended (paused) and resumed (continued) without blocking the thread running it. Unlike a thread, which when doing I/O will “sleep” and wait there (blocking the OS thread), a coroutine performing a suspend operation releases its thread so that thread can run other coroutines.

sequenceDiagram
    participant T as Thread
    participant C1 as Coroutine 1
    participant C2 as Coroutine 2
    T->>C1: run C1
    C1->>C1: doWork()
    C1-->>T: suspend (waiting for I/O)
    T->>C2: run C2 (thread free!)
    C2->>C2: doOtherWork()
    C2-->>T: suspend (waiting for I/O)
    T->>C1: resume C1 (I/O done)
    C1->>C1: continue after I/O

A single thread can run thousands of coroutines in turn, making coroutines far lighter than threads and suitable for applications with lots of concurrent I/O.

Adding the Dependency

// build.gradle.kts
dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0")
    // For Android:
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0")
    // For Spring Boot:
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor:1.8.0")
}

Suspend Functions: The Foundation of Coroutines

A function that can be suspended is marked with the suspend keyword. This function can only be called from another coroutine or another suspend function — not directly from regular code.

// A suspend function — can pause without blocking the thread
suspend fun fetchUser(id: Int): User {
    delay(500) // suspends for 500ms, the thread is NOT blocked
    return User(id, "John Doe")
}

suspend fun fetchOrders(userId: Int): List<Order> {
    delay(300) // suspends, the thread is free to run other coroutines
    return listOf(Order(1), Order(2))
}

// Using a suspend function — must be from within a coroutine
suspend fun loadDashboard() {
    val user = fetchUser(1)     // suspends here
    val orders = fetchOrders(1) // then suspends again
    println("${user.name}: ${orders.size} orders")
}
delay() is a suspend function from kotlinx.coroutines that pauses a coroutine without blocking the thread — unlike Thread.sleep(), which blocks the OS thread. Always use delay() inside a coroutine, not Thread.sleep().

CoroutineScope and Structured Concurrency

Every coroutine must be created inside a CoroutineScope. The scope defines the coroutine’s lifecycle — when the scope is cancelled, all coroutines inside it are automatically cancelled too. This is the core of structured concurrency: a coroutine can’t “leak” out of the scope that defines it.

import kotlinx.coroutines.*

fun main() = runBlocking { // creates a scope and blocks the thread until done
    println("Starting on: ${Thread.currentThread().name}")

    launch { // launches a new coroutine inside this scope
        delay(1000)
        println("Coroutine 1 finished")
    }

    launch {
        delay(500)
        println("Coroutine 2 finished")
    }

    println("Waiting for all coroutines to finish...")
    // runBlocking waits until all child coroutines finish
}

launch vs async

launch is used for coroutines that don’t return a value (fire-and-forget). async is used when you need a result from the coroutine (returns a Deferred<T>).

runBlocking {
    // launch: no return value
    val job: Job = launch {
        delay(1000)
        println("Work done")
    }
    job.join() // wait for the job to finish

    // async: returns Deferred<T>, can be awaited
    val deferred: Deferred<Int> = async {
        delay(1000)
        42 // return value
    }
    val result = deferred.await() // suspends until the result is available
    println("Result: $result")

    // Running two tasks in parallel with async
    val deferredA = async { fetchUser(1) }
    val deferredB = async { fetchOrders(1) }

    val user = deferredA.await()   // wait for both
    val orders = deferredB.await()
    // fetchUser and fetchOrders run SIMULTANEOUSLY, not sequentially
}
flowchart LR
    subgraph Sequential
        A[fetchUser] --> B[fetchOrders]
        B --> C[Done - 800ms]
    end
    subgraph Parallel with async
        D[fetchUser - 500ms] --> F[await A + B - 500ms]
        E[fetchOrders - 300ms] --> F
        F --> G[Done - 500ms]
    end

Coroutine Dispatchers: Choosing Which Thread Is Used

CoroutineDispatcher determines which thread a coroutine runs on. This is Kotlin’s idiomatic replacement for choosing between different thread pools.

// Dispatcher.Default: thread pool for CPU-intensive work
// Thread count = number of CPU cores
launch(Dispatchers.Default) {
    val result = heavyComputation() // heavy computation
}

// Dispatchers.IO: thread pool for I/O operations
// More threads (default 64, or the core count if larger)
launch(Dispatchers.IO) {
    val data = readFile("data.txt")     // file I/O
    val response = httpClient.get(url)   // network call
    val rows = database.query(sql)       // database query
}

// Dispatchers.Main: the main/UI thread (Android, JavaFX, Swing)
launch(Dispatchers.Main) {
    updateUI(result) // only in Android/GUI environments
}

// Dispatchers.Unconfined: not bound to a specific thread (rarely used)
launch(Dispatchers.Unconfined) {
    println(Thread.currentThread().name) // runs on the caller's thread
}

Switching Dispatchers Within One Coroutine

suspend fun loadAndProcess(): String {
    // Start with I/O: fetch data from the network
    val rawData = withContext(Dispatchers.IO) {
        httpClient.get("https://api.example.com/data")
    }

    // Switch to Default: process the data (CPU-intensive)
    val processed = withContext(Dispatchers.Default) {
        parseAndTransform(rawData)
    }

    // Return to Main for UI updates (on Android)
    withContext(Dispatchers.Main) {
        showResult(processed)
    }

    return processed
}
DispatcherThread PoolGood For
DefaultCPU coresParsing, sorting, computation
IO64+ threadsFile, network, database
Main1 (UI thread)UI updates
UnconfinedCaller’s threadTesting, special cases

Race Conditions in Coroutines

Even though coroutines feel sequential to write, race conditions can still happen when several coroutines access shared mutable state — especially if they run on different dispatchers or in coroutines launched in parallel.

Example: A Counter Race Condition in Coroutines

// ANTI-PATTERN: race condition in coroutines
var counter = 0 // shared mutable state

runBlocking {
    val jobs = (1..1000).map {
        launch(Dispatchers.Default) {
            counter++ // NOT SAFE even though it looks like sequential code!
        }
    }
    jobs.forEach { it.join() }
}

println(counter) // not 1000 — there are lost updates

This happens because Dispatchers.Default uses multiple threads, and counter++ still isn’t an atomic operation at the CPU level.

Solution 1: Atomic from Java

import java.util.concurrent.atomic.AtomicInteger

// CORRECT: AtomicInteger for a simple counter
val counter = AtomicInteger(0)

runBlocking {
    val jobs = (1..1000).map {
        launch(Dispatchers.Default) {
            counter.incrementAndGet() // atomic
        }
    }
    jobs.forEach { it.join() }
}

println(counter.get()) // always 1000

Solution 2: Mutex from Coroutines

kotlinx.coroutines.sync.Mutex is a suspend-friendly version of a mutex — when it can’t acquire the lock, it suspends the coroutine (instead of blocking the thread):

import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock

// CORRECT: coroutine-friendly Mutex
val mutex = Mutex()
var counter = 0

runBlocking {
    val jobs = (1..1000).map {
        launch(Dispatchers.Default) {
            mutex.withLock { // suspends if the lock isn't available, doesn't block the thread
                counter++
            }
        }
    }
    jobs.forEach { it.join() }
}

println(counter) // always 1000

Solution 3: Single-Thread Confinement

The most idiomatic pattern in Kotlin: restrict access to shared state to only one coroutine/thread:

// CORRECT: restrict counter access to a single-threaded dispatcher
val counterContext = newSingleThreadContext("CounterContext")
var counter = 0

runBlocking {
    val jobs = (1..1000).map {
        launch(Dispatchers.Default) {
            withContext(counterContext) {
                counter++ // safe because only one thread executes this
            }
        }
    }
    jobs.forEach { it.join() }
}

println(counter) // always 1000
counterContext.close()

Solution 4: The Actor Pattern with Channels

Following the “share by communicating” philosophy — one coroutine owns the state, others communicate via channels:

import kotlinx.coroutines.channels.*

// Actor: a coroutine that processes messages sequentially
sealed class CounterMsg
object IncCounter : CounterMsg()
class GetCounter(val response: CompletableDeferred<Int>) : CounterMsg()

fun CoroutineScope.counterActor() = actor<CounterMsg> {
    var counter = 0 // state is only held by this actor
    for (msg in channel) {
        when (msg) {
            is IncCounter -> counter++
            is GetCounter -> msg.response.complete(counter)
        }
    }
}

runBlocking {
    val counter = counterActor()

    val jobs = (1..1000).map {
        launch(Dispatchers.Default) {
            counter.send(IncCounter) // send a message, no shared state
        }
    }
    jobs.forEach { it.join() }

    val response = CompletableDeferred<Int>()
    counter.send(GetCounter(response))
    println("Counter: ${response.await()}") // always 1000

    counter.close()
}

Channels: Communication Between Coroutines

Channels in Kotlin Coroutines are the equivalent of Go channels — typed “pipes” for communication between coroutines with built-in synchronization.

import kotlinx.coroutines.channels.*

runBlocking {
    // Unbuffered channel: the sender suspends until there's a receiver
    val channel = Channel<Int>()

    launch {
        for (i in 1..5) {
            println("Sending $i")
            channel.send(i) // suspends if there's no receiver
        }
        channel.close() // tell the receiver there's no more data
    }

    for (value in channel) { // iterate until the channel is closed
        println("Received $value")
    }
}

// Buffered channel
val buffered = Channel<Int>(capacity = 10)

// Rendezvous (unbuffered, default)
val rendezvous = Channel<Int>(Channel.RENDEZVOUS)

// Unlimited buffer (careful: can OOM)
val unlimited = Channel<Int>(Channel.UNLIMITED)

// Drop the oldest if full
val dropping = Channel<Int>(capacity = 10, onBufferOverflow = BufferOverflow.DROP_OLDEST)

Fan-Out and Fan-In with Channels

fun CoroutineScope.produceNumbers() = produce<Int> {
    var x = 1
    while (true) send(x++)
}

fun CoroutineScope.processNumber(id: Int, numbers: ReceiveChannel<Int>) = launch {
    for (msg in numbers) {
        println("Worker $id processing $msg")
    }
}

// Fan-out: one producer, many consumers
runBlocking {
    val producer = produceNumbers()
    repeat(3) { id ->
        processNumber(id, producer) // 3 workers sharing the same channel
    }
    delay(1000)
    producer.cancel() // stop the producer
}

// Fan-in: merge several channels into one
fun CoroutineScope.mergeChannels(vararg channels: ReceiveChannel<Int>): ReceiveChannel<Int> =
    produce {
        for (channel in channels) {
            launch {
                for (value in channel) send(value)
            }
        }
    }

Flow: Asynchronous Data Streams

Flow is an abstraction for cold asynchronous streams — a sequence of values produced asynchronously that only starts flowing when someone collects it.

import kotlinx.coroutines.flow.*

// Creating a Flow
fun numbersFlow(): Flow<Int> = flow {
    for (i in 1..5) {
        delay(100) // suspend operation inside the flow builder
        emit(i)    // emit a value
    }
}

// Collecting a Flow (terminal operator, suspend function)
runBlocking {
    numbersFlow()
        .filter { it % 2 == 0 }       // transformation: filter
        .map { it * it }               // transformation: map
        .collect { value ->            // terminal operator
            println(value)             // 4, 16
        }
}

Commonly Used Flow Operators

val flow = flowOf(1, 2, 3, 4, 5)

// Transformations
flow.map { it * 2 }               // transform every element
flow.filter { it > 2 }           // filter elements
flow.flatMapMerge { fetchData(it) } // concurrent flatMap
flow.transform { emit(it); emit(it * 2) } // emit several values per element

// Combinations
flow1.zip(flow2) { a, b -> a + b }     // zip two flows
flow1.combine(flow2) { a, b -> a + b } // combine (re-emits when either changes)
merge(flow1, flow2)                     // merge several flows into one

// Control
flow.take(3)           // only take the first 3 elements
flow.debounce(300)     // wait for 300ms of quiet before emitting (good for search input)
flow.distinctUntilChanged() // skip if the value equals the previous one
flow.retry(3)          // retry up to 3 times on error
flow.catch { e -> emit(defaultValue) } // error handling

// Terminal
flow.toList()          // collect everything into a List (suspend)
flow.first()           // take only the first element (suspend)
flow.count()           // count the number of elements (suspend)
flow.reduce { acc, value -> acc + value } // reduce (suspend)

Cold vs Hot Flows

A regular Flow is cold — every collector gets the data stream from the start, and the flow only runs when there’s a collector:

val coldFlow = flow {
    println("Flow starts running")
    emit(1)
    emit(2)
}

runBlocking {
    coldFlow.collect { println("Collector 1: $it") }
    // "Flow starts running" prints again for the second collector
    coldFlow.collect { println("Collector 2: $it") }
}

SharedFlow and StateFlow are hot — they run regardless of whether there are collectors, and can be shared with many collectors:


SharedFlow and StateFlow: Hot Streams

SharedFlow: Broadcasting to Many Collectors

import kotlinx.coroutines.flow.*

val sharedFlow = MutableSharedFlow<String>(
    replay = 1,           // new subscribers immediately get the last value
    extraBufferCapacity = 10 // extra buffer so emits don't suspend
)

runBlocking {
    // Emitter
    launch {
        repeat(5) { i ->
            sharedFlow.emit("Event $i")
            delay(100)
        }
    }

    // Two collectors receiving the same events
    launch {
        sharedFlow.collect { println("Collector A: $it") }
    }

    launch {
        sharedFlow.collect { println("Collector B: $it") }
    }

    delay(1000)
}

StateFlow: A Reactive State Holder

StateFlow is a special SharedFlow that always has a current value, similar to LiveData on Android:

import kotlinx.coroutines.flow.*

class ViewModel {
    private val _uiState = MutableStateFlow(UiState.Loading)
    val uiState: StateFlow<UiState> = _uiState.asStateFlow() // exposed as read-only

    suspend fun loadData() {
        _uiState.value = UiState.Loading

        try {
            val data = repository.fetchData()
            _uiState.value = UiState.Success(data)
        } catch (e: Exception) {
            _uiState.value = UiState.Error(e.message ?: "An error occurred")
        }
    }
}

// In the UI / collector
viewModel.uiState
    .collect { state ->
        when (state) {
            is UiState.Loading -> showLoading()
            is UiState.Success -> showData(state.data)
            is UiState.Error -> showError(state.message)
        }
    }
Flow (Cold)SharedFlow (Hot)StateFlow (Hot)
Starts runningWhen there’s a collectorAlwaysAlways
Current valueNoneNone (except replay)Yes, always
Many collectorsEach gets its own streamAll get the same eventsAll get the latest state
Good forData pipelines, queriesEvents/event busesUI state, config

Structured Concurrency in Kotlin

Structured concurrency is one of Kotlin Coroutines’ biggest contributions. Its principle is simple: a coroutine can’t outlive the scope that created it. This automatically prevents coroutine leaks.

// When the scope is cancelled, all child coroutines are cancelled too
val scope = CoroutineScope(Dispatchers.Default)

scope.launch {
    launch { delay(Long.MAX_VALUE) } // child 1
    launch { delay(Long.MAX_VALUE) } // child 2
    delay(Long.MAX_VALUE)            // parent
}

scope.cancel() // all coroutines above are cancelled immediately

coroutineScope vs supervisorScope

// coroutineScope: if one child fails, all other children are cancelled
suspend fun fetchDashboard() = coroutineScope {
    val user = async { fetchUser() }      // if this fails...
    val orders = async { fetchOrders() }  // ...this is also cancelled

    DashboardData(user.await(), orders.await())
}

// supervisorScope: one child's failure doesn't affect the other children
suspend fun fetchAll() = supervisorScope {
    val user = async { fetchUser() }
    val orders = async { fetchOrders() }
    val recommendations = async { fetchRecommendations() }

    // Recommendations might fail, but user and orders are still processed
    DashboardData(
        user = user.await(),
        orders = orders.await(),
        recommendations = try { recommendations.await() } catch (e: Exception) { emptyList() }
    )
}
flowchart TD
    subgraph "coroutineScope — failure spreads"
        CS[coroutineScope] --> CA[async: fetchUser ✓]
        CS --> CB[async: fetchOrders ✗ FAILS]
        CB -->|cancel| CA
        CB -->|fails| CS
    end
    subgraph "supervisorScope — failure isolated"
        SS[supervisorScope] --> SA[async: fetchUser ✓]
        SS --> SB[async: fetchOrders ✗ FAILS]
        SS --> SC[async: fetchRecs ✓]
        SB -->|doesn't affect| SA
        SB -->|doesn't affect| SC
    end

Cancellation and Exception Handling

Cooperative Cancellation

Coroutines in Kotlin are cooperative — they can only be cancelled at suspension points. CPU-bound code that never suspends can’t be cancelled automatically:

// ANTI-PATTERN: not cooperative, can't be cancelled
val job = launch {
    var i = 0
    while (true) { // loop without a suspension point
        i++
        // no suspension here — cancel() will have no effect
    }
}
job.cancel() // won't work!

// CORRECT: check isActive or use yield()
val job = launch {
    var i = 0
    while (isActive) { // check whether still active
        i++
    }
}

// Or use yield() to give a chance for cancel/scheduling
val job = launch {
    var i = 0
    while (true) {
        yield() // suspension point: checks cancel and yields to the scheduler
        i++
    }
}

job.cancel()

CancellationException

When a coroutine is cancelled, a CancellationException is thrown at the next suspension point. Important: don’t catch CancellationException and swallow it:

// ANTI-PATTERN: swallowing CancellationException
launch {
    try {
        delay(1000)
    } catch (e: Exception) { // catches ALL exceptions including CancellationException!
        println("Error: ${e.message}") // this shouldn't be done
        // the coroutine isn't truly cancelled
    }
}

// CORRECT: only catch what isn't a CancellationException
launch {
    try {
        delay(1000)
    } catch (e: CancellationException) {
        throw e // re-throw CancellationException!
    } catch (e: Exception) {
        println("Error: ${e.message}") // catch other errors
    }
}

// Or use try/finally for cleanup
launch {
    try {
        delay(1000)
        doWork()
    } finally {
        // cleanup always runs, whether finished normally or cancelled
        closeResources()
    }
}

CoroutineExceptionHandler

For catching uncaught exceptions from launch (not async):

val handler = CoroutineExceptionHandler { _, exception ->
    println("Uncaught exception: $exception")
    // log, report to Sentry, etc.
}

val scope = CoroutineScope(Dispatchers.Default + handler)

scope.launch {
    throw RuntimeException("Something unexpected!")
    // CoroutineExceptionHandler will be called
}

Common Anti-Patterns in Kotlin Coroutines

Anti-Pattern 1: GlobalScope

// ANTI-PATTERN: GlobalScope — the coroutine isn't bound to any lifecycle
fun loadData() {
    GlobalScope.launch { // dangerous: lives forever, can leak!
        val data = fetchFromNetwork()
        updateUI(data)
    }
}

// CORRECT: use a lifecycle-bound scope
class MyViewModel : ViewModel() {
    fun loadData() {
        viewModelScope.launch { // auto-cancelled when the ViewModel is destroyed
            val data = fetchFromNetwork()
            _uiState.value = UiState.Success(data)
        }
    }
}

// Or create your own cancellable scope
class DataLoader {
    private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())

    fun load() {
        scope.launch { /* ... */ }
    }

    fun destroy() {
        scope.cancel() // cancel all coroutines
    }
}

Anti-Pattern 2: Blocking Inside a Coroutine

// ANTI-PATTERN: calling blocking code in a coroutine without an IO dispatcher
suspend fun fetchData(): String {
    val response = OkHttpClient().newCall(request).execute() // BLOCKING!
    // this blocks the carrier thread, reducing coroutine effectiveness
    return response.body?.string() ?: ""
}

// CORRECT: wrap blocking code with withContext(Dispatchers.IO)
suspend fun fetchData(): String {
    return withContext(Dispatchers.IO) {
        val response = OkHttpClient().newCall(request).execute() // blocking OK here
        response.body?.string() ?: ""
    }
}

// CORRECT: even better, use a suspend-aware library
suspend fun fetchData(): String {
    return httpClient.get(url).bodyAsText() // ktor client: already suspend, not blocking
}

Anti-Pattern 3: Not Handling Exceptions in async

// ANTI-PATTERN: an exception in async only surfaces when await is called,
// and is easy to miss
val deferred = async {
    throw RuntimeException("Failed!")
}
// the exception hasn't surfaced here...

delay(100)
deferred.await() // only explodes here — easy to forget

// CORRECT: handle the exception at await
val result = try {
    deferred.await()
} catch (e: Exception) {
    defaultValue
}

// Or use runCatching
val result = runCatching { deferred.await() }
    .getOrElse { defaultValue }

Anti-Pattern 4: Creating Dispatchers That Are Never Closed

// ANTI-PATTERN: a new dispatcher without close — thread leak
suspend fun doWork() {
    val dispatcher = newSingleThreadContext("worker")
    withContext(dispatcher) {
        // ...
    }
    // the dispatcher isn't closed, the thread lives forever!
}

// CORRECT: close the dispatcher when done
suspend fun doWork() {
    val dispatcher = newSingleThreadContext("worker")
    try {
        withContext(dispatcher) {
            // ...
        }
    } finally {
        dispatcher.close() // make sure the thread is released
    }
}

Anti-Pattern 5: Race Conditions on Shared StateFlow

// ANTI-PATTERN: modifying a StateFlow from several coroutines without synchronization
val _count = MutableStateFlow(0)

repeat(1000) {
    launch(Dispatchers.Default) {
        _count.value = _count.value + 1 // RACE CONDITION: read-modify-write!
    }
}

// CORRECT: use update() which is atomic
repeat(1000) {
    launch(Dispatchers.Default) {
        _count.update { it + 1 } // atomic: no lost updates
    }
}

Production Concurrency Patterns in Kotlin

Retry with Exponential Backoff

suspend fun <T> retryWithBackoff(
    times: Int = 3,
    initialDelay: Long = 100,
    maxDelay: Long = 1000,
    factor: Double = 2.0,
    block: suspend () -> T
): T {
    var currentDelay = initialDelay
    repeat(times - 1) { attempt ->
        try {
            return block()
        } catch (e: Exception) {
            if (e is CancellationException) throw e // don't retry on cancel
            println("Attempt ${attempt + 1} failed: ${e.message}, retrying in ${currentDelay}ms")
        }
        delay(currentDelay)
        currentDelay = (currentDelay * factor).toLong().coerceAtMost(maxDelay)
    }
    return block() // last attempt, let the exception propagate
}

// Usage
val result = retryWithBackoff(times = 3, initialDelay = 100) {
    httpClient.get("https://api.example.com/data")
}

Timeouts

import kotlinx.coroutines.*

// withTimeout: throws TimeoutCancellationException if the limit is exceeded
val result = withTimeout(5000L) {
    fetchDataFromNetwork() // must finish within 5 seconds
}

// withTimeoutOrNull: returns null on timeout, doesn't throw
val result: String? = withTimeoutOrNull(5000L) {
    fetchDataFromNetwork()
} ?: "default value"

Parallel Decomposition

// Running many requests in parallel and collecting the results
suspend fun fetchAllUsers(ids: List<Int>): List<User> = coroutineScope {
    ids.map { id ->
        async { fetchUser(id) } // each request runs in parallel
    }.awaitAll() // wait for all to finish, throws if any fails
}

// With a concurrency limit (semaphore)
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit

suspend fun fetchAllUsersLimited(ids: List<Int>, maxConcurrent: Int = 10): List<User> {
    val semaphore = Semaphore(maxConcurrent) // max 10 simultaneous requests
    return coroutineScope {
        ids.map { id ->
            async {
                semaphore.withPermit { // wait for a permit before starting
                    fetchUser(id)
                }
            }
        }.awaitAll()
    }
}

Worker Pools with Channels

fun CoroutineScope.workerPool(
    workers: Int,
    jobs: ReceiveChannel<Int>,
    results: SendChannel<Int>
) {
    repeat(workers) { workerId ->
        launch(Dispatchers.IO) {
            for (job in jobs) {
                val result = processJob(job)
                results.send(result)
                println("Worker $workerId finished job $job")
            }
        }
    }
}

runBlocking {
    val jobs = Channel<Int>(Channel.UNLIMITED)
    val results = Channel<Int>(Channel.UNLIMITED)

    // Fill the jobs
    repeat(100) { jobs.send(it) }
    jobs.close()

    // Run 5 workers in parallel
    workerPool(workers = 5, jobs = jobs, results = results)

    // Collect the results
    repeat(100) { println("Result: ${results.receive()}") }
}

Testing Coroutines

Testing concurrent code is always tricky. kotlinx-coroutines-test provides special tooling:

import kotlinx.coroutines.test.*
import kotlin.test.*

// TestCoroutineScheduler for virtual time control
class UserViewModelTest {

    @Test
    fun `loading state changes correctly`() = runTest {
        val viewModel = UserViewModel()

        viewModel.loadUser(1)

        // advanceUntilIdle: run all pending coroutines
        advanceUntilIdle()

        assertEquals(UiState.Success::class, viewModel.uiState.value::class)
    }

    @Test
    fun `delay is virtualized without waiting real time`() = runTest {
        var result = 0

        launch {
            delay(10_000) // 10 seconds — but the test doesn't actually wait 10 seconds!
            result = 42
        }

        advanceTimeBy(10_001) // advance the virtual time
        assertEquals(42, result)
    }
}

Concurrent Kotlin Code Review Checklist

BASIC THREAD SAFETY:
  □ Is every access to vars used from multiple coroutines/threads safe?
  □ Is @Volatile used for flags read from different threads?
  □ Are there no plain HashMap/ArrayList instances accessed from multiple threads?

COROUTINE SCOPES:
  □ Is GlobalScope unused? (use lifecycle-bound scopes)
  □ Is every manually created CoroutineScope cancelled when no longer used?
  □ Can no coroutine "leak" out of its scope?

DISPATCHERS:
  □ Is blocking I/O always inside withContext(Dispatchers.IO)?
  □ Is CPU-intensive work inside withContext(Dispatchers.Default)?
  □ Is there no Thread.sleep() inside a coroutine (use delay())?

CANCELLATION:
  □ Is CancellationException never swallowed (catch Exception without re-throw)?
  □ Do long-running CPU tasks check isActive or call yield()?
  □ Is cleanup inside a finally block (not just in catch)?

EXCEPTION HANDLING:
  □ Are exceptions from async {} handled at await()?
  □ Is there a CoroutineExceptionHandler for uncaught exceptions from launch?
  □ Is coroutineScope vs supervisorScope chosen correctly per the need?

SHARED STATE:
  □ Are there no shared mutable vars accessed from Dispatchers.Default?
  □ Is MutableStateFlow.update{} used (not value = value + 1)?
  □ Is a coroutine Mutex (not java.util.concurrent) used for suspend contexts?

FLOW:
  □ Is Flow never collected on the wrong background thread?
  □ Are SharedFlow/StateFlow used for hot streams, Flow for cold?
  □ Are exceptions inside flows handled with the catch operator?

RESOURCES:
  □ Are newSingleThreadContext / newFixedThreadPoolContext always closed?
  □ Are Channels always closed from the sender side?
  □ Are there no resource leaks inside coroutines that may be cancelled?

Summary

  • Kotlin inherits Java’s entire threading model — synchronized, @Volatile, java.util.concurrent, AtomicInteger — all usable directly with Kotlin’s more concise syntax.
  • Race conditions can still happen in Kotlin, both on regular threads and in coroutines running on Dispatchers.Default (multi-threaded).
  • suspend functions can be paused without blocking the thread — the foundation of the entire coroutine concurrency model.
  • launch for fire-and-forget (returns a Job); async for tasks returning a value (returns a Deferred<T> that can be awaited).
  • CoroutineDispatcher determines the thread pool: Default for CPU-bound, IO for I/O-bound, Main for the UI thread.
  • Structured concurrency ensures coroutines don’t outlive their scope — automatically preventing coroutine leaks.
  • coroutineScope propagates failure to all siblings; supervisorScope isolates failures so other children keep running.
  • Mutex from kotlinx.coroutines.sync is a suspend-friendly version of a lock — suspending the coroutine without blocking the thread while waiting for the lock.
  • For simple shared counters, AtomicInteger/AtomicLong is more efficient. For more complex state, a Mutex or single-thread confinement is more appropriate.
  • Channel for communication between coroutines (push-based); Flow for data streams pulled by a collector.
  • StateFlow for reactive UI state (always has a current value); SharedFlow for events/broadcasting to many collectors.
  • MutableStateFlow.update { } for atomic state modification — avoid _state.value = _state.value + 1, which is prone to race conditions.
  • Don’t use GlobalScope — always bind coroutines to a scope with a lifecycle (ViewModel scope, lifecycle scope, or a manually cancelled scope).
  • Don’t swallow CancellationException — catch the specific expected exceptions, and re-throw CancellationException.
  • For testing, use runTest with advanceUntilIdle() / advanceTimeBy() so virtual time can be controlled without waiting for the real duration.

Portfolio