Backwards Write

Compose executes a frame in three strictly ordered, forward-flowing phases:

Three forward-flowing phases: 1. Composition, 2. Layout, 3. Drawing
Figure 1. The three phases of a Compose frame
  1. Composition: Runs @Composable functions to build and update the UI tree.
  2. Layout: Measures children and then places them.
  3. 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.

Example of backwards write through the recomposition loop phases
Figure 2. Example of backwards write through the recomposition loop phases

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 componentHeight to determine what UI to emit. Later in the frame, the Layout phase measures or places views and writes a new value to componentHeight (for example, using onSizeChanged, onGloballyPositioned, or custom LayoutModifier).
  • Result: Modifying componentHeight in Layout invalidates the Composition phase that was just completed. Note that onSizeChanged reports 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, onGloballyPositioned runs 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, or Canvas (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 count in composable function, and modifying count directly 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(