The Snapshot System: How Compose Tracks and Batches State Changes

skydovesJaewoong Eum (skydoves)||23 min read

The Snapshot System: How Compose Tracks and Batches State Changes

Jetpack Compose revolutionized Android UI development with its declarative approach, but what makes it truly powerful is the sophisticated machinery underneath. At the heart of Compose's reactivity lies the Snapshot System, a multi-version concurrency control (MVCC) implementation that enables isolated state changes, automatic recomposition, and conflict-free concurrent updates. When you write var count by mutableStateOf(0), you're interacting with one of the most elegant concurrent systems in modern Android development.

In this article, you'll dive deep into the internal mechanisms of the Snapshot System, exploring how snapshots provide isolation through MVCC, how StateRecord chains track multiple versions of state, how the system decides which version to read, how writes create new StateRecords without blocking readers, how state observations trigger recomposition, and how the apply mechanism detects and resolves conflicts. This isn't a guide on using mutableStateOf, it's an exploration of the compiler and runtime machinery that makes reactive state management possible.

The fundamental problem: How do you track state changes safely?

Consider this simple Compose code:

@Composable
fun Counter() {
    var count by remember { mutableStateOf(0) }

    Button(onClick = { count++ }) {
        Text("Count: $count")
    }
}

This looks deceptively simple, but several complex problems need solving:

  1. Isolation: When count changes, the new value must be visible to recomposition but not affect in-progress compositions.
  2. Observation: The system must know that this composable read count so it can recompose when count changes.
  3. Concurrency: Multiple threads might read and write state simultaneously.
  4. Memory: Old state versions must eventually be garbage collected.

The naive approach would use locks everywhere, but that would kill performance. Compose solves this elegantly with snapshots, isolated views of mutable state that enable lock-free reads and conflict detection.

Understanding the core abstraction: What makes Snapshot special

At its heart, a Snapshot is an isolated view of mutable state at a specific point in time. The Snapshot class is a sealed class that encapsulates a unique snapshot ID and tracks which concurrent snapshots should be considered invalid for isolation purposes:

public sealed class Snapshot(
    snapshotId: SnapshotId,
    internal open var invalid: SnapshotIdSet,
) {
    public open var snapshotId: SnapshotId = snapshotId
    public abstract val root: Snapshot
    public abstract val readOnly: Boolean
    internal abstract val readObserver: ((Any) -> Unit)?
    internal abstract val writeObserver: ((Any) -> Unit)?
}

Three critical properties define snapshot isolation:

Snapshot IDs are monotonically increasing

Every snapshot gets a unique ID from nextSnapshotId, an atomically incremented counter. This creates a total ordering of snapshots. When you create a snapshot, it gets the next available ID:

val nextSnapshotId: SnapshotId
    get() = sync {
        currentGlobalSnapshot.get().snapshotId + 1
    }

This monotonic ID is the foundation of version selection, newer snapshots can see changes from older snapshots, but not vice versa.

Invalid sets track concurrent snapshots

Each snapshot maintains a SnapshotIdSet called invalid that contains the IDs of snapshots that were active (but not yet applied) when this snapshot was created. This is crucial for isolation:

// From the test suite demonstrating isolation
var state by mutableStateOf("0")
val snapshot = takeSnapshot()
state = "1"
assertEquals("1", state)              // Global sees "1"
assertEquals("0", snapshot.enter { state })  // Snapshot still sees "0"

The snapshot can't see changes made by concurrent snapshots because their IDs are in its invalid set. This is how MVCC provides snapshot isolation.

Observers enable reactive behavior

The readObserver is called whenever state is read, allowing the system to track dependencies. The writeObserver is called on writes, enabling batched notifications. These observers are the bridge between snapshots and recomposition.

Global vs Mutable: Two kinds of snapshots

Compose uses two snapshot types for different purposes.

GlobalSnapshot: The current state of the world

There's a single GlobalSnapshot that represents the "current" global state:

internal class GlobalSnapshot(snapshotId: SnapshotId, invalid: SnapshotIdSet) :
    MutableSnapshot(
        snapshotId,
        invalid,
        null,
        { state -> sync { globalWriteObservers.fastForEach { it(state) } } }
    )

The global snapshot is special:

  • It's the default snapshot when you're not inside any other snapshot
  • Writes to the global snapshot are immediately visible
  • It has a write observer that notifies all registered globalWriteObservers
  • When mutable snapshots are applied, they merge into the global snapshot

Every modification you make outside of an explicit snapshot context happens in the global snapshot.

MutableSnapshot: Isolated changes

When you need to make changes that might be discarded or need conflict detection, you create a MutableSnapshot:

public open class MutableSnapshot internal constructor(
    snapshotId: SnapshotId,
    invalid: SnapshotIdSet,
    override val readObserver: ((Any) -> Unit)?,
    override val writeObserver: ((Any) -> Unit)?
) : Snapshot(snapshotId, invalid)

Mutable snapshots track several important pieces of state:

internal var modified: MutableScatterSet<StateObject>? = null  // Changed objects
internal var writeCount: Int = 0                               // Number of writes
internal var applied: Boolean = false                          // Successfully applied?
internal var previousIds: SnapshotIdSet = SnapshotIdSet.EMPTY  // Previous IDs for conflict detection

The modified set is critical, it tracks every StateObject that was written in this snapshot. During apply, these are the objects checked for conflicts. The writeCount is used by derived state to detect if any writes occurred.

StateRecord chains: The MVCC implementation

The magic of snapshots is in how state is actually stored. Every piece of mutable state is a StateObject with a linked list of StateRecord objects, each representing the state's value at a different snapshot ID.

The StateRecord structure

The StateRecord abstract class forms the foundation of the linked list structure, with each record holding a snapshot ID and a pointer to the next record in the chain:

public abstract class StateRecord(
    internal var snapshotId: SnapshotId
) {
    internal var next: StateRecord? = null

    public abstract fun assign(value: StateRecord)
    public abstract fun create(): StateRecord
    public open fun create(snapshotId: SnapshotId): StateRecord
}

The next pointer creates a singly-linked list of records. Each record has a snapshotId indicating which snapshot created it. The linked list structure is carefully designed to be lock-free:

// From the source comments:
// "Changes to [next] must preserve all existing records to all threads
// even during intermediately changes. For example, it is safe to add
// the beginning or end of the list but adding to the middle requires care.
// First the new record must have its [next] updated then the [next] of
// its new predecessor can then be set to point to it."

This lock-free design enables concurrent reads without synchronization.

StateObject: The container

Every mutable state object implements StateObject:

public interface StateObject {
    public val firstStateRecord: StateRecord
    public fun prependStateRecord(value: StateRecord)

    public fun mergeRecords(
        previous: StateRecord,
        current: StateRecord,
        applied: StateRecord
    ): StateRecord?
}

The firstStateRecord is the head of the StateRecord linked list. The mergeRecords function is how conflicts are resolved when snapshots are applied, more on that later.

SnapshotMutableState: The familiar API

When you call mutableStateOf(value), you get a SnapshotMutableStateImpl:

internal open class SnapshotMutableStateImpl<T>(
    value: T,
    override val policy: SnapshotMutationPolicy<T>
) : StateObjectImpl(), SnapshotMutableState<T> {

    override var value: T
        get() = next.readable(this).value
        set(value) = next.withCurrent {
            if (!policy.equivalent(it.value, value)) {
                next.overwritable(this, it) { this.value = value }
            }
        }

    private var next: StateStateRecord<T> =
        currentSnapshot().let { snapshot ->
            StateStateRecord(snapshot.snapshotId, value).also {
                if (snapshot !is GlobalSnapshot) {
                    it.next = StateStateRecord(
                        Snapshot.PreexistingSnapshotId.toSnapshotId(),
                        value
                    )
                }
            }
        }
}

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