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.

How a browser resolves a picture element A decision flow. The browser reads sources in order: if it supports AVIF it takes the first source and stops. If not, it evaluates the WebP source and takes that if supported. If neither is supported it falls through to the img element's JPEG. A note shows that listing WebP first would give AVIF-capable browsers the larger file. First supported source wins — order is the algorithm source: AVIF 92% of traffic stops here source: WebP 7% stop here img: JPEG <1% fall through not supported not supported The common mistake WebP listed first → every AVIF-capable browser takes WebP and stops The AVIF files are still generated, still deployed, and never served Support shares from a documentation site's own analytics, mid-2026 — check your own before assuming
Nothing errors when the order is wrong — the pipeline still produces AVIF, the site still works, and the bytes you paid encoding time for are never delivered.

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.

SetupWho choosesVary neededCache risk
<picture> with per-format URLsBrowserNoNone
Single URL, CDN negotiates on AcceptEdgeYesBroken images if omitted
Single URL, Worker rewritesWorkerYesSame, 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.

Per-URL variants versus Accept negotiation With per-URL variants each format is a separate immutable cache object and the browser picks, giving a full cache hit ratio. With Accept-based negotiation one URL maps to several bodies, requiring a Vary header, and the cache is fragmented by Accept value with a lower hit ratio. Per-URL variants versus Accept negotiation Per-URL variants hero.a91f3c2.avif hero.a91f3c2.webp hero.a91f3c2.jpg no Vary · immutable · 100% hit ratio browser decides from the picture element Accept negotiation /img/hero.jpg → 3 possible bodies Vary: Accept required cache split by Accept value lower hit ratio · silent breakage if Vary is lost edge decides, and must be told to On a static site the left column is almost always the right answer — the build already produced every variant
Negotiation exists for systems that cannot enumerate variants ahead of time. A static build can, which removes both the header requirement and the cache fragmentation.

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 policyImage transferLCPBuild time (6 images × 3 widths)
JPEG only, q78812 KB3.10 s1.4 s
WebP with JPEG fallback386 KB2.18 s3.9 s
AVIF + WebP + JPEG268 KB1.94 s22.6 s
AVIF + WebP + JPEG, cached build268 KB1.94 s0.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.

Image transfer and build time by format policy A paired comparison. JPEG only transfers 812 kilobytes and builds in 1.4 seconds. WebP with a JPEG fallback transfers 386 kilobytes and builds in 3.9 seconds. AVIF, WebP and JPEG transfers 268 kilobytes but builds in 22.6 seconds, dropping to 0.3 seconds when the encoder output is cached. Readers pay bytes, the pipeline pays seconds JPEG only 812 KB WebP + JPEG 386 KB AVIF + WebP + JPEG 268 KB LCP 1.94 s Build time: 1.4 s → 3.9 s → 22.6 s uncached, and 0.3 s once encoder output is cached by content hash Shared linear scale on transfer · 6 images × 3 widths, Chrome 4× CPU throttle, fast 3G
The encoding cost is real and entirely absorbed by the cache. What reaches readers is a third of the original transfer and a 1.2-second LCP improvement.

Pitfalls & Rollback

  • WebP listed before AVIF. Everything works and the AVIF is never served. Order most-efficient first.
  • sizes on only one source. Each <source> is evaluated independently and needs its own sizes.
  • 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 Accept without Vary. 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.