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.

Two-pass layout caused by late intrinsic dimensions A timeline showing the first layout pass sizing the image box to zero, then a decoder callback on a second thread forcing a second layout pass that shifts the paragraph down. Main thread Network / decode thread Parse HTML build LayoutImage Layout pass 1 img box = 0px tall First paint p at top Layout pass 2 REFLOW: p shoved down Fetch image bytes off main thread Decode header intrinsic 1600x900 decoder callback marks NeedsLayout

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.

Priority order of inputs that size a replaced element A layered stack showing explicit dimensions resolved first, aspect-ratio second, and intrinsic decoded size last, with the top two available at parse time and the bottom one arriving late. How layout picks a box height 1. explicit width/height (HTML attr or CSS) available at parse time β€” no reflow 2. aspect-ratio + one known dimension available at parse time β€” no reflow 3. intrinsic size from decoded bytes arrives late β€” triggers second layout pass layout stops at the highest rule that resolves; reach rule 3 and you pay the reflow priority

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.

Page layout before and after reserving the media box Side-by-side page mockups: the unreserved page paints the paragraph at the top then shifts it down, the reserved page paints it in place once. BEFORE β€” height: auto AFTER β€” box reserved img (0px) paragraph decode img (640px) paragraph paragraph jumped down img box reserved paragraph decode fills the box paragraph never moves

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.