Avoiding Reflow from Intrinsic Media Dimensions
An <img> or <video> with no declared width and height occupies zero vertical space until its bytes arrive, then expands to its intrinsic size and shoves every element below it downward β a late reflow in the layout phase that the compositor reports as cumulative layout shift. The symptom is a Performance panel Layout bar firing during the network idle window, long after first paint, keyed to the moment the decoder learned the mediaβs real dimensions.
This guide is part of Intrinsic Sizing and Aspect Ratio, within the broader Layout and Paint Optimization area. The problem it isolates is narrow: a media elementβs box is sized by data the browser does not have at parse time, so the layout engine guesses zero, lays the page out, and then re-lays it out once the true dimensions decode. Reserving that box before the bytes land turns a two-pass layout into a single stable one and removes the shift entirely.
Minimal reproduction
The markup below parses instantly, but the hero image carries no dimension hint. The paragraph beneath it renders at y=0-ish, then jumps down once the image decodes its intrinsic 1600Γ900 box.
<article>
<!-- BAD: no width/height and no aspect-ratio β box is 0px tall until decode -->
<img src="/hero-1600x900.jpg" alt="Product hero">
<p>This paragraph is painted at the top, then violently pushed down
the instant the image's intrinsic dimensions arrive from the decoder.</p>
</article>
<style>
img { width: 100%; height: auto; } /* height:auto resolves to 0 until intrinsic size is known */
</style>
The height: auto is the trap. With no intrinsic ratio available and no explicit height, the used height computes to zero during the first layout. When the image resource finishes its header parse and the intrinsic 1600Γ900 becomes known, the elementβs used height jumps to whatever width: 100% scales that ratio to β often several hundred pixels β and everything after it reflows.
How the layout engine sizes a media box before decode
When Blink builds the layout tree, a replaced element such as <img> gets a LayoutImage box whose size depends on three inputs resolved in priority order: explicit CSS or HTML dimensions, the aspect-ratio property, then the resourceβs intrinsic dimensions. At parse time only the first two are available on the main thread; the intrinsic size lives inside the image bytes, which are fetched and decoded off-thread. If neither explicit dimensions nor a ratio is present, the box falls back to the CSS default intrinsic sizing β effectively a zero-height content box β and the first layout pass commits with that guess. The image request completes on a network thread, the decoder reads the header, and it posts the real intrinsic width and height back to the main thread. That callback marks the LayoutImage NeedsLayout, which dirties the containing block and forces a second layout pass over the subtree β the reflow you see in the trace. Because this second pass runs after first paint, the compositor scores the displacement of every already-painted element below it as cumulative layout shift.
A property-by-property view of what reserves the box
The fix space is the set of inputs that let layout size the box during the first pass, before any bytes arrive. Each entry below is resolved on the main thread at parse time, so none of them wait on the decoder.
| Reservation input | Pipeline phase it acts in | Constraint | Shift cost avoided |
|---|---|---|---|
width + height HTML attributes |
Layout (first pass) | must match the true aspect ratio | full late reflow |
aspect-ratio CSS + one fixed dimension |
Layout (first pass) | ratio must equal intrinsic ratio | full late reflow |
contain-intrinsic-size (with content-visibility) |
Layout (skipped subtree) | placeholder size, not exact | reflow on scroll-in |
CSS min-height only |
Layout (first pass) | guesses, rarely matches | partial β still shifts |
nothing (height: auto) |
Layout (two passes) | box is zero until decode | none β full shift |
The first two rows are the real fixes: they give layout a deterministic height in the first pass, so the decoder callback finds the box already the right size and marks nothing dirty. Modern browsers also derive an implicit aspect-ratio from the width and height HTML attributes, which is why setting both attributes works even when CSS overrides one of them with width: 100% β the ratio survives and scales the height.
Reading the shift in a DevTools trace
The tell in a Performance recording is a Layout event that fires during the loading window, well after First Contentful Paint, paired with a Layout Shift entry in the Experience track. The annotated tree below shows one such frame from the reproduction β note that Layout here is not driven by script or style recalculation but by the image resourceβs decode completing.
Timeline (image finishes decoding at ~640ms)
ββ Image Decode (hero-1600x900.jpg) off-thread
ββ postTask β main thread
ββ Layout 5.8ms β REFLOW: LayoutImage resized 0 β 640px tall
β nodesNeedingLayout: 37 (article subtree below the image)
ββ Update Layer Tree 0.3ms
ββ Paint 1.1ms
ββ Composite Layers 0.4ms
Experience track:
Layout Shift score 0.24 β paragraph moved 640px, un-anchored
hadRecentInput: false (counts toward CLS)
Fixed version (width+height set)
ββ Image Decode (hero-1600x900.jpg) off-thread
ββ postTask β main thread
ββ Paint 1.0ms β pixels fill an already-correct box
Experience track: (no Layout Shift entry)
The signature to hunt for is a Layout node whose parent in the tree is an image or media decode rather than a script task or a Recalculate Style. That parentage tells you the reflow was data-driven, not code-driven, which points straight at a missing dimension rather than a forced synchronous layout from a script reading geometry mid-task. If you also see a hadRecentInput: false shift entry with the same timestamp, that displacement is being counted against your CLS score.
The fix: reserve the box before the bytes arrive
Declare the boxβs geometry so the first layout pass is also the last. The most robust form sets the width and height HTML attributes to the mediaβs true pixel dimensions and lets CSS scale them responsively β the attributes seed an implicit aspect-ratio, and height: auto in CSS then resolves against that ratio instead of against a missing intrinsic size.
<!-- BEFORE β box collapses to zero until decode, then reflows -->
<img src="/hero-1600x900.jpg" alt="Product hero">
<style>
img { width: 100%; height: auto; } /* no ratio to resolve against β 0px first pass */
</style>
<!-- AFTER β box height is reserved in the first layout pass -->
<img src="/hero-1600x900.jpg" alt="Product hero"
width="1600" height="900"> <!-- seeds implicit aspect-ratio 16:9 -->
<style>
img {
width: 100%;
height: auto; /* now resolves via the 16:9 ratio, not the decoded bytes */
aspect-ratio: 16 / 9; /* explicit belt-and-braces for CSS-only sizing paths */
}
</style>
The browser now handles this differently because the ratio is present on the main thread during the first layout pass: LayoutImage computes its used height as usedWidth / (16 / 9) immediately, commits the correct box, and paints the paragraph in its final position. When the decoder callback later delivers the intrinsic 1600Γ900, those dimensions match the already-committed box, so no NeedsLayout flag is set and the second layout pass never runs. For elements you render lazily off-screen, pair this with contain-intrinsic-size so the skipped subtree still reserves a plausible box, and where a container must genuinely resize around late media, wrap it in CSS containment so the reflow stops at the container instead of walking the whole document.
Verification checklist
Frequently Asked Questions
Do I still need width and height attributes if my CSS sets aspect-ratio?
If your CSS sets an explicit aspect-ratio and at least one resolvable dimension (such as width: 100%), the box is reserved without the HTML attributes. But setting both width and height attributes is the more robust choice: they seed an implicit aspect ratio that survives even when CSS overrides one dimension, and they cost nothing. Use the attributes as the baseline and add CSS aspect-ratio only where markup control is impossible.
Why does height auto resolve to zero instead of the image height?
height: auto on a replaced element means βuse the intrinsic height,β and the intrinsic height is only known after the decoder reads the image header. Before that, with no aspect-ratio to derive a height from the known width, the used height computes to zero, so the first layout pass gives the box no vertical space. Providing a ratio lets auto resolve against width / ratio immediately instead of waiting on the decode.
Does this reflow count against my Cumulative Layout Shift score?
Yes. When the media box expands after first paint and pushes already-rendered content down, the compositor records a layout-shift entry with hadRecentInput: false, and unanchored shifts like this count toward CLS. Reserving the box removes the shift entry entirely, which is usually the single largest CLS win on image-heavy pages.
How is this different from a forced synchronous layout?
A forced synchronous layout is code-driven: a script reads a geometry property mid-task and forces the pending layout to flush early. This media reflow is data-driven: no script is involved, the second layout pass is triggered by the decoder delivering intrinsic dimensions the first pass lacked. In the trace, the reflowβs parent node is an image decode rather than a script task, which distinguishes the two causes.
Related Guides
- Intrinsic Sizing and Aspect Ratio β the parent overview of how replaced elements and containers derive their box size.
- contain-intrinsic-size and Scroll Anchoring β reserve a placeholder box for content skipped by
content-visibility. - Which CSS Properties Trigger Reflow vs Repaint β the wider map of which changes re-run the layout phase.
- Reducing Layout Shift from Web Fonts β the font-swap analogue of a late-arriving intrinsic size.
- CSS Containment Strategies β bound an unavoidable media reflow to its container.