Generate Report →

Build-Time Image Optimization: WebP & AVIF Pipelines for Hugo

AVIF beats WebP on photographs but costs far more encode time. Here is the build-time pipeline, the picture markup, and where each format actually wins.

Images are usually the largest share of a page’s transferred bytes and the single most common cause of a failing Largest Contentful Paint on mobile. They are also the part of performance work most often handed to a runtime plugin that resizes on first request, caches the result, and quietly adds a cold-start penalty to every new variant.

Build-time optimization inverts that. Every derivative — every format, every width — is generated once during the build, committed to the artifact, and served from the CDN edge as a static file. No transform service, no origin round trip, no per-request cost.

This walks through the format decision, the encoder settings that actually hold up, the markup that lets the browser pick correctly, and the caching detail that stops the whole thing from making your CI builds unbearable.

The format decision, honestly

FormatSize vs JPEGEncode speedBest for
JPEG (mozjpeg)baselineVery fastUniversal fallback
WebP lossy25–34% smallerFastGeneral-purpose default
WebP lossless26% smaller than PNGModerateScreenshots, UI, flat colour
AVIFSmaller again on photosVery slowLarge photographic heroes
PNGFar largerFastTransparency-critical only
SVGTiny, resolution-freeN/ALogos, icons, diagrams

The WebP percentages are Google’s own published figures for the format, in the WebP documentation. The AVIF row deliberately carries no number: the honest answer is that the saving depends heavily on the image and on your encoder settings, and you should measure your own library rather than trust anyone’s headline percentage — including mine. Run both encoders over twenty representative images and compare the totals.

Support figures move, so check caniuse.com rather than a blog post. Both WebP and AVIF are broadly supported in current browsers, which is what makes the <picture> fallback chain cheap.

Where AVIF genuinely earns its encode cost is large photographic content, where the byte saving over WebP is visible in the network panel and the quality difference is not visible at all. Where it does not is small assets: below roughly 20KB, container overhead and the format’s block structure erode the advantage to the point where an AVIF icon can come out larger than its WebP equivalent. Screenshots with fine text are their own case, where WebP lossless frequently wins outright.

Practical rule: AVIF for anything over about 600px wide and photographic. WebP for everything else raster. SVG for anything vector. Never ship a PNG photograph.

Quality settings do not translate between formats, which trips people up constantly. AVIF quality 50 is roughly perceptually equivalent to WebP 80 or JPEG 85 — the scales are unrelated. Starting points that hold up in practice: photographic AVIF 50–58, photographic WebP 78–82, text-bearing screenshots WebP 90 or lossless.

Generating derivatives at build time in Hugo

Hugo’s image processing runs in the template layer, which means the markup and the encoding stay in one place. A reusable shortcode:

{{- $src := resources.Get (.Get "src") -}}
{{- $alt := .Get "alt" -}}
{{- $sizes := .Get "sizes" | default "(max-width: 768px) 100vw, 800px" -}}
{{- $widths := slice 400 800 1200 1600 -}}

{{- $avif := slice -}}
{{- $webp := slice -}}
{{- range $widths -}}
  {{- $w := . -}}
  {{- if ge $src.Width $w -}}
    {{- $a := $src.Resize (printf "%dx webp q82" $w) -}}
    {{- $b := $src.Resize (printf "%dx avif q55" $w) -}}
    {{- $webp = $webp | append (printf "%s %dw" $a.RelPermalink $w) -}}
    {{- $avif = $avif | append (printf "%s %dw" $b.RelPermalink $w) -}}
  {{- end -}}
{{- end -}}

{{- $fallback := $src.Resize "800x webp q82" -}}
<picture>
  <source type="image/avif" srcset="{{ delimit $avif ", " }}" sizes="{{ $sizes }}">
  <source type="image/webp" srcset="{{ delimit $webp ", " }}" sizes="{{ $sizes }}">
  <img src="{{ $fallback.RelPermalink }}"
       width="{{ $fallback.Width }}" height="{{ $fallback.Height }}"
       alt="{{ $alt }}" loading="lazy" decoding="async">
</picture>

Three details worth calling out. The ge $src.Width $w guard prevents upscaling — generating a 1600px derivative from a 900px source wastes bytes and looks worse. The width and height attributes come from the actual processed resource, so they are always correct and CLS stays at zero. And the fallback <img> is WebP rather than JPEG, because WebP support is now broad enough that a separate JPEG tier serves almost nobody while doubling your derivative count.

For non-Hugo stacks, sharp gives you the same pipeline in Node:

import sharp from 'sharp';

const widths = [400, 800, 1200, 1600];
const img = sharp('src/hero.jpg');
const { width: srcW } = await img.metadata();

for (const w of widths.filter(w => w <= srcW)) {
  await img.clone().resize(w).avif({ quality: 55, effort: 4 })
    .toFile(`dist/hero-${w}.avif`);
  await img.clone().resize(w).webp({ quality: 82 })
    .toFile(`dist/hero-${w}.webp`);
}

The effort parameter on AVIF is the encode-time dial: effort: 4 is a reasonable CI compromise, effort: 9 buys a few more percent for several times the wall clock.

Getting sizes right, because srcset alone does nothing

srcset tells the browser which files exist. sizes tells it how wide the image will be rendered. Omit sizes and the browser assumes 100vw, so a 300px-wide thumbnail in a sidebar downloads the 1600px file.

Write sizes to mirror your actual CSS breakpoints:

sizes="(max-width: 600px) 100vw, (max-width: 1024px) 50vw, 400px"

Verify it rather than trusting it. In Chrome DevTools, open the Network panel, filter to images, and add the “Resource Size” column — then resize the viewport. If a narrow viewport pulls the 1600px variant, your sizes is wrong.

The hero image is the one exception to everything above. It must not be lazy-loaded, it should carry fetchpriority="high", and it deserves a preload hint in <head> so the preload scanner starts the fetch before CSS is parsed. On a throttled mobile connection that single change is often the largest LCP improvement available to you, and it takes minutes — the full mechanism is in achieving 100/100 mobile Core Web Vitals.

Keeping CI builds fast

The first time AVIF encoding runs across a few hundred images, a build measured in seconds becomes a build measured in minutes. Hugo caches derivatives in resources/_gen/images/, keyed by source hash and processing spec, so locally you pay that cost once. A fresh CI runner starts empty and pays it on every single run.

Two fixes, either works:

- name: Restore Hugo image cache
  uses: actions/cache@v4
  with:
    path: resources/_gen
    key: hugo-resources-${{ hashFiles('assets/**') }}
    restore-keys: hugo-resources-

Or commit resources/_gen directly. It bloats the repository, but it makes builds deterministic and removes a moving part from deployment. For sites under a few hundred images, committing is the lower-drama option. Broader pipeline patterns are covered in automating Hugo deployments to Cloudflare Pages via GitHub Actions.

Also set cache headers on the derivatives. Because the filenames are content-hashed, they are safe to cache permanently:

/images/*
  Cache-Control: public, max-age=31536000, immutable

ALT text is now a retrieval surface, not a checkbox

Classic image SEO treated ALT text as an accessibility requirement with a small ranking bonus. Multimodal retrieval changed the stakes: when a vision-capable model ingests your page, ALT text is often the only textual representation of the image it stores, and captions carry extra weight because they exist in the rendered text flow rather than an attribute.

The difference between useless and useful is specificity:

  • Weak: alt="chart" / alt="dental clinic" / alt="seo"
  • Strong: alt="Search Console line chart of monthly organic clicks, flat for a year then rising steadily from January"
  • Strong: alt="Cloudflare Pages build log with the htmltest step failing on a broken internal link"

Describe what the image shows, not what you wish the reader to conclude — and if the image contains numbers, put the real ones in the ALT text or leave numbers out of it entirely. Filenames matter for the same reason. hero-1600.avif says nothing; organic-clicks-monthly-chart-1600.avif is a retrievable string. Neither is a ranking hack — both are simply the difference between an asset a model can describe and one it cannot. The same extraction logic governs your prose, which is covered in structural extractability and positional retrieval bias.

Skip the decorative case: purely ornamental images should carry alt="" so screen readers pass over them. Stuffing keywords into decorative ALT is both an accessibility failure and a spam signal.

A one-afternoon implementation order

  1. Audit current weight. Load your top landing page in DevTools, sort Network by size, and note the total image bytes. Anything over 500KB on mobile is the problem.
  2. Convert the hero first. One image, AVIF plus WebP, preloaded, fetchpriority="high", no lazy. Re-measure LCP.
  3. Roll the shortcode across templates so every remaining image gets <picture>, correct sizes, explicit dimensions, and loading="lazy".
  4. Cache resources/_gen in CI before the build time becomes a habit-breaker.
  5. Rewrite ALT text on the twenty images that appear on your highest-traffic pages.

Steps two and three are where the page weight actually moves; measure before and after each one so you know which of them paid, rather than assuming. If you would like the per-image byte breakdown and the exact ALT strings currently on your pages reported back to you, that inventory is part of the Standard MarketLens audit.

Run this article on your site

Build a responsive image pipeline for my static site. Convert every raster image to a picture element that serves AVIF first, WebP second, and the original as final fallback, generating srcset widths at 400, 800, 1200 and 1600 pixels with an accurate sizes attribute for my layout. Add explicit width and height attributes to prevent layout shift, set loading='lazy' plus decoding='async' on everything below the fold, and fetchpriority='high' with no lazy attribute on the hero image. Then rewrite every generic ALT text into a specific description of what the image actually shows.

Paste into Claude Code, ChatGPT, Cursor or Gemini. It executes the steps above against your own site.

Frequently Asked Questions

Should I use AVIF or WebP in 2026?

Use both, served through a picture element with AVIF first and WebP as fallback. AVIF produces meaningfully smaller files than WebP at equivalent perceptual quality on photographic content, but encoding is far slower and it loses its advantage on small flat-colour graphics. Both formats are broadly supported across current browsers — check caniuse.com for today's figures — so the fallback chain costs you almost nothing.

What quality setting should I use for WebP and AVIF?

For photographic content, WebP at quality 78-82 and AVIF at quality 50-58 are visually indistinguishable from the source at typical viewing sizes — AVIF's quality scale is not comparable to JPEG's, so 50 is not 'half quality'. Screenshots and diagrams with text need higher settings, around WebP 90, or lossless PNG-to-WebP conversion.

How many srcset widths do I actually need?

Four to five covers virtually every real layout: roughly 400, 800, 1200, 1600 and 2000 pixels wide. More breakpoints add build time and cache fragmentation for savings the user will never perceive. The important part is an accurate sizes attribute, because without it the browser assumes 100vw and downloads a larger file than your layout needs.

Does image ALT text still matter for AI search?

More than it did for classic SEO. Vision-capable models and multimodal retrieval pipelines read ALT text as the primary textual description of the image, and captions carry additional weight because they sit in the rendered text flow. Descriptive, specific ALT text makes an image citable rather than merely accessible.

Why is my Hugo build suddenly taking minutes after adding image processing?

Hugo caches processed images in the resources directory, but a cold CI runner starts with an empty cache and re-encodes everything — and AVIF encoding is expensive. Commit the resources/_gen folder to Git, or restore it with actions/cache in your workflow, and subsequent builds drop back to seconds.

Continue the track — Static Architecture & Performance