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
| Format | Size vs JPEG | Encode speed | Best for |
|---|---|---|---|
| JPEG (mozjpeg) | baseline | Very fast | Universal fallback |
| WebP lossy | 25–34% smaller | Fast | General-purpose default |
| WebP lossless | 26% smaller than PNG | Moderate | Screenshots, UI, flat colour |
| AVIF | Smaller again on photos | Very slow | Large photographic heroes |
| PNG | Far larger | Fast | Transparency-critical only |
| SVG | Tiny, resolution-free | N/A | Logos, 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, immutableALT 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
- 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.
- Convert the hero first. One image, AVIF plus WebP, preloaded,
fetchpriority="high", no lazy. Re-measure LCP. - Roll the shortcode across templates so every remaining image gets
<picture>, correctsizes, explicit dimensions, andloading="lazy". - Cache
resources/_genin CI before the build time becomes a habit-breaker. - 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.
MarketLens