Overview

Unoptimized images are a common cause of slow Largest Contentful Paint. The fix has four parts: resize to the size actually displayed, encode in WebP and AVIF, declare width and height on every <img>, and lazy-load all images except the LCP image. This guide does the encoding with Sharp (libvips) in a build script, so every shipped image gets the same treatment. Metric context is in core-web-vitals.

Prerequisites

  • Node 20.9 or newer (Sharp’s requirement).
  • Source images at 2x the largest display size or more. Never upscale.
  • Masters in src/images/, generated files in public/images/ (gitignore the output if the build regenerates it).

Steps

1. Install Sharp

npm install sharp      # or: pnpm add sharp

Sharp ships prebuilt binaries through optional dependencies, so install with a package manager that installs optional dependencies. To install for a different platform than the host, use npm install --os=linux --cpu=x64 sharp.

2. Generate widths and formats

// scripts/optimize-images.mjs
import sharp from "sharp";
import { mkdir, readdir } from "node:fs/promises";
import { basename, extname, join } from "node:path";
 
const IN = "src/images", OUT = "public/images", WIDTHS = [400, 800, 1200];
await mkdir(OUT, { recursive: true });
for (const file of (await readdir(IN)).filter((f) => /\.(jpe?g|png)$/i.test(f))) {
  const name = basename(file, extname(file));
  for (const width of WIDTHS) {
    const img = sharp(join(IN, file)).rotate().resize({ width, withoutEnlargement: true });
    await img.clone().webp({ quality: 80 }).toFile(join(OUT, `${name}-${width}.webp`));
    await img.clone().avif({ quality: 50 }).toFile(join(OUT, `${name}-${width}.avif`));
  }
}

Defaults worth knowing: WebP quality is 80 and AVIF quality is 50 unless set, and the default resize kernel is lanczos3. Sharp strips all metadata by default, including EXIF orientation, so call .rotate() with no arguments first to bake the orientation in. Use .keepMetadata() only when you need to retain EXIF or ICC data. Pick sizes from the largest CSS width the image fills, doubled for 2x screens.

Run it from a build step: "build": "node scripts/optimize-images.mjs && npx quartz build". In a Vite project, vite-imagetools does the same resize and format conversion inside the module graph instead of a separate script.

3. Serve with picture

<picture>
  <source type="image/avif" srcset="/images/hero-400.avif 400w, /images/hero-800.avif 800w, /images/hero-1200.avif 1200w" sizes="(min-width: 800px) 800px, 100vw">
  <source type="image/webp" srcset="/images/hero-400.webp 400w, /images/hero-800.webp 800w, /images/hero-1200.webp 1200w" sizes="(min-width: 800px) 800px, 100vw">
  <img src="/images/hero-800.jpg" alt="Team at desks" width="800" height="533" fetchpriority="high">
</picture>

Browsers pick the first source they support, so list AVIF first. Google’s WebP study measured lossy WebP 25 to 34 percent smaller than JPEG at equal SSIM; measure AVIF against WebP on your own images. Check caniuse.com/avif before dropping the JPEG fallback.

4. Set dimensions and loading

Set width and height to the file’s intrinsic pixel size so the browser reserves space and avoids layout shift, and control display size in CSS (max-width: 100%; height: auto). Give the LCP image fetchpriority="high" and no loading attribute. Every other image gets loading="lazy".

5. Cache the output in CI

Re-encoding on every run is slow. Cache public/images keyed on the source hash; see set-up-github-actions-cache.

- uses: actions/cache@v6
  with:
    path: public/images
    key: images-${{ hashFiles('src/images/**', 'scripts/optimize-images.mjs') }}

Verify it worked

ls -lh src/images/hero.jpg public/images/hero-800.webp public/images/hero-800.avif
npx lighthouse https://example.com --only-categories=performance --output=json \
  | jq '.audits["largest-contentful-paint"].displayValue, .audits["cumulative-layout-shift"].displayValue'

The WebP and AVIF files should be smaller than the JPEG, and the LCP image must not appear with loading="lazy".

Common errors

  • loading="lazy" on the LCP image delays it. Remove it from that one image.
  • AVIF encoding is slow. Encode AVIF only in CI or for the widest breakpoint, and cache the output.
  • Layout still shifts with width and height set. A CSS rule overrides the height; add img { height: auto; }.
  • Images rotated wrongly. Metadata stripping removed the EXIF orientation; call .rotate() before resize.
  • Output directory missing. Sharp does not create it; use mkdir with { recursive: true }.
  • .webp served with the wrong type. Check curl -sI .../hero.webp | grep -i content-type shows image/webp.

FAQ

Should I serve AVIF or WebP?

Serve both in a <picture> element with AVIF listed first, since browsers pick the first source they support; measure AVIF against WebP on your own images.

Should I lazy-load every image?

No. Give the LCP image fetchpriority="high" and no loading attribute, and lazy-load every other image.

Why are my images rotated wrong after Sharp processes them?

Sharp strips all metadata by default, including EXIF orientation. Call .rotate() with no arguments before resize to bake the orientation in.

What quality does Sharp use for WebP and AVIF by default?

WebP quality is 80 and AVIF quality is 50 unless set.

Why does layout still shift when width and height are set?

A CSS rule overrides the height. Add img { height: auto; } and control display size in CSS.