How to Generate Responsive Images at Build Time
This guide automates Responsive Images with srcset and sizes, part of Image & Media Optimization. Hand-written srcset attributes do not scale: someone has to export five widths in three formats for every image, keep filenames consistent, record dimensions, and update everything when an image changes. In practice teams skip it, and pages ship one large JPEG to every device.
A build-time pipeline removes the manual work. Source images live in the repository (or a CMS export) at high resolution; the build generates a fixed set of widths in AVIF, WebP and a JPEG fallback, records intrinsic dimensions, optionally produces placeholders, and gives templates a helper that emits the right <picture> or <img srcset>. Static-site generators and frameworks often provide this (Astro's <Image>, Nuxt Image's static provider, Eleventy Image, Next.js image optimisation), and a small sharp script can do it for anything else.
Rapid Diagnosis
- Count image variants in the build output. One file per image means no responsive images.
- Check templates for srcset. Missing or hand-written
srcseton content images indicates no pipeline. - Compare natural and rendered sizes. Images much larger than their rendered width times DPR are wasted bytes and decode time.
- Check build times. Pipelines that re-encode every image on every build slow CI; caching matters.
Root Cause Analysis
1. Manual exports. Variants are produced by hand and inevitably drift or are skipped.
2. No dimension metadata. Templates cannot emit width/height, causing CLS.
3. Too many or too few widths. Generating every 100px wastes build time and storage; generating two widths leaves big gaps.
4. No caching. Encoding AVIF is slow; re-encoding unchanged images on every build makes the pipeline painful and likely to be disabled.
Step-by-Step Resolution
1. Choose a width ladder
Pick widths that cover your layouts at common DPRs with modest gaps — for example 320, 480, 640, 800, 1200, 1600, 2000. More widths mean closer matches but more build time and storage; five to seven is typical.
2. Generate variants with sharp and cache by content hash
// scripts/images.mjs
import sharp from 'sharp';
import { createHash } from 'node:crypto';
import { readFile, writeFile, mkdir, access } from 'node:fs/promises';
const WIDTHS = [480, 800, 1200, 1600, 2000];
export async function processImage(src) {
const buf = await readFile(src);
const hash = createHash('sha256').update(buf).digest('hex').slice(0, 10);
const { width, height } = await sharp(buf).metadata();
const variants = [];
for (const w of WIDTHS.filter((w) => w <= width)) {
for (const [fmt, opts] of [['avif', { quality: 50 }], ['webp', { quality: 75 }], ['jpeg', { quality: 78, mozjpeg: true }]]) {
const out = `dist/img/${hash}-${w}.${fmt === 'jpeg' ? 'jpg' : fmt}`;
try { await access(out); } catch { await sharp(buf).resize(w).toFormat(fmt, opts).toFile(out); }
variants.push({ w, fmt, url: out.replace('dist', '') });
}
}
return { width, height, variants };
}
// trade-off: AVIF encoding is CPU-heavy; the existence check caches by content
// hash across builds. Persist dist/img between CI runs or the cache is useless.
Expected outcome: every source image has right-sized variants in three formats, named by content hash.
3. Emit markup from a template helper
export function picture({ width, height, variants }, { alt, sizes, priority = false }) {
const set = (fmt) => variants.filter((v) => v.fmt === fmt).map((v) => `${v.url} ${v.w}w`).join(', ');
const fallback = variants.filter((v) => v.fmt === 'jpeg').at(-1).url;
return `<picture>
<source type="image/avif" srcset="${set('avif')}" sizes="${sizes}">
<source type="image/webp" srcset="${set('webp')}" sizes="${sizes}">
<img src="${fallback}" srcset="${set('jpeg')}" sizes="${sizes}" width="${width}" height="${height}"
alt="${alt}" ${priority ? 'fetchpriority="high"' : 'loading="lazy" decoding="async"'}>
</picture>`;
}
// trade-off: the helper needs an accurate sizes value per usage; a wrong sizes
// makes the browser choose too large or too small a variant.
4. Serve with immutable caching
Hashed filenames can be cached for a year with immutable; changed images get new hashes automatically.
Verification
Check the build output: each source image has the expected variants. In the browser at several widths and DPRs, confirm the chosen currentSrc is the smallest variant that covers the rendered size. Run Lighthouse's "Properly size images" audit; it should pass. Track total image bytes per page view in RUM before and after.
Worked Example: A Static Marketing Site
A static marketing site committed 340 images exported at 2400px as JPEG and referenced them directly. Pages averaged 4.2MB of images on mobile. A sharp pipeline with a five-width ladder, AVIF/WebP/JPEG output, content-hash caching persisted in CI, and a picture() helper used by all templates reduced average mobile image weight to 680KB. The first full build took 9 minutes; subsequent builds with the cache took 25 seconds. LCP p75 on mobile improved by 700ms.
Build-Time vs Image CDN
Build-time generation suits static and mostly-static sites where images are known at build time: it costs nothing at request time and works with any host. Image CDNs suit dynamic catalogues and user uploads, where generating every variant ahead of time is impractical: they transform on first request and cache the result at the edge. Many teams combine them — build-time variants for site chrome and editorial images, an image CDN for catalogue and user content — with one template helper that hides the difference. The guide on image CDNs and fetchpriority covers the CDN side.
Common Mistakes
- Upscaling small sources. Generate only widths up to the source width.
- Missing dimensions in markup. The pipeline should return width and height to the template.
- No cache in CI. AVIF encoding makes uncached builds slow; teams then disable the pipeline.
- A fixed
sizeseverywhere.sizesdepends on layout; pass it per usage.
Edge Cases
CMS images. If editors upload images at runtime, a build-time pipeline only covers images present at build; use an image CDN or a webhook-triggered build.
Transparent PNGs. Keep alpha: AVIF and WebP support it; fallback should be PNG, not JPEG.
Animated images. Exclude GIFs and animated sources from the still-image pipeline; convert them to video instead.
Colour profiles. Strip or convert to sRGB to avoid colour shifts and save bytes, unless you serve wide-gamut images deliberately.
FAQ
How many widths should I generate?
Five to seven covering your smallest and largest rendered sizes at DPR 1–3 is typical. Gaps of roughly 30–50% between widths keep wasted bytes small without exploding build time.
Is AVIF worth the build time?
Usually yes for images that matter (heroes, product photos). With caching by content hash, the encode cost is paid once per image, not per build.
Should JPEG fallbacks still be generated?
For broad compatibility and email or social previews, yes, though nearly all browsers now support WebP and most support AVIF. Fallbacks cost storage, not user bytes.
Can the pipeline generate placeholders too?
Yes — dominant colours or tiny LQIP previews can be computed with sharp in the same pass and passed to the template.
How do I handle images in Markdown content?
Use a Markdown plugin or post-processing step that replaces image references with the picture() helper's output, so authors keep writing simple Markdown.
Does Next.js need a build-time pipeline?
next/image optimises on demand at runtime (or via a custom loader to an image CDN). For static exports, use a loader or a build-time tool, since the default optimiser requires a server.
How do I keep the pipeline fast as the image library grows?
Cache outputs by content hash (and persist the cache between CI runs), process images in parallel with a concurrency limit matched to CPU cores, and skip widths larger than the source. With those three, adding images costs only their own encoding time, once.
Related
- Batch-converting images with sharp — the conversion step in depth.
- Responsive images with Nuxt Image and Astro — framework-integrated pipelines.
- Setting up immutable cache headers for hashed assets — caching the outputs.