Meet Modi
Back to Blog
·5 min·updated

runBlocking on the main thread for a value I already had

A synchronous accessor wrapping a suspending DataStore read, called from the UI thread everywhere. An ANR on Android and a real deadlock risk on Native.

By Meet Modi
AndroidKotlinConcurrencyPerformanceDebugging

runBlocking on the UI thread to read Jetpack DataStore blocks that thread on real disk I/O, which is an ANR on Android. On Kotlin/Native it is worse than slow: a main-thread runBlocking waiting on work DataStore dispatches elsewhere can deadlock outright. If the value is already in memory at startup, mirror it and let the synchronous accessor read the mirror.

The line

fun getAppLanguageSync(): String =
    runBlocking { dataStore.data.first() }[LANG_KEY] ?: "en"

Every caller of that function was on the UI thread. It had been there a while and nobody had filed anything, which is the usual story with blocking reads: a warm page cache makes it fast enough that it only hurts the users with slow storage and a cold start, and those users do not file bugs, they uninstall.

Why it is two different bugs

On Android it is an ANR waiting for a slow disk. The platform is explicit that DataStore is suspending because the read is real I/O, and runBlocking is how you discard that guarantee while keeping the API that advertised it.

On Kotlin/Native it can deadlock. DataStore dispatches its work to another dispatcher, and the main thread is sitting inside runBlocking waiting for the result rather than running its own event loop. If the completion needs the main thread for any part of its path, nothing moves. That is not slow. That is stopped.

Somebody already had the value

That question answered itself. Both platforms run a batch config read at cold start, before any UI exists, and both of those batches already contained the language key. The value was in memory before anything could ask for it. The synchronous accessor was going back to disk for something sitting in a variable one layer up.

// Boxed so "not read yet" stays distinguishable from "read, no choice".
private var langMirror: Box<String?>? = null

fun primeLanguage(value: String?) { langMirror = Box(value) }   // from both
                                                                // startup readers
suspend fun setAppLanguage(v: String) {
    dataStore.edit { it[LANG_KEY] = v }
    langMirror = Box(v)
}

fun getAppLanguageSync(): String? {
    val m = langMirror
    if (m == null) {
        // A read before the prime is a bug in startup ordering, not a
        // reason to quietly answer English.
        logger.e("language read before prime")
        return null
    }
    return m.value
}

The box is the detail I would have skipped a year ago. Without it, null means both "we have not read the store yet" and "we read it and the user has never chosen a language". Those need different handling, and collapsing them is how you end up forcing English on someone who picked Hindi before logging in.

Logging on a read-before-prime rather than returning a default is the same instinct. A default here would make a startup-ordering bug invisible for as long as the ordering happened to work.

The inverse problem next door

The locale manager had the mirror image. changeLanguage and applyServerLanguage are suspend functions, so they look safe, but they were being launched from rememberCoroutineScope and from a LaunchedEffect collector. Both of those are Main. A suspend function does not move itself off the thread that called it. It inherits the caller's context unless something changes it.

// Before: suspend, therefore assumed safe. Runs on Main.
suspend fun changeLanguage(code: String) {
    storage.setAppLanguage(code)      // disk write on the UI thread
    platform.applyLocale(code)
}

// After: the I/O names the dispatcher it needs.
suspend fun changeLanguage(code: String) {
    withContext(Dispatchers.IO) { storage.setAppLanguage(code) }
    withContext(Dispatchers.Main) { platform.applyLocale(code) }
}

Pinning the platform call to Main explicitly matters too, because before this it ran on whatever thread the coroutine happened to resume on.

What I would check in any codebase

  • Grep for runBlocking and look at who calls each one. On a UI thread it is a bug, on a worker it is usually fine.
  • Treat a suspend function as inheriting its caller's thread, because it does. The keyword is not a dispatcher.
  • Before adding a blocking read, find out whether something at startup already has the value. Mine did, on both platforms.
  • Box a cached nullable so "unknown" and "known to be empty" stay apart.

The web has the same disease under a different name, where the thread being held is the one painting frames rather than the one drawing Android views: cutting total blocking time on a docs page.

More Posts