What Happens Inside DataStore When Your App Crashes During updateData?

skydovesJaewoong Eum (skydoves)||11 min read

What Happens Inside DataStore When Your App Crashes During updateData?

You store a login token with dataStore.updateData { it.copy(token = newToken) }. The user signs in, the token starts writing to disk, and a millisecond later the Android system kills your process because of memory pressure. When the user opens the app again, is the token there? Is the file corrupted? Is the old data intact, or is it gone too?

In this article, you'll trace the exact path your data takes inside DataStore during updateData, from the coroutine mutex through the scratch file to the atomic rename, then walk through five crash timing scenarios to see what actually survives.

The fundamental problem: File writes are not atomic

Before looking at DataStore, consider what happens if you write to a file the naive way:

val file = File(context.filesDir, "prefs.json")
file.writeText(json.encodeToString(newPrefs))

writeText opens the file, truncates it to zero bytes, and starts writing. If the process dies halfway through, the file contains half of your JSON. On next launch, the file is there but unreadable. You have lost both the old data and the new data.

This is the core problem DataStore solves. Not through retry logic or error handling, but through a write strategy that makes partial writes impossible at the file level.

The updateData pipeline

When you call updateData, a chain of four nested operations runs. Each layer adds a guarantee.

The outer method acquires a coroutine Mutex that serializes all writers:

override suspend fun updateData(transform: suspend (t: T) -> T): T {
    scope.coroutineContext.ensureActive()
    // ...
    writerMutex.withLock {
        val updateMsg = Message.Update(transform, enqueueState, token)
        val result = handleUpdate(updateMsg)
        yield()
        result
    }
}

Only one updateData call can execute at a time. If two fragments both call updateData concurrently, the second one waits until the first finishes. This means the second transform always sees the result of the first, so no writes are lost.

Inside the mutex, handleUpdate delegates to transformAndWrite, which acquires the coordinator's file lock:

private suspend fun transformAndWrite(
    transform: suspend (t: T) -> T,
    token: DataStoreTraceToken?,
): T =
    coordinator.lock {
        val curData = readDataOrHandleCorruption(
            hasWriteFileLock = true,
            getVersion = { coordinator.getVersion() },
        )
        val newData = transform(curData.value)
        curData.checkHashCode()
        if (curData.value != newData) {
            writeData(newData, updateCache = true)
        }
        newData
    }

Three things happen here. First, the current data is read from the cache (or from disk if the cache is stale). Second, your transform function runs on the current data. Third, checkHashCode() verifies that nobody mutated the current data object while the transform was running. If the data has changed, a write happens. If it hasn't, writeData is skipped entirely.

The write aside strategy: Scratch files and atomic moves

The actual disk write is where crash safety comes from. DataStore never writes to your data file directly. It writes to a temporary scratch file, then renames the scratch file to replace the original.

The writeData method enters the storage connection's writeScope:

internal suspend fun writeData(newData: T, updateCache: Boolean): Int {
    var newVersion = 0
    storageConnection.writeScope {
        newVersion = coordinator.incrementAndGetVersion()
        writeData(newData)
        if (updateCache) {
            inMemoryCache.tryUpdate(
                Data(newData, newData.hashCode(), newVersion)
            )
        }
    }
    return newVersion
}

This article continues for subscribers

Subscribe to Dove Letter for full access to 40+ deep-dive articles about Android and Kotlin development.

Become a Sponsor