Post

Android Jetpack Compose Render Phases Explained

Deep dive into the three render phases of Jetpack Compose — Composition, Layout, and Drawing — and how understanding them unlocks better performance.

Android Jetpack Compose Render Phases Explained

Introduction

Jetpack Compose renders your UI through a precise, ordered pipeline. Every frame your app produces passes through three distinct phases: Composition, Layout, and Drawing. Understanding how these phases work — and how Compose decides to skip or re-run them — is essential for writing performant Compose code and is a very common topic in Android technical interviews.

If you’ve ever wondered why changing a color feels instant but repositioning an element costs more, or how Compose knows which parts to update, the answer lies in these three phases.


Concept Overview

Each time Compose needs to produce a frame, it executes (when necessary) the following phases in order:

  1. CompositionWhat to show. Composable functions run and produce a UI tree.
  2. LayoutWhere to place elements. Each node is measured and positioned.
  3. DrawingHow to render. Each node paints itself onto the canvas.

Data flows one direction through these phases (unidirectional data flow). Compose is smart enough to skip phases that are not needed. For example:

  • If only a color changes → skip Composition and Layout, only run Drawing.
  • If only a position changes → skip Composition, only run Layout and Drawing.
  • If content changes → all three phases run.

This optimization is possible because Compose tracks exactly where each state is read.


Flow / Architecture Diagram

The Three Phases Pipeline

flowchart TD
    A["State Change"] --> B["Phase 1: Composition<br/>Execute @Composable functions<br/>Build UI node tree"]
    B --> C["Phase 2: Layout<br/>Measure children<br/>Decide sizes<br/>Place children"]
    C --> D["Phase 3: Drawing<br/>Traverse tree top to bottom<br/>Draw each node on Canvas"]
    D --> E["Frame Displayed"]

    style A fill:#6366f1,color:#fff
    style B fill:#8b5cf6,color:#fff
    style C fill:#7c3aed,color:#fff
    style D fill:#6d28d9,color:#fff
    style E fill:#4c1d95,color:#fff

Layout Phase — Measurement Algorithm

flowchart TD
    R["Row (root)"] --> IM["Measure Image"]
    R --> CM["Measure Column"]
    CM --> T1["Measure Text 1"]
    CM --> T2["Measure Text 2"]
    T1 --> T1S["Report size to Column"]
    T2 --> T2S["Report size to Column"]
    T1S --> CS["Column decides own size<br/>(max width, sum height)"]
    T2S --> CS
    IM --> IS["Report size to Row"]
    CS --> RS["Row decides own size<br/>(max height, sum widths)"]
    IS --> RS
    RS --> P["Place children"]

    style R fill:#059669,color:#fff
    style P fill:#047857,color:#fff

Phase Skipping Optimization

flowchart LR
    SC["State Change<br/>in Composition"] --> A1["Composition ✅"]
    A1 --> B1["Layout ✅"]
    B1 --> C1["Drawing ✅"]

    SL["State Change<br/>in Layout"] --> A2["Composition ⏭️ skip"]
    A2 --> B2["Layout ✅"]
    B2 --> C2["Drawing ✅"]

    SD["State Change<br/>in Drawing"] --> A3["Composition ⏭️ skip"]
    A3 --> B3["Layout ⏭️ skip"]
    B3 --> C3["Drawing ✅"]

    style SC fill:#6366f1,color:#fff
    style SL fill:#0891b2,color:#fff
    style SD fill:#0d9488,color:#fff

Key Concepts

Phase 1: Composition

Compose runs your @Composable functions and builds a UI node tree that describes what to display. This tree is then fed into the next phase.

  • State read inside a @Composable function triggers recomposition when the state changes.
  • Compose may skip re-running a composable if its inputs haven’t changed (smart recomposition).
  • Changing state read during composition will also trigger Layout and Drawing unless Compose can determine nothing else changed.
1
2
3
4
5
6
7
8
var padding by remember { mutableStateOf(8.dp) }

Text(
    text = "Hello",
    // State `padding` read during Composition
    // Any change triggers full recomposition
    modifier = Modifier.padding(padding)
)

Phase 2: Layout

The Layout phase traverses the UI tree using a single-pass measurement algorithm with three steps:

  1. Measure children — a node asks all its children for their sizes.
  2. Decide own size — based on children measurements, the node decides its size.
  3. Place children — each child is positioned relative to the parent.

The single-pass approach keeps traversal time linear (O(n)) rather than exponential.

State reads during Layout (e.g., inside LayoutModifier, Modifier.offset { } lambda) trigger only Layout and Drawing on change, skipping Composition.

1
2
3
4
5
6
7
8
9
10
var offsetX by remember { mutableStateOf(8.dp) }

Text(
    text = "Hello",
    modifier = Modifier.offset {
        // State `offsetX` read during Layout phase (placement step)
        // Changes only re-trigger Layout + Drawing
        IntOffset(offsetX.roundToPx(), 0)
    }
)

Phase 3: Drawing

Compose traverses the tree top to bottom and each node renders itself onto the Canvas.

State reads inside drawing APIs (e.g., Canvas(), Modifier.drawBehind, Modifier.drawWithContent) trigger only the Drawing phase on change.

1
2
3
4
5
6
7
var color by remember { mutableStateOf(Color.Red) }

Canvas(modifier = modifier) {
    // State `color` read during Drawing phase
    // Changes only re-trigger Drawing
    drawRect(color)
}

When to Use in Real Apps

ScenarioRecommended Approach
Animate element color or alphaRead state in drawBehind / Canvas → Drawing phase only
Animate element positionRead state in Modifier.offset { } lambda → Layout phase only
Show/hide entire composableRead state in composition → Composition + Layout + Drawing
Parallax scroll effectUse Modifier.offset { } lambda (Layout), not Modifier.offset(Dp) (Composition)
Derived or filtered stateUse derivedStateOf to avoid unnecessary recompositions

Comparison Table

AspectCompositionLayoutDrawing
What it doesBuilds UI treeMeasures & places nodesRenders pixels
Triggered by@Composable state readsLayoutModifier state readsdrawBehind / Canvas state reads
Can be skipped?✅ Yes✅ Yes✅ Yes
Performance costHigh (re-runs Kotlin code)Medium (traverses tree)Low (GPU-accelerated)
Key APIs@Composable, rememberLayout, Modifier.offset{}Canvas, Modifier.drawBehind

Common Interview Questions

Q1: What are the three render phases in Jetpack Compose?

Composition (builds UI tree by running @Composable functions), Layout (measures and places each node using a single-pass algorithm), and Drawing (renders each node onto the Canvas top to bottom).

Q2: How does Compose optimize rendering performance?

Compose tracks exactly where each state value is read. If a state change only affects the Drawing phase (e.g., a color change), Compose can skip Composition and Layout entirely, running only the Drawing phase.

Q3: What is the difference between Modifier.offset(Dp) and Modifier.offset { IntOffset }?

Modifier.offset(Dp) reads state during Composition, causing recomposition on every scroll event. Modifier.offset { } (lambda form) reads state during the Layout placement step, so only Layout and Drawing are re-triggered — which is significantly more performant for scroll-driven animations.

Q4: What is a recomposition loop and how does it happen?

A recomposition loop occurs when a state is written during the Layout phase (e.g., via Modifier.onSizeChanged) and that same state is then read during Composition. This creates a multi-frame cycle: the UI renders incorrectly in the first frame, triggers a recomposition for the second frame, and potentially loops. The fix is to use proper layout primitives (e.g., Column, Row) or a custom layout that eliminates the need for inter-phase state sharing.

Q5: Why does the Layout phase use a single-pass measurement algorithm?

A single-pass algorithm ensures traversal time scales linearly with the number of nodes (O(n)). If each node were visited multiple times (as in older View systems with multiple measurement passes), traversal time would grow exponentially, degrading performance in deep UI hierarchies.


Common Mistakes / Pitfalls

⚠️ Reading scroll offset in Composition for visual offsets

1
2
3
4
5
6
7
8
9
// ❌ Reads state during Composition → full recomposition on every scroll
Modifier.offset(
    with(LocalDensity.current) { (listState.firstVisibleItemScrollOffset / 2).toDp() }
)

// ✅ Reads state during Layout → only Layout + Drawing re-triggered
Modifier.offset {
    IntOffset(x = 0, y = listState.firstVisibleItemScrollOffset / 2)
}

⚠️ Using onSizeChanged + padding to create relative layouts

  • This creates a recomposition loop across multiple frames.
  • Fix: Use Column, Row, or write a custom Layout composable.

⚠️ Assuming all state changes are equally expensive

  • A state read in Drawing only triggers Drawing.
  • A state read in Composition triggers all three phases.
  • Place state reads at the lowest phase possible.

⚠️ Forgetting that BoxWithConstraints, LazyColumn, and LazyRow are exceptions

  • Their children’s Composition depends on the parent’s Layout phase (the layout phase determines which children to compose).
  • These break the strict “Composition before Layout” order.

Best Practices

Defer state reads to the latest possible phase — prefer Drawing-phase reads over Layout-phase reads, and Layout-phase reads over Composition-phase reads when the logic allows.

Use the lambda form of Modifier.offset { } for scroll-driven parallax effects instead of the Dp-based overload.

Use derivedStateOf to filter state changes and avoid unnecessary recompositions.

Prefer layout primitives (Column, Row, Box, ConstraintLayout) over manual onSizeChanged + padding patterns.

Use Modifier.drawBehind or Canvas for color/alpha animations to bypass Composition and Layout entirely.

Profile with Layout Inspector and Composition Tracing to identify which phases are being triggered and how often.


Quick Cheatsheet

ScenarioWhere state is readPhases triggered
Show/hide content@Composable bodyComposition → Layout → Drawing
Animate positionModifier.offset { } lambdaLayout → Drawing
Animate color / alphaModifier.drawBehind / CanvasDrawing only
Scroll-driven parallaxModifier.offset { } lambdaLayout → Drawing
Filter state changesderivedStateOfReduced recompositions
Relative sizingUse Column / custom LayoutAvoids recomposition loops

Summary

Key takeaways:

  • Compose renders every frame through three ordered phases: Composition → Layout → Drawing.
  • Composition runs @Composable functions and builds a UI node tree.
  • Layout uses a single-pass algorithm to measure and place each node in O(n) time.
  • Drawing traverses the tree top to bottom and renders each node onto the Canvas.
  • Compose skips phases that are not affected by a state change, making it highly efficient.
  • State reads are tracked per phase — reading state at a later phase means fewer phases are re-triggered on change.
  • The lambda form of Modifier.offset { } defers the state read from Composition to Layout, a key optimization pattern.
  • Avoid recomposition loops caused by onSizeChanged + layout modifier patterns; use proper layout primitives instead.
This post is licensed under CC BY 4.0 by the author.