Progressive enhancement isn't just a philosophy — it's a concrete technique for media delivery. You start with a baseline that works everywhere (JPG images, MP4 video, MP3 audio) and layer on improvements for capable browsers (AVIF, WebM VP9, Opus). Users with modern browsers get smaller, higher-quality files. Users with older browsers get something that works.
This guide covers format fallback chains, feature detection, and user preference signals that let you serve optimal media to every client without breaking any of them.
Image Format Fallback Chain
The <picture> element is the canonical progressive enhancement tool for images. Browsers evaluate <source> elements top to bottom and pick the first format they support.
<picture>
<!-- Tier 1: AVIF — best compression, 92%+ support -->
<source srcset="photo-400.avif 400w, photo-800.avif 800w, photo-1200.avif 1200w"
sizes="(max-width: 768px) 100vw, 50vw" type="image/avif">
<!-- Tier 2: WebP — good compression, 97%+ support -->
<source srcset="photo-400.webp 400w, photo-800.webp 800w, photo-1200.webp 1200w"
sizes="(max-width: 768px) 100vw, 50vw" type="image/webp">
<!-- Tier 3: JPG — universal fallback -->
<img src="photo-800.jpg"
srcset="photo-400.jpg 400w, photo-800.jpg 800w, photo-1200.jpg 1200w"
sizes="(max-width: 768px) 100vw, 50vw"
alt="Photo description" width="1200" height="800"
loading="lazy" decoding="async">
</picture>Why this order matters: The browser stops at the first compatible <source>. If you put WebP before AVIF, browsers that support both (Chrome, Firefox) will always pick WebP, missing the 20-30% additional savings from AVIF. Always order from most-compressed to least-compressed.
The baseline (JPG) always works. Even if a browser doesn't understand <picture> or <source>, it falls through to the <img> tag, which every browser since the 1990s supports.
Video Source Fallback Chain
Video uses the same pattern with <source> elements inside <video>:
<video controls width="1280" height="720" preload="metadata" poster="thumb.jpg">
<!-- Tier 1: AV1 — best compression, ~90% support -->
<source src="video-av1.mp4" type='video/mp4; codecs="av01.0.04M.08, opus"'>
<!-- Tier 2: VP9 — good compression, ~96% support -->
<source src="video.webm" type='video/webm; codecs="vp9, opus"'>
<!-- Tier 3: H.264 — universal fallback -->
<source src="video.mp4" type='video/mp4; codecs="avc1.42E01E, mp4a.40.2"'>
<!-- Tier 4: No video support at all -->
<p>Your browser doesn't support video. <a href="video.mp4">Download the video</a>.</p>
</video>Codec strings are important here. Without the codecs parameter in type, the browser might start downloading a file only to discover it can't decode the video codec inside. The codec string lets the browser reject incompatible files without any download.
Convert between formats as needed: MKV to MP4 | MP4 to WebM | AVI to MP4
Audio Format Fallback
<audio controls preload="metadata">
<!-- Tier 1: Opus — best quality-per-bit, 96%+ support -->
<source src="track.opus" type="audio/opus">
<!-- Tier 2: AAC — good quality, universal -->
<source src="track.m4a" type="audio/mp4">
<!-- Tier 3: MP3 — oldest universal format -->
<source src="track.mp3" type="audio/mpeg">
<p><a href="track.mp3">Download the audio</a>.</p>
</audio>Audio fallback is less critical than image or video because MP3 and AAC both have near-100% support. The main benefit of including Opus is smaller file sizes at equivalent quality. For most web applications, a single MP3 or AAC source is sufficient.
JavaScript Format Detection
When you need to know the best format in JavaScript (for dynamic image loading, canvas operations, or analytics), use feature detection rather than user-agent sniffing.
// Image format detection
function supportsImageFormat(format) {
const testImages = {
avif: 'data:image/avif;base64,AAAAIGZ0eXBhdmlmAAAAAGF2aWZtaWYxbWlhZk1BMUIAAADybWV0YQAAAAAAAAAoaGRscgAAAAAAAAAAcGljdAAAAAAAAAAAAAAAAGxpYmF2aWYAAAAADnBpdG0AAAAAAAEAAAAeaWxvYwAAAABEAAABAAEAAAABAAABGgAAAB0AAAAoaWluZgAAAAAAAQAAABppbmZlAgAAAAABAABhdjAxQ29sb3IAAAAAamlwcnAAAABLaXBjbwAAABRpc3BlAAAAAAAAAAIAAAACAAAAEHBpeGkAAAAAAwgICAAAAAxhdjFDgQ0MAAAAABNjb2xybmNseAACAAIAAYAAAAAXaXBtYQAAAAAAAAABAAEEAQKDBAAAACVtZGF0EgAKBzgABpAQ0AIyExAAAAAP+j9P/HjL8A==',
webp: 'data:image/webp;base64,UklGRiIAAABXRUJQVlA4IBYAAAAwAQCdASoBAAEADsD+JaQAA3AAAAAA',
};
return new Promise(resolve => {
const img = new Image();
img.onload = () => resolve(img.width > 0 && img.height > 0);
img.onerror = () => resolve(false);
img.src = testImages[format];
});
}
// Usage: pick the best format for dynamic image loading
async function getBestImageFormat() {
if (await supportsImageFormat('avif')) return 'avif';
if (await supportsImageFormat('webp')) return 'webp';
return 'jpg';
}
// Video codec detection
function supportsVideoCodec(mimeCodec) {
const video = document.createElement('video');
return video.canPlayType(mimeCodec) !== '';
}
console.log(supportsVideoCodec('video/mp4; codecs="av01.0.04M.08"')); // AV1
console.log(supportsVideoCodec('video/webm; codecs="vp9"')); // VP9
console.log(supportsVideoCodec('video/mp4; codecs="avc1.42E01E"')); // H.264
CSS image-set() for Background Images
The <picture> element handles <img> progressive enhancement. For CSS background images, use image-set():
.hero {
/* Fallback for browsers without image-set support */
background-image: url('hero.jpg');
/* Progressive enhancement with format selection */
background-image: image-set(
url('hero.avif') type('image/avif'),
url('hero.webp') type('image/webp'),
url('hero.jpg') type('image/jpeg')
);
background-size: cover;
background-position: center;
}Browser support: image-set() with type() is supported in Chrome 113+, Firefox 113+, and Safari 17+. The fallback background-image declaration (plain URL) ensures older browsers still get the image. This is progressive enhancement in action — old browsers get JPG, new browsers get AVIF.
Connection-Aware Media Loading
The Network Information API and Save-Data header let you adapt media quality to the user's connection.
// Network Information API
function getConnectionQuality() {
const conn = navigator.connection || navigator.mozConnection;
if (!conn) return 'unknown';
// effectiveType: 'slow-2g', '2g', '3g', '4g'
return conn.effectiveType;
}
function getImageQuality() {
const quality = getConnectionQuality();
switch (quality) {
case 'slow-2g':
case '2g': return { maxWidth: 400, format: 'jpg', quality: 60 };
case '3g': return { maxWidth: 800, format: 'webp', quality: 70 };
case '4g':
default: return { maxWidth: 1200, format: 'avif', quality: 80 };
}
}
// Save-Data header (server-side)
// The browser sends: Save-Data: on
app.get('/image/:name', (req, res) => {
const saveData = req.headers['save-data'] === 'on';
if (saveData) {
// Serve heavily compressed, smaller version
return res.sendFile(`/images/low/${req.params.name}`);
}
return res.sendFile(`/images/full/${req.params.name}`);
});
// Client-side Save-Data detection
if (navigator.connection && navigator.connection.saveData) {
// User has data saver enabled — skip autoplay video, use low-res images
document.querySelectorAll('video[autoplay]').forEach(v => {
v.removeAttribute('autoplay');
v.preload = 'none';
});
}Practical impact: On a slow connection, swapping a 200KB AVIF hero image for a 30KB compressed JPG thumb can mean the difference between a 3-second and a 15-second page load. Respect the user's bandwidth constraints.
Respecting prefers-reduced-motion
Users who set reduced motion preferences (vestibular disorders, seizure sensitivity, personal preference) should not see autoplay video, animated GIFs, or CSS animations.
/* CSS: disable animations for reduced-motion users */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
/* Replace autoplay video with static poster */
video[autoplay] {
display: none;
}
.video-poster {
display: block;
}
}// JavaScript: conditional video autoplay
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (prefersReducedMotion) {
// Don't autoplay videos
document.querySelectorAll('video[autoplay]').forEach(video => {
video.removeAttribute('autoplay');
video.pause();
});
// Replace animated GIFs with static images
document.querySelectorAll('img[src$=".gif"]').forEach(img => {
// Swap to static first frame or poster
img.src = img.src.replace('.gif', '-static.jpg');
});
}This is an accessibility requirement, not an optional enhancement. WCAG 2.1 Success Criterion 2.3.3 (Animation from Interactions) and 2.2.2 (Pause, Stop, Hide) both apply. Failing to respect reduced motion is an accessibility violation.
High Contrast and Media
prefers-contrast indicates whether the user has requested high contrast mode. For media, this mainly affects text overlays on images and SVG rendering.
@media (prefers-contrast: more) {
/* Increase text contrast on image overlays */
.hero-overlay {
background: rgba(0, 0, 0, 0.8); /* Darker overlay for readability */
color: #fff;
}
/* Ensure SVG icons have sufficient contrast */
.icon svg {
stroke: currentColor;
stroke-width: 2;
}
/* Add borders to images for visual separation */
img {
border: 2px solid currentColor;
}
}
@media (prefers-contrast: less) {
/* Reduce visual intensity */
.hero-overlay {
background: rgba(0, 0, 0, 0.3);
}
}
Media Without JavaScript
All media elements should function without JavaScript. This is the foundation of progressive enhancement: the HTML alone provides a working experience. JavaScript adds enhancements.
<!-- Works without JS: browser handles format selection, lazy loading, and responsive sizing -->
<picture>
<source srcset="hero.avif" type="image/avif">
<source srcset="hero.webp" type="image/webp">
<img src="hero.jpg" alt="Hero" width="1200" height="600" loading="lazy">
</picture>
<!-- Works without JS: browser handles playback and format selection -->
<video controls preload="metadata" poster="thumb.jpg">
<source src="video.webm" type="video/webm">
<source src="video.mp4" type="video/mp4">
</video>
<!-- Fails without JS: image won't load -->
<div class="lazy-image" data-src="photo.jpg"></div>
<!-- This pattern breaks without JavaScript. Use native loading="lazy" instead. -->The rule: If removing all JavaScript from the page makes media elements disappear or become non-functional, your progressive enhancement is broken. Native HTML attributes (loading, srcset, sizes, <source>) work without JavaScript. Data attributes that require JavaScript to swap into real attributes do not.
Progressive enhancement for media is not extra work on top of "normal" development — it IS normal development. The <picture> element, <source> fallbacks, and native HTML attributes like loading and srcset are the standard tools. Using them correctly means every user gets an appropriate experience, from a 2G phone to a high-end desktop.
The user preference signals (reduced motion, save data, contrast) are the next level. They're not hard to implement — a few media queries and a dozen lines of JavaScript. But they make the difference between a site that respects its users and one that doesn't.