Reserving Space for Images and Embeds

An image with no declared dimensions occupies zero height until its bytes arrive, so every element below it renders in the wrong place and then jumps. This is the single largest source of Cumulative Layout Shift on content sites, and on a static site it is entirely preventable: the generator has the file on disk at build time and knows exactly how big it is.

This guide is the concrete recipe — what to emit for plain images, responsive srcset, art-directed <picture>, and embeds that have no intrinsic size at all — plus the numbers from applying it to a documentation template. It is the media-specific piece of Cumulative Layout Shift Fixes for Static Sites.

Prerequisites

  • A static site where images are processed at build time (Astro astro:assets, Hugo image resources, @11ty/eleventy-img, or a pre-processing step of your own).
  • Chrome DevTools for the Performance panel's Layout Shifts track, or the Layout Instability script from the parent guide.
  • A baseline CLS number per template so you can prove the change worked.

Why the Attributes Still Matter

Browsers compute an intrinsic aspect ratio from the width and height attributes before any CSS is applied. That gives the layout engine a box to reserve during the very first pass — before the image request has even resolved. CSS aspect-ratio cannot do this alone, because a stylesheet arrives after the HTML has begun laying out, and because the property needs at least one known dimension to work from.

Reserved box versus collapsed box during image load Two page columns compared over time. Without dimensions the image area is zero height at 0.2 seconds and the following paragraph sits high, then jumps 320 pixels when the image decodes at 1.4 seconds. With width and height attributes the box is reserved at 0.2 seconds and the paragraph never moves. Same image, two first layout passes No width or height image box = 0 px tall paragraph renders here at 0.2 s then drops 320 px at 1.4 s image finally occupies its space width=1600 height=900 box reserved at 0.2 s empty, correct height paragraph renders in final position 0 px movement · CLS 0.00 Only the ratio of the attributes is used — the CSS width still controls the rendered size
The attributes are a promise about shape, not size: the browser reserves a correctly proportioned box immediately and scales it to whatever the CSS says.

The Recipe

1. Plain images

Emit the intrinsic dimensions of the source file and let CSS control the rendered width:

<img src="/img/pipeline.avif" width="1600" height="900"
     alt="Build pipeline stages" loading="lazy" decoding="async">
.prose img { max-width: 100%; height: auto; }

height: auto is what keeps the ratio honest once max-width shrinks the image; without it the browser uses the literal height attribute and distorts the picture on narrow screens.

2. Responsive srcset

srcset candidates all share one intrinsic ratio, so a single width/height pair still describes the box:

<img
  srcset="/img/hero-480.avif 480w, /img/hero-960.avif 960w, /img/hero-1600.avif 1600w"
  sizes="(min-width: 768px) 720px, 100vw"
  src="/img/hero-960.avif" width="1600" height="900"
  alt="Deployment dashboard" fetchpriority="high">

Generate the candidates from the same source so the ratio cannot drift. Astro's <Picture> does this automatically, as covered in Image Optimization Pipelines in Astro; the equivalent Hugo approach is in Optimizing WebP Images in Hugo Without Plugins.

3. Art-directed picture elements

This is where reservations break. If a mobile <source> is a 1:1 crop and the desktop <source> is 16:9, the box has to change at the breakpoint. Put the dimensions on each <source> so the browser knows the ratio for the media query it matched:

<picture>
  <source media="(min-width: 768px)" srcset="/img/wide.avif" width="1600" height="900">
  <source srcset="/img/square.avif" width="800" height="800">
  <img src="/img/wide.avif" width="1600" height="900" alt="Release timeline">
</picture>

4. Iframes and embeds

An iframe has no intrinsic size, so the wrapper owns the reservation:

.embed { position: relative; width: 100%; aspect-ratio: 16 / 9; }
.embed > iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; }

For embeds without a fixed ratio — a comment thread, a newsletter form, a status widget — reserve a realistic min-height instead and let the content grow downward from a stable top edge. A wrong-by-40-pixels reservation costs a fraction of the score that a zero-height reservation does.

Which technique applies is decided entirely by what you know at build time, and the decision is small enough to memorise:

Choosing a space-reservation technique A decision tree. If the intrinsic size is known at build time, emit width and height attributes. If not, ask whether the ratio is fixed: a fixed ratio gets an aspect-ratio wrapper, a variable one gets a min-height reservation on the container. Pick the reservation by what the build knows Intrinsic size known at build time? width + height attributes images, picture sources Is the ratio fixed? video 16 / 9, map 4 / 3 … aspect-ratio wrapper min-height best estimate yes no yes no
Only the leftmost branch is free of guesswork, which is why moving media into the build pipeline pays off twice: smaller files and a reservation you never have to estimate.

The min-height branch feels unsatisfying, but it is worth being precise about how much it buys. A widget that finally renders at 460 px in a container reserved for 420 px moves the content below it by 40 px rather than 460 px — an eleven-fold reduction in the distance fraction, and usually the difference between a failing and a passing score. Estimate from the median observed height in production rather than the smallest possible one, and re-check it when the widget's content changes.

5. Enforce it in the build

Manual discipline decays. Fail the build when an image slips through:

// scripts/check-img-dims.mjs — run after the build, over the generated HTML
import { readFileSync } from 'node:fs';
import { globSync } from 'node:fs';

let bad = 0;
for (const file of globSync('dist/**/*.html')) {
  const html = readFileSync(file, 'utf8');
  for (const [tag] of html.matchAll(/<img\b[^>]*>/g)) {
    if (!/\bwidth=/.test(tag) || !/\bheight=/.test(tag)) {
      console.error(`${file}: ${tag.slice(0, 90)}`);
      bad++;
    }
  }
}
if (bad) {
  console.error(`check-img-dims: ${bad} image(s) without dimensions`);
  process.exit(1);
}
console.log('check-img-dims: all images carry dimensions');

Wire it into the same CI job that runs your other content gates — the pattern is the same one used for build-time checks in GitHub Actions for Automated SSG Builds.

Measured Impact

Measured on a documentation template with six body images and one video embed, Chrome 4× CPU throttle on a fast 3G profile, median of five runs:

ChangeCLSLCPNotes
Baseline (no dimensions)0.312.9 sEvery body image collapses to zero height
width/height on body images0.072.9 sRemaining shift is the video embed
Aspect-ratio wrapper on the embed0.012.9 sResidual is a late-swapping web font
Above + loading="lazy" below fold0.012.4 s380 KB fewer bytes on first load
CLS by fix on a documentation template A horizontal bar chart on a shared scale. Baseline scores 0.31, adding image dimensions drops it to 0.07, adding the embed aspect-ratio wrapper drops it to 0.01, and adding lazy loading keeps it at 0.01 while removing 380 kilobytes. The 0.10 good threshold is marked with a dashed line. Where the score actually goes 0.10 threshold Baseline no dimensions CLS 0.31 + image dims 6 body images 0.07 + embed box 16 / 9 wrapper 0.01 Shared linear scale · Chrome 4× CPU throttle, fast 3G, median of 5 runs
Dimensions on the body images removed 77% of the score; the embed wrapper removed most of what was left. Neither change touched a single byte of image data.

The LCP row is worth noting: reserving space did not make the page faster, it made it stable. The 0.5 s LCP improvement came from lazy-loading below-the-fold images, which is a separate lever — and one that is only safe once the boxes are reserved.

Pitfalls & Rollback

  • Putting rendered size in the attributes. Use intrinsic dimensions. Attributes of width="640" height="360" on a 1600×900 source still work (same ratio), but a mismatched ratio distorts the image.
  • Omitting height: auto in CSS. With max-width: 100% alone the browser honours the literal height attribute and squashes the picture on narrow viewports.
  • One ratio for art-directed sources. If crops differ per breakpoint, each <source> needs its own dimensions or the box re-reserves at the breakpoint.
  • Reserving with a spacer element. A JavaScript-inserted spacer runs after paint, which is the thing you are trying to avoid. Reserve in HTML and CSS only.
  • Lazy-loading the LCP image. Correct dimensions make lazy loading safe for CLS but loading="lazy" on the hero still delays LCP — keep the first image eager, as covered in Reducing LCP From Hero Images on Static Sites.
  • Rollback: every change here is additive markup. Removing the attributes or the wrapper class restores the previous behaviour with no data migration and no cache to purge.

Conclusion

Reserving space is the cheapest performance work available to a static site: no new bytes, no new requests, and a build-time source of truth for every dimension. Emit width and height on every image, give each <source> its own pair when crops differ, wrap embeds in an aspect-ratio box, and enforce it with a build check so the next contributor cannot regress it. Then move on to the remaining causes — fonts and injected content — in Cumulative Layout Shift Fixes for Static Sites.

FAQ

Do I still need width and height if my CSS sets aspect-ratio?

Yes. The HTML attributes are available to the browser before any stylesheet is parsed, so they protect the very first layout pass. The CSS aspect-ratio property is a safety net for cases where a responsive rule changes the rendered width, not a replacement for the attributes.

What values go in width and height for a responsive image?

The intrinsic pixel dimensions of the source file, not the rendered size. The browser only uses the ratio between them, then scales the box to whatever CSS says the width should be, so a 1600 by 900 attribute pair works perfectly for an image displayed at 640 pixels wide.

How do I reserve space for an iframe whose height is unknown?

Give the wrapper a fixed aspect ratio if the content has one, such as 16 by 9 for video, and a min-height otherwise. A slightly wrong reservation still scores far better than none, because the shift is limited to the difference rather than the whole element height.

Does lazy loading cause layout shift?

Only if the element has no reserved space. With correct dimensions, loading="lazy" is safe and saves bytes. Without them, lazy loading makes shifts worse because the image arrives later, after the reader has probably already started reading.

What about images whose size is only known at request time?

Fetch them at build time and record the dimensions in your content data, or serve them through a transform that guarantees a fixed ratio. If neither is possible, wrap them in a box with a declared ratio and accept letterboxing rather than a shift.