NK

Search

Search pages, posts, and components

All posts
Mobile9 min read

How Flutter Rendering Actually Works

The three trees, why constraints go down and sizes go up, what a rebuild really costs, and how that knowledge turns performance guesswork into diagnosis.

flutterdartrenderingperformance

Most Flutter developers can ship a whole app without knowing how a frame is produced. That works until something is slow, or a layout does something inexplicable, and the usual advice - sprinkle const, add a RepaintBoundary, wrap it in a Builder - turns into cargo cult debugging.

The underlying model is actually simple, and knowing it converts that guesswork into diagnosis. Widgets are not what is on screen. They are a description of what should be on screen, thrown away and recreated constantly, and the machinery underneath decides how little real work that description requires.

This post covers the three trees, the layout protocol, what happens on a rebuild, and how each piece maps to a performance decision you will actually make.

Three trees, three jobs

The single most useful thing to internalise: there are three parallel trees, and almost every confusing Flutter behaviour comes from attributing one tree's job to another.

Widgets are immutable descriptions

A widget is a configuration object. It is immutable, cheap to allocate, and discarded on every rebuild. Text('hi') does not draw anything - it describes text that should exist.

This is why build() being called sixty times a second is not inherently a problem. Allocating a few hundred small immutable objects is fast, and Dart's generational collector is tuned for exactly this pattern. The cost of a rebuild is almost never the widgets themselves.

Elements are the persistent instances

The element tree is the long-lived one. Each element holds a reference to its current widget, its position in the tree, and - for stateful widgets - the State object that survives rebuilds.

When a rebuild produces a new widget, Flutter compares it with the old one at the same position:

// Simplified from framework.dart
static bool canUpdate(Widget oldWidget, Widget newWidget) {
  return oldWidget.runtimeType == newWidget.runtimeType &&
      oldWidget.key == newWidget.key;
}

If the runtime type and key match, the element is updated in place - it keeps its state, keeps its render object, and only the changed properties propagate. If they differ, the element is deactivated, its state is discarded, and a new subtree is built.

That one function explains a family of bugs. Reorder a list of stateful widgets without keys and their state stays behind, attached to positions rather than items - the classic "I dragged the second card and the wrong one is expanded". Keys exist to tell canUpdate that identity travels with the item.

RenderObjects do the geometry

The render tree is where actual work happens: layout, painting, hit testing. A RenderBox knows its size, its position relative to its parent, and how to paint itself.

Render objects are expensive to create and cheap to update, which is exactly why the element tree tries so hard to reuse them. When you rebuild a widget with a different colour, no render object is created - the existing one has its colour set and is marked as needing paint.

Not every widget has one. Padding, Center, and Container are composition helpers that resolve to a handful of render objects; StatelessWidget has none of its own. The render tree is typically much shallower than the widget tree.

The layout protocol

Flutter lays out in a single pass, which is unusual and is the reason it can be fast. That single pass is enforced by one rule, repeated in the docs and worth memorising:

Constraints go down. Sizes go up. Parent sets position.

A parent hands each child a BoxConstraints - min and max width and height. The child picks its own size within those bounds and reports it back. The parent then positions the child. No child ever knows where it is; no parent ever knows how big a child wants to be before asking.

Why "unbounded constraints" errors happen

This is the source of the error every Flutter developer meets in their first week:

RenderBox was not laid out
Vertical viewport was given unbounded height

A Column inside a ListView receives infinite height, because the ListView scrolls and is happy to be any height. The Column then asks its children how tall they want to be, and one of them says "as tall as my parent" - which is infinity. The layout has no solution, so it throws.

Expanded, Flexible, shrinkWrap, and SizedBox are all answers to the same question: what bounds this? Once you read the error as "someone asked for infinity", the fix is usually obvious rather than a matter of trying wrappers until one works.

Single-pass has a cost you can trigger

Because layout is one pass, a parent normally cannot look at a child's size and then change its own constraints. That restriction is what keeps layout linear instead of quadratic.

IntrinsicWidth, IntrinsicHeight, and some Table configurations opt out by querying children speculatively before laying them out. The docs describe them as "relatively expensive", which understates it: they can turn one pass into several per node, and nesting them multiplies. If a screen is mysteriously slow in profile mode, an IntrinsicHeight inside a list is a strong suspect.

What actually happens on a frame

When something calls setState, the framework does not rebuild your app. It marks one element dirty and schedules a frame. On the next vsync, the pipeline runs in order: build → layout → paint → composite → rasterise.

Dirty marking limits the blast radius

setState marks that element dirty. At build time, only dirty elements and their descendants rebuild. Ancestors and siblings are untouched.

This is why where you call setState matters more than how often. A setState at the root of a screen rebuilds the screen; the same call inside a small leaf widget rebuilds a leaf. Splitting a large widget so state lives close to what it affects is the highest-leverage performance change available in Flutter, and it needs no packages.

const constructors help here in a specific way: a const widget is canonicalised, so the identical instance is reused on rebuild, canUpdate sees the same object, and the subtree short-circuits entirely. That is the real mechanism behind "add const" - not a micro-optimisation on allocation, but an early exit from the build.

Layout and paint are separately dirtied

Changing a colour marks the render object as needing paint but not layout. Changing a size marks both. The framework tracks these independently, so a repaint does not imply a relayout.

RepaintBoundary inserts a separate compositing layer so that repaints inside it do not force the surrounding tree to repaint. This actually helps for an animation over a complex static background - the background rasterises once and is reused.

It is not free. Every boundary is another layer to composite and more GPU memory. Scattering them "for performance" without measuring usually makes things slower. debugRepaintRainbowEnabled shows you what actually repaints, which is the only sound basis for adding one.

The raster thread is a separate budget

Two threads matter. The UI thread runs your Dart: build, layout, paint. The raster thread turns the resulting layer tree into pixels via Impeller (or Skia on older versions).

Jank on one is not jank on the other, and the fixes are unrelated. UI-thread jank means too much Dart work per frame - expensive builds, JSON parsing on the main isolate, an IntrinsicHeight. Raster-thread jank means the GPU work is too heavy - large blurs, saveLayer from opacity or clipping, shader compilation on first use.

Flutter DevTools' timeline separates them, and reading which track is over budget tells you which half of the problem you have. Guessing without that is how people end up adding const to fix a BackdropFilter.

Key takeaways

  • Three trees, three jobs. Widgets describe, elements persist and reconcile, render objects do geometry and painting.
  • canUpdate is type plus key. Nearly every "state stuck to the wrong item" bug is a missing key in a reordered list.
  • Constraints down, sizes up, parent positions. Read every layout error as a violation of that sentence.
  • setState marks one element dirty. Push state down the tree and the blast radius shrinks - no package required.
  • const short-circuits reconciliation by reusing a canonicalised instance, so it matters more than allocation cost suggests.
  • RepaintBoundary trades compositing cost for repaint isolation. Measure with the repaint rainbow before adding one.
  • UI-thread jank and raster-thread jank are different problems. Check the DevTools timeline before choosing a fix.

FAQ

Is calling build sixty times a second bad?

Not inherently. Widgets are cheap immutable objects and Dart's collector is built for short-lived allocations. It becomes bad when build does real work - parsing, sorting, formatting, allocating images - or when a rebuild high in the tree drags a large subtree with it.

Does const actually improve performance?

Yes, but through reconciliation rather than allocation. A const widget is the same canonical instance on every rebuild, so canUpdate sees identity and skips the subtree. Meaningful on a widget rebuilt frequently; negligible on one built once.

When should I use Key?

When identity must survive a change in position: reorderable lists, filtered lists, and any list of stateful children. ValueKey(item.id) is right far more often than UniqueKey(), which forces a rebuild every time and defeats the purpose.

What changed with Impeller?

Impeller precompiles its shaders, which removes the first-run shader compilation jank that plagued Skia - that infamous stutter the first time an animation ran. The rendering model above is unchanged; Impeller replaces the rasterisation backend, not the framework pipeline.

Why is my Opacity widget slow?

Opacity with a value strictly between 0 and 1 triggers saveLayer, which allocates an offscreen buffer and composites it - expensive on the raster thread. Prefer AnimatedOpacity on a leaf, Color.withOpacity on a painted colour, or FadeTransition, all of which usually avoid the layer.

How do I find what is rebuilding?

DevTools' widget rebuild profiler counts rebuilds per widget per frame. Start there rather than in the code - the widget you suspect is frequently not the one rebuilding, and the counts settle the argument in seconds.

Conclusion

None of this is esoteric internals. It is four facts - three trees, one layout rule, dirty marking, two threads - and each one maps directly onto a decision you already make: where to put state, whether to add a key, why a layout throws, which kind of jank you are looking at.

The difference it makes is not that you write different code most days. It is that when something is slow or a layout misbehaves, you can reason about it from first principles instead of permuting wrappers until the red disappears.

Read more

If you want to go deeper, the framework source is unusually readable - framework.dart for elements and reconciliation, box.dart for the layout protocol. Both are better documented than most tutorials about them.