Compose's Three Phase Rendering Pipeline
Compose's Three Phase Rendering Pipeline
Every frame in Compose goes through three ordered phases: Composition, Layout, and Drawing. Composition reexecutes composable functions to produce or update the node tree. Layout measures each node and assigns its position. Drawing renders the final pixels to the canvas. While most developers know these phases from documentation, the deeper question is how the runtime enforces this ordering, how each phase decides what work to do, and how state reads are scoped so that a change observed during drawing does not trigger recomposition.
By the end of this lesson, you will be able to:
- Explain how
dispatchDraw()enforces the Composition, Layout, Drawing order. - Describe how the Recomposer schedules and performs recomposition within a frame.
- Trace how
MeasureAndLayoutDelegatemeasures and places dirty nodes in depth order. - Explain how
NodeCoordinator.draw()walks the modifier chain and renders children. - Identify how
OwnerSnapshotObserverscopes state reads to specific phases for targeted invalidation.
The Frame Loop and Phase Ordering
On Android, the entry point for each frame is AndroidComposeView.dispatchDraw(). The Android View system calls this method when it is time to draw the Compose hierarchy. Inside dispatchDraw(), the phase ordering is enforced directly in code:
override fun dispatchDraw(canvas: android.graphics.Canvas) {
if (!isAttachedToWindow) {
invalidateLayers(root)
}
measureAndLayout()
Snapshot.notifyObjectsInitialized()
isDrawingContent = true
canvasHolder.drawInto(canvas) {
root.draw(canvas = this, graphicsLayer = null)
}
}
The call to measureAndLayout() runs the layout phase, measuring and placing any nodes that were marked dirty by composition. After layout completes, root.draw() walks the tree and renders every node to the canvas.
Composition happens before dispatchDraw() is ever called. The Recomposer runs in a coroutine on the UI thread, synchronized to the Choreographer frame callback. By the time the View system calls dispatchDraw(), composition has already finished and the node tree is up to date. Layout and drawing then execute in sequence within the same method.
Composition: Rebuilding the Node Tree
The Recomposer drives composition. It runs a long lived coroutine that waits for invalidation signals, aligns with the next Choreographer frame, and then reexecutes any composable functions whose state dependencies have changed.
The core loop lives in runRecomposeAndApplyChanges(). It waits for work to become available, then enters a frame callback to perform the actual recomposition:
while (shouldKeepRecomposing) {
awaitWorkAvailable()
if (!recordComposerModifications()) continue
parentFrameClock.withFrameNanos { frameTime ->
// Propagate the frame time to anyone who is awaiting
// from the recomposer clock.
if (hasBroadcastFrameClockAwaiters) {
broadcastFrameClock.sendFrame(frameTime)
Snapshot.sendApplyNotifications()
}
// Drain any composer invalidations from snapshot changes
// and record composers to work on
recordComposerModifications()
synchronized(stateLock) {
compositionInvalidations.forEach { toRecompose += it }
compositionInvalidations.clear()
}
// Perform recomposition for any invalidated composers
toRecompose.fastForEach { composition ->
performRecompose(composition, modifiedValues)?.let {
toApply += it
}
}
toRecompose.clear()
// Apply changes from recomposition to the node tree
toApply.fastForEach { composition ->
composition.applyChanges()
}
toApply.clear()
}
}
awaitWorkAvailable() suspends until a snapshot state change invalidates at least one composition. recordComposerModifications() collects which compositions need work. Then parentFrameClock.withFrameNanos aligns execution with the next Choreographer frame. Inside the frame callback, the Recomposer first broadcasts the frame time to any animation awaiters, then calls performRecompose() for each invalidated composition.
performRecompose() itself is straightforward. It calls composition.recompose(), which reexecutes the invalidated composable functions and records any changes to the node tree:
private fun performRecompose(
composition: ControlledComposition,
modifiedValues: MutableScatterSet<Any>?,
): ControlledComposition? {
if (composition.isComposing || composition.isDisposed) return null
return if (composing(composition, modifiedValues) {
composition.recompose()
}) composition else null
}
After all compositions finish, their changes are applied to the underlying LayoutNode tree. Nodes may be added, removed, or have their properties updated. Any node whose measurement or layout is affected gets marked as measurePending or layoutPending, which the layout phase will process next.
Layout: Measuring and Placing Nodes
The layout phase is driven by MeasureAndLayoutDelegate, which maintains a depth sorted set called relayoutNodes. This set contains every LayoutNode that needs remeasuring or relaying out. Depth sorting is the key design decision here: it ensures that parent nodes are always processed before their children, because a parent's constraints determine how its children are measured.
measureAndLayout() iterates through this set and processes each node:
fun measureAndLayout(onLayout: (() -> Unit)? = null): Boolean {
var rootNodeResized = false
performMeasureAndLayout(fullPass = true) {
if (relayoutNodes.isNotEmpty()) {
relayoutNodes.popEach { layoutNode, affectsLookahead, relayoutNeeded ->
val sizeChanged = if (relayoutNeeded) {
remeasureAndRelayoutIfNeeded(layoutNode, affectsLookahead)
} else {
remeasureIfNeeded(layoutNode, affectsLookahead)
}
if (layoutNode === root && sizeChanged) {
rootNodeResized = true
}
}
onLayout?.invoke()
}
}
return rootNodeResized
}
For each node popped from the depth sorted set, the delegate calls remeasureAndRelayoutIfNeeded(). This method checks which work the node actually needs:
private fun remeasureAndRelayoutIfNeeded(layoutNode: LayoutNode): Boolean {
var sizeChanged = false
if (layoutNode.measurePending) {
sizeChanged = doRemeasure(layoutNode, constraints)
}
if (layoutNode.layoutPending) {
if (layoutNode === root) {
layoutNode.place(0, 0)
} else {
layoutNode.replace()
}
}
return sizeChanged
}
If measurePending is true, the node is remeasured via doRemeasure(). If measurement produces a size change and the parent measured this child during its own measure block, the parent itself is added back to relayoutNodes via requestRemeasure(). This upward propagation continues until no more parents are affected.
If layoutPending is true after measurement, the node is placed. The root node is placed at (0, 0). Other nodes call replace(), which reexecutes the parent's placement logic for that child.
Drawing: Rendering to the Canvas
Drawing starts at LayoutNode.draw(), which delegates to the outermost NodeCoordinator:
internal fun draw(canvas: Canvas, graphicsLayer: GraphicsLayer?) =
outerCoordinator.draw(canvas, graphicsLayer)
NodeCoordinator.draw() handles the coordinate translation and decides whether to use a graphics layer:
fun draw(canvas: Canvas, graphicsLayer: GraphicsLayer?) {
val layer = layer
if (layer != null) {
layer.drawLayer(canvas, graphicsLayer)
} else {
val x = position.x.toFloat()
val y = position.y.toFloat()
canvas.translate(x, y)
drawContainedDrawModifiers(canvas, graphicsLayer)
canvas.translate(-x, -y)
}
}
If the node has a graphics layer (created by Modifier.graphicsLayer), drawing is delegated to the layer, which can cache its content as a display list. If there is no layer, the coordinator translates the canvas to the node's position and calls drawContainedDrawModifiers().
drawContainedDrawModifiers() checks for draw modifier nodes in the chain. If present, it passes them to LayoutNodeDrawScope for execution. If there are no draw modifiers, it falls through to performDraw(). The InnerNodeCoordinator at the end of the chain implements performDraw() by iterating the node's children:
override fun performDraw(canvas: Canvas, graphicsLayer: GraphicsLayer?) {
layoutNode.zSortedChildren.forEach { child ->
if (child.isPlaced) {
child.draw(canvas, graphicsLayer)
}
}
}
Children are iterated in z order, and only placed children are drawn. Each child's draw() call recurses back into NodeCoordinator.draw(), walking the entire tree depth first.
Phase Scoped Invalidation
The most important optimization in the three phase system is that state reads are scoped to the phase where they occur. OwnerSnapshotObserver registers different callbacks depending on which phase is reading the state:
internal class OwnerSnapshotObserver(onChangedExecutor: (callback: () -> Unit) -> Unit) {
private val observer = SnapshotStateObserver(onChangedExecutor)
private val onCommitAffectingMeasure: (LayoutNode) -> Unit = { layoutNode ->
if (layoutNode.isValidOwnerScope) {
layoutNode.requestRemeasure()
}
}
private val onCommitAffectingLayout: (LayoutNode) -> Unit = { layoutNode ->
if (layoutNode.isValidOwnerScope) {
layoutNode.requestRelayout()
}
}
}
When a state object is read during measurement, the onCommitAffectingMeasure callback is registered. When that state later changes, the callback calls requestRemeasure(), which marks the node for remeasurement and relayout in the next frame. This is the most expensive invalidation because it may propagate to parent nodes.
When a state is read during layout (placement), the onCommitAffectingLayout callback is registered instead. A change to that state calls requestRelayout(), which skips measurement entirely and only reexecutes placement.
Drawing works differently. Draw modifier blocks execute inside snapshotObserver.observeReads() with a layer level callback. When a state read during drawing changes, only the graphics layer is invalidated. The node is not remeasured or relaid out. The layer simply redraws its content on the next frame. This is the cheapest form of invalidation.
This scoping is why reading an Animatable value inside Modifier.drawWithContent {} does not trigger recomposition or relayout. The state read is observed only at the draw level, and the invalidation only affects the drawing layer. The composition and layout phases are skipped entirely.
Summary
Compose renders each frame in three sequential phases. Composition runs first via the Recomposer's frame aligned coroutine, reexecuting invalidated composable functions and updating the node tree. Layout runs next inside dispatchDraw(), where MeasureAndLayoutDelegate processes dirty nodes in depth order, remeasuring and replacing as needed. Drawing runs last, with NodeCoordinator walking the modifier chain and InnerNodeCoordinator recursing through z sorted children. OwnerSnapshotObserver scopes state reads to the phase where they occur, so a state change observed during drawing invalidates only the layer, while a state change observed during measurement triggers the full measure, layout, draw sequence.
Practical Questions
Explain how Compose's three rendering phases are ordered within a single frame, and why reading state during the draw phase does not trigger recomposition.
The three phases execute in a fixed order: Composition, then Layout, then Drawing. Composition runs first because the Recomposer is synchronized to the Choreographer frame callback. When a snapshot state changes, the Recomposer's runRecomposeAndApplyChanges() loop detects the invalidation via awaitWorkAvailable(), then waits for the next frame using parentFrameClock.withFrameNanos. Inside the frame callback, it calls performRecompose() for each invalidated composition, which reexecutes the affected composable functions and applies changes to the LayoutNode tree.
After composition completes and changes are applied, the Android View system calls AndroidComposeView.dispatchDraw(). The first thing dispatchDraw() does is call measureAndLayout(), which runs the layout phase. MeasureAndLayoutDelegate iterates its depth sorted relayoutNodes set, remeasuring nodes that have measurePending set to true and replacing nodes with layoutPending set to true. Depth sorting ensures parents are processed before children, because a parent's constraints determine child sizing.
After layout completes, dispatchDraw() calls root.draw(), which begins the drawing phase. NodeCoordinator.draw() walks the modifier chain for each node, applying graphics layers or direct canvas translations, and InnerNodeCoordinator.performDraw() recurses through z sorted children.
State reads during drawing do not trigger recomposition because of OwnerSnapshotObserver's phase scoped callbacks. When a composable function reads state during composition, the Recomposer observes that read and registers a composition level invalidation. But when a draw modifier reads state, the read is observed by snapshotObserver.observeReads() with a layer level callback. When that state changes, only the graphics layer is marked dirty. The layer redraws on the next frame without going through composition or layout. This is why animating a value inside Modifier.drawWithContent {} is efficient: it skips the two most expensive phases entirely.
Follow-up Q: How does MeasureAndLayoutDelegate ensure that parent nodes are measured before their children, and what happens when a child's size change affects its parent?
MeasureAndLayoutDelegate maintains a relayoutNodes collection that is sorted by node depth in the tree. When measureAndLayout() iterates this collection, it processes nodes from the shallowest depth to the deepest. This guarantees that a parent is always measured before its children, which is necessary because a parent passes constraints downward during measurement.
When a child is remeasured via doRemeasure() and the measurement produces a different size, the delegate checks how the parent originally measured that child. It looks at the child's measuredByParent property, which tracks whether the child was measured during the parent's measure block or during the parent's layout block.
If measuredByParent equals InMeasureBlock, the child was measured as part of the parent's MeasureScope.measure() call. A size change means the parent's own measurement is invalid. The delegate calls parent.requestRemeasure(), which adds the parent back to relayoutNodes. Because the parent has a lower depth, it will be processed again before any of its other children.
If measuredByParent equals InLayoutBlock, the child was measured during the parent's layout phase, not its measure phase. In this case, the parent's own size was not affected, so only a relayout is needed. The delegate calls parent.requestRelayout() instead, which is less expensive because it skips remeasurement of the parent entirely.
This depth ordered processing with upward propagation ensures that the tree converges to a consistent state: every node's constraints, size, and position reflect the final values of its ancestors, even when changes ripple upward through the hierarchy.

