skip to content

In Flutter, what does a RepaintBoundary change in the paint phase, and when does adding one help or hurt performance?

level: seniorimportance: should knowfreq 40%

answer

  1. markNeedsPaint walks up
  2. stops at nearest repaint boundary
  3. own OffsetLayer, reused picture
  4. ListView adds them per item
  5. symmetric vs asymmetric paint counts

basics

~20 s

RepaintBoundary gives its subtree its own layer: a repaint inside no longer repaints ancestors, and an ancestor repaint reuses its recorded content. It helps where parent and child repaint at different times and only adds cost where they repaint together.

solid answer

~50 s

When a render object calls `markNeedsPaint`, the request walks up the render tree until it reaches a **repaint boundary** (`isRepaintBoundary == true`); that boundary is added to the pipeline owner's dirty-paint list and everything from it down is re-recorded in `flushPaint`. A `RepaintBoundary` widget creates a `RenderRepaintBoundary`, which always owns an `OffsetLayer`. So a ticking indicator behind a boundary repaints only itself, and when the surrounding screen repaints, the boundary's existing layer is reused without re-recording its content. It helps at a seam where parent and child paint at **different** times, such as an animating typing indicator above a static comment thread. It hurts when both always repaint together: the extra layer costs memory and compositing work with no reuse. `ListView` already wraps each item (`addRepaintBoundaries: true`). Check with the repaint rainbow and `debugSymmetricPaintCount` vs `debugAsymmetricPaintCount`.

code

dart · 13 lines
dart
Column(
  children: [
    // Animates every frame while someone is typing.
    const RepaintBoundary(child: TypingIndicator()),
    Expanded(
      child: ListView.builder(
        // Each item is already wrapped: addRepaintBoundaries defaults to true.
        itemCount: comments.length,
        itemBuilder: (context, i) => CommentTile(comment: comments[i]),
      ),
    ),
  ],
)

go deeper

for a junior

Recall that RepaintBoundary limits how much of the screen repaints when a small part changes, and that ListView adds them for you.

for a middle

Explain how markNeedsPaint walks up to the nearest boundary and why the boundary's own layer lets an ancestor reuse its content.

for a senior

Place boundaries at seams where repaint timing differs, prove the gain with the repaint rainbow and the symmetric versus asymmetric counts, and remove the ones that only add layers.

for a principal

Treat layers as a budget: agree when a boundary is justified, require measurement over habit, and keep performance reviews focused on repaint regions rather than widget counts.

## How a repaint request travels Every `RenderObject` can be marked dirty for painting with `markNeedsPaint`. What happens next depends on `isRepaintBoundary`: - If the object **is** a repaint boundary that already has its layer, it adds itself to its `PipelineOwner`'s list of nodes needing paint and requests a frame. Nothing above it is involved. - If it is **not** a boundary, it calls `markNeedsPaint` on its **parent**, and the request keeps climbing until a boundary is found. The root `RenderView` is always one. In the frame's paint step, `PipelineOwner.flushPaint` repaints each dirty boundary, deepest first. "Repaint" means re-running `paint` for that boundary and every descendant that paints into the same layer, recording new drawing commands. So without any boundaries, a tiny change deep in the tree would re-record the whole screen. ## What a boundary actually gives you A render object that returns `true` from `isRepaintBoundary` gets its own **`OffsetLayer`**, created by the framework. Two effects follow: 1. **Changes inside stay inside.** A repaint request from a descendant stops at the boundary, so ancestors and siblings are not re-recorded. 2. **Outside changes reuse the inside.** When an ancestor repaints and the boundary itself is not dirty, the ancestor appends the boundary's existing layer to its own instead of re-running the boundary's `paint`. The `RepaintBoundary` widget is the everyday way to create one; it builds a `RenderRepaintBoundary`. Its documentation adds that a complex, static subtree behind a boundary may also be cached by the engine after rasterization, which is an engine-side decision, not a guarantee. ## When it helps The canonical case is a **seam** where the two sides repaint at different times: - an animating "someone is typing" indicator above a long, static comment thread; - a progress ring or clock ticking inside an otherwise still dashboard; - a custom-painted chart that only changes when its data changes, beside widgets that animate. Sometimes **two** boundaries are needed. The framework's own example is a mail app: a new message changes both the list and the unread counter, so only when **both** are behind boundaries does the rest of the screen stop repainting. Flutter already inserts boundaries in common places. `ListView`, `GridView` and the sliver child delegates default `addRepaintBoundaries` to `true`, wrapping each item; the Material `Drawer` places one so its content does not repaint while it slides. ## When it hurts | Situation | Effect of a boundary | |---|---| | Child and parent repaint at different times | fewer paint operations recorded per frame | | Child and parent always repaint together | an extra layer to allocate and composite, no saving | | Boundary around every small widget | many layers, more memory and compositing work | | Subtree that is expensive to paint and rarely changes | its layer can be reused while surroundings change | A boundary is not free: each one is a layer the engine has to hold and composite. Wrapping widgets "just in case" can make a screen slower. ## Measuring instead of guessing - **Repaint rainbow.** Setting `debugRepaintRainbowEnabled = true` (from `package:flutter/rendering.dart`, or DevTools' repaint highlighting) paints a rotating colour over each layer as it repaints, so you can see which regions re-record every frame. - **Boundary statistics.** In debug mode, each `RenderRepaintBoundary` counts `debugSymmetricPaintCount` (painted at the same time as its parent, so the boundary was redundant) and `debugAsymmetricPaintCount` (only one of the two painted, so the boundary saved work). `debugDumpRenderTree()` prints the ratio for every boundary. A boundary whose symmetric count dominates is a candidate for removal; a region that flashes every frame beside static content is a candidate for one. ## A checklist before adding one 1. Turn on the repaint rainbow and find a region that repaints every frame next to one that does not. 2. Place the boundary at the seam between them, not around each small widget. 3. Re-run the same interaction and confirm the static region stopped flashing. 4. Check the boundary's paint counts; if symmetric paints dominate, remove it again. ## Side use: capturing a widget as an image Because a `RenderRepaintBoundary` owns its layer, it can turn its last painted state into an image with `toImage(pixelRatio: ...)`, which is how "share this card as a picture" features are usually built. It requires the boundary to have gone through the paint phase with no repaint pending.

  • How do you tell whether an existing RepaintBoundary is paying for itself?
    In debug mode, read its `debugSymmetricPaintCount` and `debugAsymmetricPaintCount`, or call `debugDumpRenderTree()`, which prints the ratio for each boundary. Symmetric means the boundary painted together with its parent, so it saved nothing; asymmetric means one side painted alone, which is the case a boundary exists for.
  • Why does ListView wrap each item in a RepaintBoundary by default?
    While scrolling, the viewport moves items but their content usually does not change. With each item in its own layer, a scroll only repositions existing layers instead of re-recording every row, and a change inside one row does not repaint its neighbours. The `addRepaintBoundaries` parameter turns this off for very simple items.
  • Does a RepaintBoundary stop rebuilds or relayouts of its subtree?
    No. It only affects the paint phase. A `setState` above it still rebuilds the widgets below it, and layout changes still propagate through it according to the usual constraint rules. Reducing rebuilds is a different tool, such as moving state down or using const widgets.

A repaint boundary is like a glass pane in a shop window display. You can rearrange what is behind one pane without touching the rest, and redressing the window around it leaves that pane untouched. But framing every single item in its own pane just adds glass to carry, with no benefit when everything is changed together anyway.

saying these in an interview costs you the question

  • Wrapping every widget in a RepaintBoundary always makes the app faster.
  • A RepaintBoundary prevents the widgets inside it from rebuilding.
  • Items in a ListView need a RepaintBoundary added by hand to repaint separately.
  • markNeedsPaint repaints only the single render object that called it.
  • A repaint boundary has no memory or compositing cost.