Compose executes a frame in three strictly ordered, forward-flowing phases:
- Composition: Runs
@Composablefunctions to build and update the UI tree. - Layout: Measures children and then places them.
- Drawing: Emits canvas draw commands to render pixels to the screen.
Whenever a Compose State is read during any phase, Compose automatically
records a dependency between that state and the corresponding phase.
What makes a write "backwards"?
A backwards write occurs whenever a state is modified in a later phase (or in a downstream scope—meaning a composable scope executed later in order within the same composition pass) than where it was read, forcing Compose to recompose by scheduling an earlier phase or composable to run again. A backwards write is an unoptimized recomposition loop.
Consequences of backwards writes
Backwards writes aren't necessarily a bad thing, and they don't always trigger crashes, but they are inefficient and can harm app performance in several ways:
- Extra frame rendering and dropped frames: A backwards write forces Compose to execute redundant composition passes across consecutive frames, wasting CPU and GPU resources and potentially causing jank.
- First-frame correctness issues: If your component requires a backwards write to resolve its final dimensions or state, the first frame is rendered with invalid, default, or unsettled data (such as zero size or an incorrect offset). This causes visible visual popping or layout flashing when the second frame renders.
- Infinite recomposition loops: If a state change alters layout sizing, and layout sizing continuously writes back a new value to state, you can risk creating an infinite frame loop where the screen constantly recomposes every frame without ever stabilizing.
Forward flow through phases
State changes should always flow forward through the phases:
| Read Phase | Write Context | Acceptable? | Why |
|---|---|---|---|
Layout (Modifier.offset { }) |
Composition | Yes | Composition updates the state → Layout reads it later in the same frame without recomposing. |
Draw (graphicsLayer { }, drawBehind { }) |
Composition | Yes | Composition updates the state → Draw reads it in the final phase. Composition and Layout are skipped entirely. |
| Draw | Layout | Yes | Layout updates the state, and draw reads it, valid flow. |
| Composition | Event Callback (onClick, onValueChange) driving state change. Note: layout callbacks do not count as events. |
Yes | An event mutates state that is used to drive composition. If the event happens out-of-frame (not in Composition, Layout or Draw), this is valid. |
| Composition | Coroutine (LaunchedEffect) |
Yes - with caution | Asynchronously updates state in response to lifecycle/events. Writes from effects can be valid, but can point to inefficient state layering. They should be avoided where possible. |
| Placement (In Layout) | Measure (In Layout) | Yes | In Layout, updating the state then reading that state in placement is acceptable. |
| Measure (In Layout) | Placement (In Layout) | No - backwards write | Writing to state in placement that's then further in the read state, causes remeasure loop. |
| Composition | Layout (onSizeChanged, LayoutModifier) |
No - Backwards write | Layout invalidates Composition → Recomposition loop. |
| Composition | Draw (drawWithContent, Canvas) |
No - Backwards write | Draw invalidates Composition → Recomposition loop. |
Phase combinations: Backwards versus forward
The following are examples of backwards writes within Compose and how you can resolve them.
Backwards: Reading in Composition, writing in Layout
- What happens: Composition reads
componentHeightto determine what UI to emit. Later in the frame, the Layout phase measures or places views and writes a new value tocomponentHeight(for example, usingonSizeChanged,onGloballyPositioned, or customLayoutModifier). - Result: Modifying
componentHeightin Layout invalidates the Composition phase that was just completed. Note thatonSizeChangedreports size after the layout measurement pass completes. If the updated state value stabilizes on the next pass, the recomposition may halt after one extra frame; however, if the new value continues to alter the size, it results in an infinite frame loop. Furthermore,onGloballyPositionedruns after both layout and placement, making state writes inside it even more susceptible to continuous recomposition and relayout loops across consecutive frames.
// ❌ BAD: Read in Composition, Written in Layout (onSizeChanged) @Composable fun BadAspectRatioImage(painter: Painter) { var calculatedHeight by remember { mutableStateOf(0.dp) } val density = LocalDensity.current // State read during COMPOSITION: Image( painter = painter, contentDescription = "Dynamic Image", modifier = Modifier .fillMaxWidth() .height(calculatedHeight) .onSizeChanged { size -> // State write during LAYOUT phase! // Triggers backwards write and recomposition pass val aspectRatio = 16f / 9f val widthDp = with(density) { size.width.toDp() } calculatedHeight = widthDp / aspectRatio } ) } // ✅ GOOD: Measure and calculate aspect ratio height in Phase 2 (Layout) without recomposition @Composable fun GoodAspectRatioImage( painter: Painter, aspectRatio: Float = 16f / 9f, modifier: Modifier = Modifier ) { Layout( content = { Image( painter = painter, contentDescription = "Dynamic Image" ) }, modifier = modifier ) { measurables, constraints -> val width = constraints.maxWidth val height = (width / aspectRatio).toInt() // Illustrative, you can use Modifier.aspectRatio() val imageConstraints = constraints.copy( minWidth = width, maxWidth = width, minHeight = height, maxHeight = height ) val placeable = measurables.first().measure(imageConstraints) layout(width, height) { placeable.placeRelative(0, 0) } } }
Backwards: Reading in Composition, writing in Drawing
- What happens: State is read in the Composable body (Composition phase),
but mutated inside
Modifier.drawWithContent,Modifier.drawBehind, orCanvas(Draw phase). - Result: Draw phase mutates state → Composition invalidated → endless loop.
// ❌ BAD: Read in Composition, Written in Draw () @Composable fun BadBackwardsWriteDraw() { var componentHeight by remember { mutableStateOf(0.dp) } // State read during COMPOSITION: Text( text = "Height is: $componentHeight", modifier = Modifier.drawBehind { // State write during the DRAW phase! // Invalidates Composition -> triggers recomposition loop! componentHeight = size.height.dp } ) }
Backwards: Reading in Composition, writing in Composition (same phase)
- What happens: Reading
countin composable function, and modifyingcountdirectly in another composables content slot after reading it. - Result: The snapshot system records the read and subsequent write within the same composition pass, immediately invalidating the current scope.
// ❌ BAD: Direct write in Composable body after read @Composable fun BadCounter() { var count by remember { mutableIntStateOf(0) } Text("Count: $count") // State read in Composition Button(onClick = {}) { count++ // State write in Composition (Backwards write!) } } // Acceptable - but error-prone as someone may add a read before the write : Direct write in Composable body before read @Composable fun OkCounter() { var count by remember { mutableIntStateOf(0) } Button(onClick = {}) { count++ // State write in Composition } Text("Count: $count") // State read in Composition }
Key rules to prevent backwards writes
- Don't write to state in
onGloballyPositioned,onSizeChanged, orLayoutModifierif that state is read in Composition as this causes the first-frame-correctness problem.- If layout coordinates or sizes are only needed for custom drawing, read
them directly in the Draw or Layout phase (for example, using
Modifier.drawWithCacheorModifier.layout). - For Window-Level Sizing (
WindowWidthSizeClass): Hoist size observation to the window level. Composition branches on window size classes before local measurement occurs. - Keep Composition Uniform: Use a single custom Layout or components
like
FlowRoworLazyVerticalGridthat adjust measurement and placement during Phase 2 without recomposing or altering state used in composition. - Use Sub-composition: Use
BoxWithConstraintsorSubcomposeLayoutwhen child composables must branch based on local width or height. Use caution: Subcomposition carries a performance cost and can usually be avoided. - As a last resort: Allow the first frame to be wrong, storing size in
onSizeChangedto trigger a second frame recomposition. This causes visible layout popping, jank, and risks infinite loops.
- If layout coordinates or sizes are only needed for custom drawing, read
them directly in the Draw or Layout phase (for example, using
- Don't mutate state after its read the first time in composition:
- Although you may safely write to
MutableStateobjects during composition outside of aSideEffect, take special care to ensure that you don't write to a state that you may have previously read in composition. It's recommended to userememberUpdatedStatewhen this need for a state write in composition arises. Writing to a state during composition in another way is usually a sign of a missing effect or an inappropriately designed state or composable. Remember that composition is optimistic and always executes with the latest value of a state, so you may not see all state changes in recompositions. UI updates shouldn't be used as a way of handling one-time events, which makes it uncommon for a state's value to require being updated as a result of recomposing. - Avoid mutating states that are observed outside of composition (for
example,
ViewModelfields orisVisibleflags). State writes that affect composition belong in event lambdas (onClick), coroutines (LaunchedEffect), or side effects (SideEffect).rememberUpdatedStateis an exception as it is designed to mutate state that is only used in@Composablebody.
- Although you may safely write to
- Defer state reads to the latest possible phase:
- Reading states in Draw (
Modifier.graphicsLayer { alpha = ... }) or Layout (Modifier.offset { IntOffset(...) }) ensures that changes only invalidate Phase 2 or 3, completely skipping Phase 1 (Composition).
- Reading states in Draw (