SnapshotApplyConflictException retry behavior lacks configurable bounds during recomposition
0 reputation · 18 Feb 2022, 14:13 UTC
0 reputation · 18 Feb 2022, 14:13 UTC
Jetpack Compose's snapshot system uses optimistic concurrency: when Snapshot.apply() detects that another snapshot has committed changes to the same State record since the current snapshot was opened, it throws SnapshotApplyConflictException and discards the pending writes. The recomposition machinery catches this exception internally and restarts the affected composition from a fresh snapshot.
The runtime documentation states only that "the operation must be retried" without defining a maximum retry count, back-off strategy, or timeout for these automatic restarts. In practice this means a hotly contended State object can cause a composable to re-execute indefinitely, amplifying any side-effects that are not properly guarded by remember or LaunchedEffect.
androidx.compose.runtime:runtime)SnapshotApplyConflictException directly in app code is discouraged as an internal implementation detailGiven these constraints, how does the recomposition scheduler decide when to stop retrying a composition that repeatedly encounters SnapshotApplyConflictException? Is there an implicit bound tied to frame deadlines, or can a pathological contention scenario livelock the UI thread? What observability hooks (if any) exist to detect excessive retry rates in production?
30275 reputation · 18 Feb 2022, 22:31 UTC
The recomposer does not expose a configurable retry limit. It relies on the frame‑callback loop: each composition must finish before the next VSYNC. If a SnapshotApplyConflictException occurs, the composition is retried immediately, but the next attempt is only scheduled after the current frame’s deadline. In practice this means the implicit bound is roughly one retry per frame (≈60 retries per second on a 60 Hz display). If contention is so high that the UI thread can’t finish a recomposition before the next deadline, the scheduler keeps restarting the same composition every frame, effectively saturating the UI thread until contention subsides or the process is killed.
SnapshotApplyConflictException can lead to a livelock that consumes the UI thread.adb shell setprop debug.tracing.enable 1
adb shell am start -n your.package/.YourActivity
adb shell setprop debug.tracing.enable 0
adb pull /data/local/tracing/trace.txt
Look for repeated Recomposer.runRecomposeAndApplyChanges spans that occur back‑to‑back without intervening FrameCallback boundaries.
adb shell top -m 10 -t
High CPU usage on the main thread during a contention test indicates the scheduler is retrying every frame.
LaunchedEffect(Unit) {
var counter = 0
snapshotFlow { myState.value }
.onEach { counter++ }
.launchIn(this)
Log.d("ComposeRetry", "Recompositions: $counter")
}
When the counter stops growing after a few seconds, the frame deadline bound is being hit.
remember, derivedStateOf, or LaunchedEffect blocks.MutableState only for UI‑visible data; use plain objects for heavy logic.To refine the recommendation, it would help to know the device’s refresh rate (e.g., 60 Hz vs 90 Hz). The implicit retry bound scales with the frame period, so a higher refresh rate allows more retries per second before saturating the UI thread.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.