Serving AVIF With Fallbacks on Static Sites
AVIF is the most efficient widely supported image format, typically 20-30% smaller than WebP at matched quality and dramatically smaller than the JPEG most sites still ship. On a static site the entire cost is paid at build time, so readers get the smaller file and the pipeline absorbs the encoding.
The complication is that "widely supported" is not "universally supported", and the fallback mechanics are easy to get subtly wrong — a source order that defeats the point, a Vary header that poisons a cache, a <picture> element that loses the dimensions that keep layout stable. This guide gets all of it right. It sits under Image Optimization Pipelines in Astro.
Prerequisites
- A build-time image pipeline:
astro:assets,@11ty/eleventy-img, Hugo image resources, or Sharp called directly. - Source images at least as large as the biggest rendered size you need.
- A way to cache build output, since AVIF encoding is the slowest step in most image pipelines.
Order the Sources Correctly
The <picture> element picks the first source whose type the browser supports. That makes ordering a correctness issue rather than a style one:
<picture>
<source type="image/avif" srcset="/img/hero-480.avif 480w, /img/hero-960.avif 960w, /img/hero-1600.avif 1600w"
sizes="(min-width: 768px) 720px, 100vw">
<source type="image/webp" srcset="/img/hero-480.webp 480w, /img/hero-960.webp 960w, /img/hero-1600.webp 1600w"
sizes="(min-width: 768px) 720px, 100vw">
<img src="/img/hero-960.jpg" width="1600" height="900" alt="Deployment dashboard"
loading="eager" fetchpriority="high" decoding="async">
</picture>
Three details are load-bearing. AVIF comes first because it is the most efficient. The <img> carries width and height, which is what reserves layout space — see Reserving Space for Images and Embeds. And sizes is repeated on every source, because each one is evaluated independently.
Generate the Variants in One Step
// scripts/encode-images.mjs — one source in, nine files out, cached by content hash
import { createHash } from 'node:crypto';
import { mkdir, readFile, writeFile, access } from 'node:fs/promises';
import { globSync } from 'node:fs';
import path from 'node:path';
import sharp from 'sharp';
const WIDTHS = [480, 960, 1600];
const OUT = 'dist/img';
await mkdir(OUT, { recursive: true });
for (const src of globSync('src/images/**/*.{jpg,jpeg,png}')) {
const buf = await readFile(src);
const hash = createHash('sha1').update(buf).digest('hex').slice(0, 8);
const base = path.basename(src).replace(/\.[^.]+$/, '');
for (const w of WIDTHS) {
const resized = sharp(buf).resize(w, null, { withoutEnlargement: true });
const jobs = [
[`${base}-${w}.${hash}.avif`, resized.clone().avif({ quality: 55, effort: 4 })],
[`${base}-${w}.${hash}.webp`, resized.clone().webp({ quality: 76 })],
[`${base}-${w}.${hash}.jpg`, resized.clone().jpeg({ quality: 78, mozjpeg: true })],
];
for (const [name, pipeline] of jobs) {
const out = path.join(OUT, name);
try { await access(out); continue; } catch { /* not cached */ }
await writeFile(out, await pipeline.toBuffer());
}
}
}
Two choices deserve explanation. AVIF quality 55 against WebP 76 is not a typo: the two scales are not comparable, and AVIF holds up at a much lower number for the same perceived quality. And effort: 4 rather than the maximum trades a few per cent of file size for roughly half the encoding time, which matters when a build encodes hundreds of images.
Content-hashing the filename is what makes the output immutably cacheable, per CDN Caching Rules for SSGs, and it doubles as the cache key for the encoder — an unchanged source produces the same filename, which already exists, so nothing is re-encoded.
Do Not Assume AVIF Always Wins
For photographic content it nearly always does. For flat illustrations, diagrams and screenshots containing text, WebP or even a well-optimised PNG can be smaller, and AVIF's lossy encoding smears thin high-contrast edges in a way that is very visible on a screenshot of a terminal.
Pick per image rather than by policy:
// After encoding, keep only the smaller of avif/webp for this image
const [avif, webp] = await Promise.all([stat(avifPath), stat(webpPath)]);
if (avif.size >= webp.size * 0.95) {
await rm(avifPath); // AVIF is not winning enough to justify the source
console.log(`${base}: keeping webp (${webp.size} < ${avif.size})`);
}
Then omit the AVIF <source> for that image. A build-time decision keeps the markup honest and prevents the odd result where the "most efficient" format is the largest file on the page.
Vary Headers and the Negotiation Trap
With <picture>, each variant has its own URL and the browser chooses, so no negotiation happens at the server and no Vary header is needed. The trap appears when something in front of the site rewrites a single URL based on Accept — an image CDN, a Worker, a host feature.
In that case the response must carry Vary: Accept, or a cache will serve the AVIF it stored for a modern browser to a client that cannot decode it. The symptom is a broken image for a small share of readers, and it is invisible in testing because your own browser gets a correct variant.
| Setup | Who chooses | Vary needed | Cache risk |
|---|---|---|---|
<picture> with per-format URLs | Browser | No | None |
Single URL, CDN negotiates on Accept | Edge | Yes | Broken images if omitted |
| Single URL, Worker rewrites | Worker | Yes | Same, plus lower hit ratio |
The per-URL approach is simpler and caches better — every variant is a separate immutable object with a 100% hit ratio, rather than one URL whose cache is fragmented by header value.
Measured Impact
An article template with one hero and five body images, Chrome 4× CPU throttle on a fast 3G profile, median of five runs:
| Format policy | Image transfer | LCP | Build time (6 images × 3 widths) |
|---|---|---|---|
| JPEG only, q78 | 812 KB | 3.10 s | 1.4 s |
| WebP with JPEG fallback | 386 KB | 2.18 s | 3.9 s |
| AVIF + WebP + JPEG | 268 KB | 1.94 s | 22.6 s |
| AVIF + WebP + JPEG, cached build | 268 KB | 1.94 s | 0.3 s |
The build-time row is the honest cost: AVIF encoding is roughly six times slower than WebP, which turns a two-second image step into twenty. Caching by content hash removes it entirely for unchanged images, so only a genuinely new image pays — the same caching discipline described in Caching node_modules in GitHub Actions for Faster SSG Builds.
Pitfalls & Rollback
- WebP listed before AVIF. Everything works and the AVIF is never served. Order most-efficient first.
sizeson only one source. Each<source>is evaluated independently and needs its ownsizes.- Dimensions missing from the
<img>. The fallback element is what reserves layout space; without it the picture still shifts the page. - Assuming AVIF wins for screenshots. Compare bytes per image and drop the AVIF source when it does not.
- Negotiating on
AcceptwithoutVary. A cache will hand an AVIF to a client that cannot decode it. - Rollback: remove the AVIF
<source>line from the template. The WebP and JPEG variants are already deployed, so the page keeps working with no rebuild and no cache purge.
Conclusion
Serving AVIF properly is three decisions: order sources by efficiency, keep dimensions on the fallback <img>, and let the browser choose from per-format URLs rather than negotiating at the edge. Encode with format-appropriate quality settings, cache by content hash so the slow encoder runs only for new images, and check per image that AVIF is actually smaller before shipping the source. The wider pipeline is in Image Optimization Pipelines in Astro.
FAQ
Is AVIF always smaller than WebP?
Usually, by 20 to 30 percent at matched visual quality on photographic content, and occasionally not — flat illustrations and screenshots with sharp text sometimes encode smaller as WebP or even as optimised PNG. Encode both, compare the actual bytes, and keep whichever wins per image.
Does AVIF cost more to decode on low-end devices?
It decodes more slowly than WebP, by a few milliseconds for typical content sizes, which is far outweighed by transferring 30 percent fewer bytes on a slow connection. On a fast connection with a very cheap device the trade narrows but still favours the smaller file.
Why does picture source order matter?
The browser takes the first source whose type it supports, so listing WebP before AVIF means AVIF-capable browsers still get WebP. Order sources from most to least efficient, with the plain <img> last as the universal fallback.
Do I need Vary headers for content negotiation?
Not with the <picture> element, because the browser chooses and each variant has its own URL. Vary matters only when a server or CDN rewrites a single URL based on the Accept header, which is where mis-set Vary silently poisons caches.
How much does encoding AVIF slow the build?
Substantially — AVIF encoding is several times slower than WebP. Cache the encoded output keyed on the source file so only new or changed images pay the cost, and consider a lower encoder effort setting for large batches.
Related
- Parent: Image Optimization Pipelines in Astro — the pipeline that generates these variants.
- Optimizing WebP Images in Hugo Without Plugins — the same work in Hugo's native pipeline.
- Reserving Space for Images and Embeds — the dimensions the fallback
<img>must carry. - Reducing LCP From Hero Images on Static Sites — what to do with the hero once it is small.
- CDN Caching Rules for SSGs — immutable caching for hashed variants.