Skip to content
HandbookPublic
Security

Image optimisation

Format, sizing, priority and lazy loading — with the decisions that matter on mid-range Android handsets and metered connections.

Written forFrontend engineers whose pages are heavier than they meant them to be
Reading time9 min read
Last reviewed2026-09-05

Images are usually the largest thing a page downloads and almost always the LCP element. They are also where the easiest wins live: most sites are shipping images two to four times larger than the box they are rendered into, in a format a decade old, at a priority the browser guessed.

Format: AVIF, WebP, and when neither

FormatUse forWatch out for
AVIFPhotographs and hero images — typically 30–50% smaller than WebP at equal qualitySlower to encode; decode cost is real on old devices, so do not use it for dozens of thumbnails
WebPThe safe default for photographic content and the fallback below AVIFLossless WebP is often larger than PNG for flat graphics
SVGLogos, icons, diagrams, anything geometricSanitise any SVG you did not author — it is executable markup
PNGScreenshots with text, images needing exact pixelsRun it through a lossy quantiser; a 24-bit PNG of a UI screenshot is nearly always wasteful
JPEGFallback onlyProgressive JPEG still beats baseline for perceived speed on slow links

Serve the size the box actually needs

A 2400px-wide image in a 400px column wastes roughly 96% of the bytes it transferred, and on a device with a 2× screen you still only needed 800px. The browser will choose correctly if you tell it both what widths exist and how wide the box will be — and the second half is the one teams forget.

html
<img
  src="/case-800.avif"
  srcset="/case-400.avif 400w, /case-800.avif 800w, /case-1600.avif 1600w"
  sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
  width="1600" height="900"
  alt="Gate scan in progress at the assembly entrance"
  loading="lazy" decoding="async">
Without an accurate sizes attribute the browser assumes full viewport width and downloads the largest candidate.
  • Always set width and height (or aspect-ratio). It costs nothing and removes the layout shift entirely.
  • Generate three or four candidate widths, not eight. Past a point the extra variants cost build time and cache entries for no measurable benefit.
  • Cap the largest variant at what your layout can actually display. A 3000px variant on a page whose content column maxes at 1280 is dead weight in the cache.

Priority: the LCP image must not be lazy

The most common self-inflicted LCP problem we find is loading="lazy" on the hero image, applied by a global rule or a component default. The browser then deliberately delays the one image the metric is measuring.

01
Eager and high priority above the foldThe LCP candidate gets fetchpriority="high" and no lazy attribute. In Next.js that is the priority prop on exactly one image per page.
02
Lazy everything below itloading="lazy" from the second viewport down. The default root margin is generous enough that users rarely see a placeholder.
03
De-prioritise carousel images past the firstfetchpriority="low" on slides two onward. They otherwise compete with the LCP image for bandwidth on exactly the connection where it matters.
04
Preload only when discovery is lateIf the hero is referenced by CSS or by a component that hydrates late, preload it in the head. If it is a plain img in the initial HTML, the preload is redundant.

Delivery and caching

  • Put a content hash in the filename and serve with a one-year immutable cache. Revalidation requests on images are pure latency for no benefit.
  • Resize on upload, not on request. On-the-fly transformation is convenient and turns every cold cache entry into a slow response.
  • Strip metadata. Camera EXIF and colour profiles can be a surprising share of a small image.
  • Respect Save-Data. When the header is present, serving a smaller variant is a real courtesy on a metered connection — and it is one line in a delivery rule.