Media Lazy Loading
Master media lazy loading strategies — native browser lazy loading for images and iframes, video poster optimization, low-quality image placeholders (LQIP), BlurHash encoding, progressive rendering techniques, and prioritization of above-the-fold media.
When to Use
- A page contains many images but only 2-3 are visible on initial load
- Lighthouse flags "Defer offscreen images" with significant byte savings
- Page load downloads all images regardless of whether they are in the viewport
- Video embeds (YouTube, Vimeo) load heavy iframes before the user clicks play
- Image-heavy galleries or product listings transfer megabytes on initial load
- Placeholder images cause layout shift when the real image loads
- Users perceive the page as slow because all media loads simultaneously
- A content-heavy blog or news site loads images for the entire article at once
- Infinite scroll feeds download images for items far below the current scroll position
- Third-party image embeds (Instagram, Twitter) add hundreds of KB to page weight
Instructions
Use native lazy loading for below-fold images. The loading="lazy" attribute defers image loading until the image approaches the viewport:
<!-- Below-fold images: lazy load -->
<img
src="product.jpg"
alt="Product photo"
width="400"
height="300"
loading="lazy"
decoding="async"
/>
<!-- Above-fold / LCP images: NEVER lazy load -->
<img src="hero.jpg" alt="Hero banner" width="1200" height="600" fetchpriority="high" />
The browser determines the loading threshold (typically 1250-2500px from viewport depending on connection speed). Do not add loading="lazy" to the LCP image or any image visible in the initial viewport.
Lazy load iframes for video embeds. YouTube and Vimeo iframes load 500KB+ of JavaScript. Defer until the user indicates intent:
<!-- Native iframe lazy loading -->
<iframe
src="https://www.youtube.com/embed/dQw4w9WgXcQ"
loading="lazy"
width="560"
height="315"
title="Video title"
allow="accelerometer; autoplay; encrypted-media"
allowfullscreen
></iframe>
<!-- Better: facade pattern — show thumbnail, load iframe on click -->
function YouTubeFacade({ videoId, title }: { videoId: string; title: string }) {
const [loaded, setLoaded] = useState(false);
if (loaded) {
return (
<iframe
src={`https://www.youtube.com/embed/${videoId}?autoplay=1`}
width="560"
height="315"
title={title}
allow="accelerometer; autoplay; encrypted-media"
allowfullscreen
/>
);
}
return (
<button
=> setLoaded(true)}
style={{ position: 'relative', width: 560, height: 315 }}
aria-label={`Play: ${title}`}
>
<img
src={`https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`}
alt={title}
width="560"
height="315"
loading="lazy"
/>
<PlayIcon />
</button>
);
}
Implement LQIP (Low-Quality Image Placeholders). Show a tiny blurred preview while the full image loads:
// Generate LQIP at build time with sharp
const sharp = require('sharp');
async function generateLQIP(inputPath) {
const buffer = await sharp(inputPath)
.resize(20) // 20px wide
.blur(2)
.jpeg({ quality: 20 })
.toBuffer();
return `data:image/jpeg;base64,${buffer.toString('base64')}`;
// ~200-400 bytes, inlineable in HTML
}
<!-- LQIP placeholder with fade-in transition -->
<div class="image-container" style="aspect-ratio: 4/3;">
<img
src="data:image/jpeg;base64,/9j/4AAQSkZJ..."
data-src="product-full.jpg"
alt="Product"
class="lqip-image"
width="800"
height="600"
/>
</div>
<style>
.image-container {
overflow: hidden;
}
.lqip-image {
width: 100%;
height: 100%;
object-fit: cover;
filter: blur(20px);
transform: scale(1.1);
transition:
filter 0.3s,
transform 0.3s;
}
.lqip-image.loaded {
filter: blur(0);
transform: scale(1);
}
</style>
Use BlurHash for compact placeholders. BlurHash encodes a placeholder in ~20-30 characters, decoded to a blurred preview on the client:
// Server: encode at upload/build time using sharp + blurhash
import { encode } from 'blurhash';
import sharp from 'sharp';
async function generateBlurHash(imagePath: string): Promise<string> {
const { data, info } = await sharp(imagePath)
.raw()
.ensureAlpha()
.resize(32, 32, { fit: 'inside' })
.toBuffer({ resolveWithObject: true });
return encode(new Uint8ClampedArray(data), info.width, info.height, 4, 3);
}
// Client: decode hash to pixels, render to <canvas> via ctx.putImageData()
Implement video poster optimization. For video elements, use a poster image to avoid loading video data before play:
<video poster="video-poster.webp" preload="none" width="1280" height="720" playsinline>
<source src="video.mp4" type="video/mp4" />
</video>
Set preload="none" to prevent the browser from downloading any video data. The poster image provides the visual preview. Combine with loading="lazy" on a facade image for below-fold videos.
Implement progressive image loading with CSS transitions. Preload the full image in a new Image(), swap src on load, and animate with filter: blur(20px) transitioning to filter: none over 300ms. This smooths the placeholder-to-image transition without layout shift.
Configure the loading threshold. Browsers load lazy images before they enter the viewport. Chrome uses a distance threshold that varies by connection speed (~1250px on 4G, ~2500px on slow 3G). For custom Intersection Observer implementations, set rootMargin based on image size and expected scroll speed:
// Small thumbnails (fast to load): trigger closer
const observer = new IntersectionObserver(callback, { rootMargin: '200px' });
// Large hero images (slow to load): trigger earlier
const observer = new IntersectionObserver(callback, { rootMargin: '500px' });
Details
Native Lazy Loading Browser Behavior
When loading="lazy" is set, the browser defers the image fetch until the image is within a distance threshold of the viewport. This threshold is not configurable by developers — it varies by browser and connection speed. Chrome on a fast connection starts loading images ~1250px before they enter the viewport. On slow 3G, this increases to ~2500px to compensate for longer download times. Images with loading="lazy" that are in the initial viewport on page load are loaded immediately (no deferral).
Worked Example: Medium Article Images
Medium inlines a ~200-byte blurred LQIP as a base64 data URI. When the container approaches the viewport (Intersection Observer, 300px rootMargin), the full image loads and a CSS transition fades from blur to sharp over 300ms. Result: zero layout shift, immediate visual feedback, and ~95% of image bytes deferred.
Worked Example: Instagram Feed
Each API response includes a 28-character BlurHash string rendered to a canvas as an instant placeholder. The actual image loads progressively (headers first, then increasing resolution scans). Result: colored blurred previews within 50ms of scrolling, zero layout shift, and perceived-instant loading.
Anti-Patterns
Lazy loading the LCP image. Adding loading="lazy" to the hero image or any image visible without scrolling delays LCP by the intersection observer threshold plus download time. The LCP image should be eagerly loaded with fetchpriority="high".
Using JavaScript lazy loading when native suffices. Libraries like lazysizes add JavaScript overhead for functionality the browser provides natively. Use loading="lazy" for standard cases. Reserve JavaScript solutions for LQIP, BlurHash, or Intersection Observer patterns that native lazy loading cannot provide.
Placeholders that cause layout shift. A placeholder with different dimensions than the final image causes Cumulative Layout Shift when swapped. Always set width, height, and aspect-ratio on containers. Placeholders must match the final image's aspect ratio.
Lazy loading all images indiscriminately. Applying loading="lazy" to every image on the page, including above-the-fold images, delays rendering of critical content. Audit which images are in the initial viewport and exclude them from lazy loading.
Source
Process
- Read the instructions and examples in this document.
- Apply the patterns to your implementation, adapting to your specific context.
- Verify your implementation against the details and edge cases listed above.
Harness Integration
- Type: knowledge — this skill is a reference document, not a procedural workflow.
- No tools or state — consumed as context by other skills and agents.
Success Criteria
- Below-fold images use
loading="lazy" and above-fold images do not.
- Video embeds use a facade pattern that loads the iframe only on interaction.
- Image placeholders (LQIP or BlurHash) are implemented for key images.
- No media lazy loading causes layout shift (containers have explicit dimensions).
- Initial page load transfers only images visible in the viewport.
1---2name: perf-lazy-loading-media3description: Media Lazy Loading4---5# Media Lazy Loading67> Master media lazy loading strategies — native browser lazy loading for images and iframes, video poster optimization, low-quality image placeholders (LQIP), BlurHash encoding, progressive rendering techniques, and prioritization of above-the-fold media.89## When to Use1011- A page contains many images but only 2-3 are visible on initial load12- Lighthouse flags "Defer offscreen images" with significant byte savings13- Page load downloads all images regardless of whether they are in the viewport14- Video embeds (YouTube, Vimeo) load heavy iframes before the user clicks play15- Image-heavy galleries or product listings transfer megabytes on initial load16- Placeholder images cause layout shift when the real image loads17- Users perceive the page as slow because all media loads simultaneously18- A content-heavy blog or news site loads images for the entire article at once19- Infinite scroll feeds download images for items far below the current scroll position20- Third-party image embeds (Instagram, Twitter) add hundreds of KB to page weight2122## Instructions23241. **Use native lazy loading for below-fold images.** The `loading="lazy"` attribute defers image loading until the image approaches the viewport:2526 ```html27 <!-- Below-fold images: lazy load -->28 <img29 src="product.jpg"30 alt="Product photo"31 width="400"32 height="300"33 loading="lazy"34 decoding="async"35 />3637 <!-- Above-fold / LCP images: NEVER lazy load -->38 <img src="hero.jpg" alt="Hero banner" width="1200" height="600" fetchpriority="high" />39 ```4041 The browser determines the loading threshold (typically 1250-2500px from viewport depending on connection speed). Do not add `loading="lazy"` to the LCP image or any image visible in the initial viewport.42432. **Lazy load iframes for video embeds.** YouTube and Vimeo iframes load 500KB+ of JavaScript. Defer until the user indicates intent:4445 ```html46 <!-- Native iframe lazy loading -->47 <iframe48 src="https://www.youtube.com/embed/dQw4w9WgXcQ"49 loading="lazy"50 width="560"51 height="315"52 title="Video title"53 allow="accelerometer; autoplay; encrypted-media"54 allowfullscreen55 ></iframe>5657 <!-- Better: facade pattern — show thumbnail, load iframe on click -->58 ```5960 ```typescript61 function YouTubeFacade({ videoId, title }: { videoId: string; title: string }) {62 const [loaded, setLoaded] = useState(false);6364 if (loaded) {65 return (66 <iframe67 src={`https://www.youtube.com/embed/${videoId}?autoplay=1`}68 width="560"69 height="315"70 title={title}71 allow="accelerometer; autoplay; encrypted-media"72 allowfullscreen73 />74 );75 }7677 return (78 <button79 onClick={() => setLoaded(true)}80 style={{ position: 'relative', width: 560, height: 315 }}81 aria-label={`Play: ${title}`}82 >83 <img84 src={`https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`}85 alt={title}86 width="560"87 height="315"88 loading="lazy"89 />90 <PlayIcon />91 </button>92 );93 }94 ```95963. **Implement LQIP (Low-Quality Image Placeholders).** Show a tiny blurred preview while the full image loads:9798 ```javascript99 // Generate LQIP at build time with sharp100 const sharp = require('sharp');101102 async function generateLQIP(inputPath) {103 const buffer = await sharp(inputPath)104 .resize(20) // 20px wide105 .blur(2)106 .jpeg({ quality: 20 })107 .toBuffer();108109 return `data:image/jpeg;base64,${buffer.toString('base64')}`;110 // ~200-400 bytes, inlineable in HTML111 }112 ```113114 ```html115 <!-- LQIP placeholder with fade-in transition -->116 <div class="image-container" style="aspect-ratio: 4/3;">117 <img118 src="data:image/jpeg;base64,/9j/4AAQSkZJ..."119 data-src="product-full.jpg"120 alt="Product"121 class="lqip-image"122 width="800"123 height="600"124 />125 </div>126127 <style>128 .image-container {129 overflow: hidden;130 }131 .lqip-image {132 width: 100%;133 height: 100%;134 object-fit: cover;135 filter: blur(20px);136 transform: scale(1.1);137 transition:138 filter 0.3s,139 transform 0.3s;140 }141 .lqip-image.loaded {142 filter: blur(0);143 transform: scale(1);144 }145 </style>146 ```1471484. **Use BlurHash for compact placeholders.** BlurHash encodes a placeholder in ~20-30 characters, decoded to a blurred preview on the client:149150 ```typescript151 // Server: encode at upload/build time using sharp + blurhash152 import { encode } from 'blurhash';153 import sharp from 'sharp';154155 async function generateBlurHash(imagePath: string): Promise<string> {156 const { data, info } = await sharp(imagePath)157 .raw()158 .ensureAlpha()159 .resize(32, 32, { fit: 'inside' })160 .toBuffer({ resolveWithObject: true });161 return encode(new Uint8ClampedArray(data), info.width, info.height, 4, 3);162 }163164 // Client: decode hash to pixels, render to <canvas> via ctx.putImageData()165 ```1661675. **Implement video poster optimization.** For video elements, use a poster image to avoid loading video data before play:168169 ```html170 <video poster="video-poster.webp" preload="none" width="1280" height="720" playsinline>171 <source src="video.mp4" type="video/mp4" />172 </video>173 ```174175 Set `preload="none"` to prevent the browser from downloading any video data. The poster image provides the visual preview. Combine with `loading="lazy"` on a facade image for below-fold videos.1761776. **Implement progressive image loading with CSS transitions.** Preload the full image in a `new Image()`, swap `src` on load, and animate with `filter: blur(20px)` transitioning to `filter: none` over 300ms. This smooths the placeholder-to-image transition without layout shift.1781797. **Configure the loading threshold.** Browsers load lazy images before they enter the viewport. Chrome uses a distance threshold that varies by connection speed (~1250px on 4G, ~2500px on slow 3G). For custom Intersection Observer implementations, set rootMargin based on image size and expected scroll speed:180181 ```typescript182 // Small thumbnails (fast to load): trigger closer183 const observer = new IntersectionObserver(callback, { rootMargin: '200px' });184185 // Large hero images (slow to load): trigger earlier186 const observer = new IntersectionObserver(callback, { rootMargin: '500px' });187 ```188189## Details190191### Native Lazy Loading Browser Behavior192193When `loading="lazy"` is set, the browser defers the image fetch until the image is within a distance threshold of the viewport. This threshold is not configurable by developers — it varies by browser and connection speed. Chrome on a fast connection starts loading images ~1250px before they enter the viewport. On slow 3G, this increases to ~2500px to compensate for longer download times. Images with `loading="lazy"` that are in the initial viewport on page load are loaded immediately (no deferral).194195### Worked Example: Medium Article Images196197Medium inlines a ~200-byte blurred LQIP as a base64 data URI. When the container approaches the viewport (Intersection Observer, 300px rootMargin), the full image loads and a CSS transition fades from blur to sharp over 300ms. Result: zero layout shift, immediate visual feedback, and ~95% of image bytes deferred.198199### Worked Example: Instagram Feed200201Each API response includes a 28-character BlurHash string rendered to a canvas as an instant placeholder. The actual image loads progressively (headers first, then increasing resolution scans). Result: colored blurred previews within 50ms of scrolling, zero layout shift, and perceived-instant loading.202203### Anti-Patterns204205**Lazy loading the LCP image.** Adding `loading="lazy"` to the hero image or any image visible without scrolling delays LCP by the intersection observer threshold plus download time. The LCP image should be eagerly loaded with `fetchpriority="high"`.206207**Using JavaScript lazy loading when native suffices.** Libraries like lazysizes add JavaScript overhead for functionality the browser provides natively. Use `loading="lazy"` for standard cases. Reserve JavaScript solutions for LQIP, BlurHash, or Intersection Observer patterns that native lazy loading cannot provide.208209**Placeholders that cause layout shift.** A placeholder with different dimensions than the final image causes Cumulative Layout Shift when swapped. Always set width, height, and aspect-ratio on containers. Placeholders must match the final image's aspect ratio.210211**Lazy loading all images indiscriminately.** Applying `loading="lazy"` to every image on the page, including above-the-fold images, delays rendering of critical content. Audit which images are in the initial viewport and exclude them from lazy loading.212213## Source214215- web.dev: Browser-level image lazy loading — https://web.dev/articles/browser-level-image-lazy-loading216- MDN: Lazy loading — https://developer.mozilla.org/en-US/docs/Web/Performance/Lazy_loading217- BlurHash — https://blurha.sh/218- lite-youtube-embed — https://github.com/niccoloborghesi/lite-youtube-embed219220## Process2212221. Read the instructions and examples in this document.2232. Apply the patterns to your implementation, adapting to your specific context.2243. Verify your implementation against the details and edge cases listed above.225226## Harness Integration227228- **Type:** knowledge — this skill is a reference document, not a procedural workflow.229- **No tools or state** — consumed as context by other skills and agents.230231## Success Criteria232233- Below-fold images use `loading="lazy"` and above-fold images do not.234- Video embeds use a facade pattern that loads the iframe only on interaction.235- Image placeholders (LQIP or BlurHash) are implemented for key images.236- No media lazy loading causes layout shift (containers have explicit dimensions).237- Initial page load transfers only images visible in the viewport.