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.
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.
canUpdateis 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.
setStatemarks one element dirty. Push state down the tree and the blast radius shrinks - no package required.constshort-circuits reconciliation by reusing a canonicalised instance, so it matters more than allocation cost suggests.RepaintBoundarytrades 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.