Lesson 3

Apply is a commit attempt

Trace successful and conflicting snapshot applies without confusing consistency with recomposition scheduling.

4 min readUpdated Jul 12, 2026

Status: Apply is a visibility and conflict boundary, not a recomposition command. That separation is Durable . The previous/current/applied comparison and SnapshotApplyResult classes are Version-specific at the pinned revision.

Outcome

Given two mutable snapshots that write the same state object, you should be able to predict whether the second apply succeeds, fails, or asks a mutation policy to merge values.

Apply closes an isolation interval

A mutable snapshot accumulates modified StateObjects. apply() checks those writes before making them visible to global state or to the parent snapshot. On success, the changes become atomically visible at that boundary and the snapshot closes its active state. The result is SnapshotApplyResult.Success (Snapshot.kt).

A failed apply returns SnapshotApplyResult.Failure. Calling check() on that result disposes the snapshot and throws SnapshotApplyConflictException; the failed snapshot’s changes are not made visible (Snapshot.kt). This is why a test should assert both the result and the value that remains visible.

Diagram
flowchart TD
    First[First snapshot write] --> ApplyOne[First apply]
    Second[Second snapshot write] --> Check[Conflict check]
    ApplyOne --> Current[Current global record]
    Current --> Check
    Check -->|No conflict| Success[Success and publish]
    Check -->|Conflict| Merge[Try policy merge]
    Merge -->|Merge value| Success
    Merge -->|No merge| Failure[Failure and no publish]

What counts as a conflict

The pinned innerApplyLocked routine compares three records for each modified state object:

  • previous — the record the applying snapshot started from;
  • current — the record now visible in the parent or global snapshot; and
  • applied — the applying snapshot’s record.

If both the applying snapshot and the parent/global side changed from previous, the runtime has a potential conflict. If one side did not change, the write can be applied without a merge. If both sides changed, the state object’s mergeRecords method is asked to resolve the situation (Snapshot.kt).

The default StateObject.mergeRecords returns null, so a state object without a merge policy cannot resolve a genuine collision. SnapshotMutableStateImpl delegates the decision to its SnapshotMutationPolicy; Lesson 4 explains that hook (SnapshotState.kt).

An equal result can avoid a conflict. Two snapshots that both change an integer from 0 to 1 can apply successfully under the default structural policy because the values are equivalent. That does not mean all same-looking writes are conflict-free: a referential or never-equal policy can make a different decision.

Apply is not a scheduling event

Applying a snapshot can notify snapshot apply observers. It does not itself run a composition, request a frame, or guarantee that a Recomposer immediately executes. A composition may consume the observation later, and the scheduler is a separate causal layer. Keep this chapter’s statement narrow:

Apply decides whether a snapshot’s records can become visible together.

Chapter 6 follows how observed writes become invalidation work. Chapter 7 will cover scheduling and frames. Neither is needed to understand why a conflicting snapshot is rejected.

Reproduce success and failure

This is a pinned upstream-test procedure, not an experiment run for this lesson. In an AndroidX checkout at cc1caf65677fc10a8ce8116eba46e716f4cef222, run:

./gradlew :compose:runtime:runtime:desktopTest \
  --tests 'androidx.compose.runtime.snapshots.SnapshotTests.applyingCollidingSnapshotsWillFail' \
  --tests 'androidx.compose.runtime.snapshots.SnapshotTests.snapshotsChangesCanMerge' \
  --tests 'androidx.compose.runtime.snapshots.SnapshotTests.statesWithStructuralEqualityPolicyMerge' \
  --tests 'androidx.compose.runtime.snapshots.SnapshotTests.throwInApplyWithMutableSnapshot'

The control is snapshotsChangesCanMerge: both snapshots write 1, and both applies should succeed with global value 1. The mutation is applyingCollidingSnapshotsWillFail: write 1 and 2 from separate snapshots, apply the first, then expect the second to report failure while global remains 1. Expected: success publishes atomically; failure leaves the already-applied value visible. Interpretation limit: the tests cover the pinned common runtime. They do not prove serializability; the implementation comments explicitly note that the current algorithm does not guarantee serializable snapshots. They also do not measure notification latency or recomposition.

Misconceptions

  • “Apply always succeeds because snapshots are isolated.” Isolation delays the collision; apply still checks it.
  • “Failure rolls back every earlier mutation.” The failed snapshot is not published; earlier successful applies remain visible.
  • “Apply means recomposition happened.” Apply is a snapshot consistency boundary.
  • “Same value always means no conflict.” Equivalence depends on the state’s policy.

Check yourself

Snapshot A and Snapshot B both start at 0. A writes 1; B writes 2; A applies first. Which three records does B’s apply conceptually compare, and why can its default merge fail?

Source notes

ClaimDirect evidenceStatus
apply() publishes atomically to global or parent stateSnapshot.ktDurable Version-specific
Apply compares previous, current, and applied recordsSnapshot.ktVersion-specific
Failure is explicit and check() throwsSnapshot.ktVersion-specific
Conflicting and mergeable cases are testedSnapshotTests.ktVersion-specific

Freshness

Refresh this lesson after changes to innerApplyLocked, SnapshotApplyResult, StateObject.mergeRecords, or conflict tests. The current release notes record recent fixes to snapshot apply observers and merge-policy behavior; check the Compose Runtime release notes before mapping this consistency model onto a released artifact.

Finished this lesson?

Your progress stays only in this browser.