← Recipes

阅读中文版本

Lazy-Load Below-the-Fold Images and Widgets Correctly

Audit what actually ships in the initial payload, apply native lazy-loading plus fetchpriority hints, and verify with a network waterfall.

~25 minperformancefrontendweb-vitals

Task

Identify off-screen resources loaded eagerly, defer them with correct attributes, and prove the initial payload shrank.

When to use this

A gallery page ships 60 images in the first paint, 9MB of transfer, and a 4-second LCP on mobile. Only two images are above the fold.

Prerequisites

  • Chrome DevTools with network throttling
  • A content-heavy page with many images or embeds
  • Ability to edit templates or components

Steps

1. Count what the first paint actually requests.

node -e "const fs=require('fs');const h=fs.readFileSync('dist/gallery.html','utf8');const imgs=(h.match(/<img[^>]*>/g)||[]);console.log('img tags:',imgs.length);console.log('with loading=lazy:',(h.match(/loading="lazy"/g)||[]).length);console.log('with fetchpriority:',(h.match(/fetchpriority=/g)||[]).length)"

Expected output: The counts expose the gap: 60 image tags with zero lazy attributes means every byte ships before first paint.

2. Mark off-screen media as lazy and the LCP image as eager-high-priority.

<!-- LCP hero: opt OUT of lazy, raise priority -->
<img src="/hero.webp" alt="featured artwork"
     width="1600" height="900" fetchpriority="high"
     decoding="async">

<!-- everything below the fold: native lazy -->
<img src="/gallery/piece-01.webp" alt="linocut print"
     width="640" height="640" loading="lazy"
     decoding="async">

<!-- late widgets: defer on interaction or intersection -->
<iframe data-src="/embeds/map" title="studio map"
        width="600" height="400"
        class="lazy-frame"></iframe>
<script>
  const io = new IntersectionObserver((es) => {
    es.forEach((e) => {
      if (e.isIntersecting) { e.target.src = e.target.dataset.src; io.unobserve(e.target); }
    });
  }, { rootMargin: '200px' });
  document.querySelectorAll('.lazy-frame').forEach((f) => io.observe(f));
</script>

Expected output: The rendered HTML shows one high-priority hero, lazy gallery images, and embeds that load only when scrolled near.

3. Rebuild and capture the waterfall under mobile throttling.

npx vite build 2>&1 | tail -2; echo "in DevTools: Network > Fast 3G > Disable cache > Reload; note first-paint request count and transfer"

Expected output: The throttled reload now requests a handful of resources initially; the rest stream as the user scrolls.

4. Verify laziness actually works and nothing silently breaks.

node -e "const h=require('fs').readFileSync('dist/gallery.html','utf8');const lazy=(h.match(/loading="lazy"/g)||[]).length;console.log('lazy imgs:',lazy); if(lazy < 50) { console.error('regression: lazy count dropped'); process.exit(1); }"

Expected output: The guard passes while attributes survive the build; any future template change that drops lazy-loading fails this check loudly.

5. Measure the metric that motivated the work.

node -e "console.log('verify LCP in DevTools > Performance insights, and CLS unchanged')" && echo "record: first-byte-to-LCP before 4.1s / after 1.8s; initial transfer 9.0MB -> 1.3MB"

Expected output: The before-and-after numbers are recorded next to the change, closing the loop from audit to verified improvement.

Troubleshooting

Problem

LCP image got slower after adding lazy everywhere

Solution

The hero was marked lazy by a blanket rule; exempt the LCP element with fetchpriority="high" and never lazy-load it, since lazy-loading the LCP directly delays the metric you are optimizing.

Problem

Scrolling feels janky as images pop in

Solution

Reserve space with width/height or aspect-ratio to avoid reflow, and add rootMargin to the observer so loading starts before the element is visible.

Problem

Third-party iframe ignores loading attributes

Solution

The widget loads itself via script; defer the script tag with a facade (click-to-load) or load it only after first interaction to keep it out of the critical path.

Limitations

  • Native lazy-loading relies on browser heuristics; verify behavior on the low-end devices your audience actually uses.
  • Deferring widgets trades instant availability for speed; click-to-load facades change UX and deserve a product decision, not just an engineering one.

Ship the first screen, not the whole article

A gallery with 60 images sends 9MB before the visitor sees anything, although 58 of those images are below the fold. Native lazy-loading plus an explicit priority for the one image that matters cuts the initial payload by an order of magnitude without a single line of lazy-loading framework code.

<!-- before: 60 eager <img>, 9MB, LCP 4.1s on Fast 3G -->

<!-- after -->
<img src="/hero.webp" width="1600" height="900"
     fetchpriority="high" decoding="async" alt="...">   <!-- the ONE eager -->

<img src="/piece-01.webp" width="640" height="640"
     loading="lazy" decoding="async" alt="...">          <!-- 58 of these -->

<!-- metrics: initial transfer 9.0MB -> 1.3MB -->
<!--          LCP 4.1s -> 1.8s, CLS unchanged 0.02 -->

The LCP element is the exception to every lazy rule

Blanket lazy-loading is the classic self-own: the largest image, the one defining LCP, gets deferred and the metric doubles. Audit which element is actually your LCP, mark it eager with fetchpriority high, and let everything below the fold be lazy. One exemption, stated explicitly in the template.

Reserve the space you are deferring

Lazy images without width and height cause CLS as they load and push content down. Intrinsic dimensions or an aspect-ratio box hold the layout stable while bytes stream. The speed win is only real if the stability metric does not regress alongside it.