IntrinsicHeight and LayoutBuilder: why they break the one-pass rule on purpose
You have three cards in a row. Each one has a title, a description, and a button pinned at the bottom. The cards should look like a tidy matching set. They don't. The middle card has more text than the other two, so it's taller, and now you're staring at a row where two cards float awkwardly in a taller band while the middle one strains against invisible edges. The buttons don't line up. The whole thing looks broken.
You try the obvious fix: crossAxisAlignment: CrossAxisAlignment.stretch on the Row. Nothing changes. You wrap the cards in Expanded — that's the horizontal axis, not what you wanted. You try slapping height: double.infinity on a Container. Error. You poke at this for twenty minutes, then Google it, and you stumble across a widget you've never knowingly used: IntrinsicHeight. You wrap the Row in it. The cards snap to the same height. It works. You ship.
But a small voice in the back of your head is asking: what just happened, and is this going to come back and bite me later?
This post is that voice getting a proper answer. Alongside it, we'll meet LayoutBuilder, because the two widgets solve very different problems but both rely on the same trick: they bend the rules of the normal layout pass.
The rule IntrinsicHeight breaks
Post 1 beat a single drum: Flutter lays out widgets in one pass. Constraints flow down, sizes come back up, nobody gets measured twice, everything finishes fast enough to animate at high frame rates. One pass is the whole reason Flutter can lay out huge trees without falling over.
But the three-card scenario is literally impossible in one pass. Think through the conversation:
- The Row asks Card A "how tall do you want to be?" Card A measures its content and answers "100."
- The Row asks Card B. Card B says "160."
- The Row asks Card C. Card C says "110."
- The Row now knows the tallest is 160. If it wanted the other two to match, it would have to go back to Card A and Card C and say "ignore what I asked a second ago, you're 160 now."
That second round of questioning is exactly what Flutter normally refuses to do. No going back. No revisions. One pass, one answer, commit. So how does IntrinsicHeight pull off the match-height trick without breaking the whole layout engine?
By cheating. Specifically, by holding a rehearsal.
The rehearsal pass
Before any real layout happens inside an IntrinsicHeight, it does something the rest of the layout system would consider bad manners. It runs a hypothetical layout pass on its child, where every descendant is asked a slightly different question than the usual one: not "how tall are you given these constraints?" but "how tall would you naturally be if no constraints existed at all?"
This hypothetical question has a name in Flutter's internals: the intrinsic size. Every render object in Flutter can, in principle, answer "what's my natural width?" and "what's my natural height?" without anyone handing it constraints. Text knows how wide and tall it would be if it could spread out freely. A column of fixed-size children knows the sum of their heights. A padded widget knows its child's intrinsic size plus the padding. And so on, recursively.
IntrinsicHeight exploits this. It asks its direct child — usually a Row — "what's your natural height?" The Row, being a flex widget, answers by asking each of its own children the same thing and returning the maximum. So Card A says 100, Card B says 160, Card C says 110, the Row reports back 160, and IntrinsicHeight now knows the target. It then runs the real layout pass, this time feeding the Row a tight height constraint of exactly 160. The Row passes that tight constraint down to each of its children via the stretch alignment, and all three cards lay themselves out at 160 pixels tall. They match. The buttons line up. You ship, and this time you know why.
Two layout passes where there should have been one. That is the trick, and it is completely deliberate. IntrinsicHeight exists precisely to buy you an extra pass for the cases where one pass isn't enough.
Why this is expensive
"An extra pass" sounds mild until you think through what it actually costs. The hypothetical pass — the one that computes intrinsic sizes — has to recursively visit every descendant of the IntrinsicHeight. For a simple tree of Text and Icon widgets, that's cheap. For a tree with nested Columns, Paddings, Images, custom widgets, and maybe a nested Row inside a Card inside an AspectRatio, the recursion adds up, and nothing is cached between the hypothetical pass and the real one by default. You're effectively doubling (sometimes worse) the layout work for everything inside the IntrinsicHeight.
Now imagine putting an IntrinsicHeight around a row of cards that lives inside a ListView.builder, scrolling through hundreds of items. Every visible item is now doing its layout work twice per frame, and the rebuilds that happen during a scroll turn into a perf bug you can feel on a mid-range Android device. Your buttery 120 Hz scroll is suddenly a 40 Hz slog.
This is the first rule of IntrinsicHeight: don't use it inside widgets that rebuild their children rapidly, and especially not inside scrollable lists. Outside those contexts, for a one-off section of a screen where you need three or four siblings to agree on a dimension? Totally fine. In tight scroll loops? Find another way. Usually the "other way" is to compute the desired height elsewhere — maybe from the data itself, maybe from a known design constant — and pass it down as a fixed number. A fixed height: 160 on each card is infinitely cheaper than asking IntrinsicHeight to rediscover the answer every frame.
Not every widget plays along
Here is the other thing nobody warns you about until you've already fallen into the hole. Intrinsic sizing is optional from a render object's point of view. A widget is allowed to say, in effect, "I can't meaningfully tell you what my natural size is, because my size depends on something I don't have access to yet." Some widgets throw assertion errors the instant you try to ask them.
The biggest offender by far is the entire scrollable family. Viewports, slivers, and the widgets that live inside them don't really have an "intrinsic" size in the way IntrinsicHeight expects — a ListView's natural height would be the sum of all its children, but its children could be thousands of items or could be lazily built and not exist yet. So the render objects involved simply refuse to answer. If you wrap an IntrinsicHeight around a ListView, or put a ListView inside a Row that's inside an IntrinsicHeight, you'll get an assertion error the first time layout runs, complaining about an intrinsic dimension that can't be computed.
The rule that follows: intrinsic sizing and scrollables do not mix. If you need both — say, a scrollable region with match-height children — compute the heights some other way.
Using IntrinsicHeight correctly
Here's the three-card layout written properly:
IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: ProductCard(product: first)),
const SizedBox(width: 12),
Expanded(child: ProductCard(product: second)),
const SizedBox(width: 12),
Expanded(child: ProductCard(product: third)),
],
),
)Two details are doing most of the work.
crossAxisAlignment: CrossAxisAlignment.stretch is essential. Without it, IntrinsicHeight will compute the tallest intrinsic height and hand it down as a tight height constraint, but the Row's default cross-axis alignment is center, which doesn't actually stretch its children. You'd still see three cards at their natural heights, floating, centered inside a taller band. Stretch is what forces each card to fill the row's full cross-axis dimension — and now that IntrinsicHeight has fixed that dimension at the tallest child's natural height, stretching produces the result you wanted.
Expanded is handling the horizontal distribution. IntrinsicHeight doesn't touch the horizontal axis at all — that's still plain flex math. The three Expanded widgets split the available width evenly; the SizedBoxes carve out fixed gaps in between.
A common mistake worth flagging: wrapping the wrong widget. IntrinsicHeight has to go around the Row, not around each individual card. If you wrap each card in its own IntrinsicHeight, you're asking each card to be as tall as itself, which does nothing. The whole point is that IntrinsicHeight needs to see all three siblings together so it can find the max.
The other escape hatch: LayoutBuilder
A different problem, same underlying theme — the one-pass layout model is hiding information you need, and there's a widget whose job is to surface it.
Imagine you're building a dashboard with a collapsible sidebar. On a phone, the sidebar is hidden and the content area takes the full width. On a tablet, the sidebar takes 280 pixels on the left and the content gets whatever's left. Inside the content area, you have a grid of analytics cards. You want the grid to show one card per row when the content is narrow, two when it's medium, three when it's wide.
The first instinct is to reach for MediaQuery.of(context).size.width. Read the screen width, do some math, pick a column count. Clean. Simple. Wrong. MediaQuery reports the dimensions of the full window — the whole screen, sidebar included. On a 1024-wide tablet with the sidebar open, MediaQuery will cheerfully tell you the width is 1024, but the grid inside the content area actually has 744 to work with. Your column count is off by one. Worse, if the sidebar is animating open or closed, MediaQuery won't reflect the in-between state at all — the screen size never changed, only the widget's local slot did.
The question you actually want to ask isn't "how big is the screen?" It's "how big is the space my parent just handed me?" And that question has a slightly uncomfortable property: the answer depends on where this widget is in the tree, which depends on layout, which hasn't happened yet when build() runs. Build and layout are different phases of the frame, and build runs first, so your widget's build() method is fundamentally blind to the size of the slot it's about to occupy.
Unless you use LayoutBuilder, which is basically "the widget that waits."
Building with the lights on
A normal widget's build() method is a pure function of its inputs. It doesn't know anything about where it's being placed, because at the moment build() runs, the layout pass that determines placement hasn't happened. Flutter builds the whole tree, then lays it out, then paints it. You get access to MediaQuery (which is set globally, before anything) and any props passed down, but not the actual constraints that your immediate parent is about to hand you.
LayoutBuilder politely declines to play this game. Instead of taking a child, it takes a callback: `(BuildContext context, BoxConstraints constraints) =
- Widget
. And it postpones calling that callback until the layout phase, at which point it has a realBoxConstraints` object in hand — the live, up-to-the-millisecond constraints from its parent. It runs the callback, receives a widget tree as a return value, and builds that tree *then*. So the widget gets to look around before deciding what to construct.
LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth < 600) {
return ProductGrid(columns: 1);
} else if (constraints.maxWidth < 900) {
return ProductGrid(columns: 2);
} else {
return ProductGrid(columns: 3);
}
},
)The constraints value here is the real, local answer to "how big is my slot?" — not the screen, not the window, just the space this specific LayoutBuilder is being offered. Drop the same LayoutBuilder inside a 400-wide modal dialog and constraints.maxWidth is 400. Put it inside the content area of the tablet layout with the sidebar open and constraints.maxWidth is 744. Animate the sidebar open and closed and the LayoutBuilder's callback reruns each frame with new constraints, so the grid smoothly transitions from one column to two to three as the space changes. No math against MediaQuery, no guessing, no lag.
This is technically a violation of the build-then-layout ordering — LayoutBuilder is building something during the layout phase — and Flutter has special plumbing to make it work without breaking the rest of the system. It's an escape hatch with a trapdoor built underneath it.
Why this is also expensive, and the trap
Nothing this powerful is free. Every time the incoming constraints change, LayoutBuilder reruns its builder callback and rebuilds its entire subtree. In a stable layout that happens once, during the initial mount, and then never again. But in a layout that animates — a collapsing sidebar, a resizable split view, a dragging panel — the constraints can change on every frame of the animation, and LayoutBuilder happily rebuilds everything inside it on every one of those frames. If the subtree is heavy, that's a visible stutter waiting to happen.
The general rule is: wrap only the part that actually needs to branch on size, not the whole page. If only the grid's column count changes, put the LayoutBuilder tight around the grid, not around the entire content area. The tighter the scope, the less work a constraint change triggers.
The second trap is subtler and more common. LayoutBuilder needs a sensible constraint from its parent to do anything useful. If you put it somewhere where one of its axes is unbounded — for example, a LayoutBuilder inside a vertical ListView will receive maxHeight: double.infinity — your conditions become nonsense. double.infinity < 600 is always false, and the branches on the max side always fire whether you wanted them to or not. The fix depends on context: sometimes you branch only on the bounded axis (maxWidth works fine inside a vertical scroll), sometimes you use SliverLayoutBuilder instead, which is a sliver-aware variant designed for inside scroll views and gives you viewport-relative dimensions.
Using LayoutBuilder correctly
A few rules of the road:
Use it for branching, not measuring. LayoutBuilder's purpose is deciding which tree to return based on the available space. It's not a tool for measuring an existing tree — for that you need intrinsic sizing or a custom render object. If your first impulse is "I want to measure my child and react to it," LayoutBuilder is probably not the right tool.
Scope it tightly. Wrapping an entire screen in a LayoutBuilder "just in case" is a performance smell. The narrower the subtree under the callback, the cheaper each rebuild. Put it around the one widget whose layout actually needs to branch.
Use it alongside MediaQuery, not instead of it. These two answer different questions and both have a place. MediaQuery is for device- and window-level concerns: keyboard height, safe area insets, text scale factor, dark mode, screen orientation. LayoutBuilder is for local-slot concerns: how wide is this particular region of my UI, right now, inside whatever layout context it's been dropped into. Use each for the thing it's designed for.
Cache decisions higher when you can. If the column count can be computed once by a parent that already knows the overall page structure, and passed down as an int, that is almost always cheaper than letting a LayoutBuilder rediscover it on every layout change. LayoutBuilder is for when you genuinely don't know.
The bigger picture: information vs speed
Step back and notice what IntrinsicHeight and LayoutBuilder have in common, because that shared property is what tells you when to use them.
The default Flutter layout model is deliberately restrictive. One pass, constraints down, sizes up, no sibling communication, no looking at your own slot before building. Those restrictions are what make the system fast. They're also what make certain problems awkward or impossible to express directly.
- Problems where siblings need to agree on a dimension. Match-height cards, equal-width columns in a complex layout, stretch-to-tallest rows. The normal flex widgets can't solve this in one pass because nobody knows the max until everyone has been measured, and by then it's too late to go back.
IntrinsicHeightruns a preliminary pass specifically to collect that information, then feeds it into the real pass as a tight constraint.
- Problems where a widget's structure depends on its own slot size. Responsive grids, adaptive detail panes, charts that switch between "small" and "large" visualizations based on the region they've been handed. The normal build method runs before layout and has no idea what size the slot will be.
LayoutBuilderpostpones the build decision until the constraints are in hand.
Both widgets buy you information that the one-pass model actively hides from you. And both charge you for it — IntrinsicHeight in extra layout work, LayoutBuilder in extra rebuild triggers. When you genuinely need the information, paying the cost is correct. When you don't, reaching for these widgets anyway is how you end up with a layout that works fine on your M1 Max and chokes on a Pixel 4a.
Rule of thumb: ask yourself whether the information you need could be known above the widget that needs it — from the data, from a design constant, from a parent that already has a MediaQuery in scope, from a state variable that someone else computes. If yes, prefer to pass it down as a plain value. If you genuinely can't know until you're in the middle of layout, now you know which escape hatch to reach for.
The one-paragraph summary
IntrinsicHeight and IntrinsicWidth break the one-pass rule by running a hypothetical "how big would you naturally be?" measurement before the real layout, so siblings can agree on a shared dimension. They're safe for one-off sections, dangerous inside scrolling lists, and incompatible with anything in the scrollable family. LayoutBuilder breaks the build-then-layout rule by postponing its callback until layout time, so a widget can decide what to build based on the slot it's actually being given. It's the right tool when you need responsiveness to local space (not the whole screen), wrong when the answer could have been computed upstream and passed down as a prop. Use both sparingly, scope them tightly, and remember: they exist because the fast path isn't always expressive enough, not because the fast path is wrong.
The one-pass rule is the default for a reason. But Flutter gave you two ways out for the days when the default isn't enough. Now you know why they're there, how they cheat, and what the cheating costs.