Tiếng Việt

Home

next/image: optimizing images, measured before and after

Images are usually the heaviest thing on a page. This page builds a real app on next@16.4.4, places the same 3000×2000 file (402 KB) four ways, and measures each with HTTP: the bytes downloaded, the format the browser receives, the cache header, and what the HTML promises before any image arrives. No number below is written from memory.

The code on this page is an app that runs: 22/22 checks passed on 2026-11-02. Versions run: next@16.4.4, react@19.3.0, react-dom@19.3.0, Node.js v26.0.0.

Four ways to place one photo

The sample app has a photo.jpg, 3000×2000, 402 KB. The page places it four times: a plain <img> pointing at the built file, a next/image at the top of the page with preload, a next/image below the fold, and an image from public/ with quality={65}.

The photo is one we generated (hills and an evening sun), so no third party's licence is involved. It is fairly smooth, so it compresses better than a detailed photograph — your numbers will differ. The measuring method is the same, and that is the part worth taking home.

app/page.tsx
import Image from "next/image";
import photo from "./photo.jpg";

const ALT = "Ảnh mẫu: đồi và bầu trời lúc chiều";

export default function Home() {
  return (
    <main>
      <h1>next/image: đo trước và sau</h1>
      <p>Cùng một tệp JPEG 3000×2000 (1,2 MB), bốn cách đặt lên trang.</p>

      <h2>1. Thẻ img thường</h2>
      <img src={photo.src} alt={ALT} width={3000} height={2000} />

      <h2>2. next/image, ảnh đầu trang</h2>
      <Image src={photo} alt={ALT} sizes="(max-width: 640px) 100vw, 640px" preload />

      <h2>3. next/image, ảnh dưới màn hình đầu</h2>
      <Image src={photo} alt={ALT} width={480} height={320} sizes="480px" placeholder="blur" />

      <h2>4. Ảnh trong public/ và một quality không có trong danh sách</h2>
      <Image src="/banner.jpg" alt="Băng rôn" width={900} height={600} sizes="900px" quality={65} />
    </main>
  );
}
next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  images: {
    // AVIF first: a photo is smaller as AVIF than as WebP, at the cost of a slower first encode.
    formats: ["image/avif", "image/webp"],
    // Next.js 16 accepts only quality 75 unless you list others. 65 is deliberately missing:
    // asking for it is coerced to 75 without a warning.
    qualities: [50, 75, 90],
  },
};

export default nextConfig;

What the image optimizer does

For a statically imported image, next/image does not hand the browser the original file. It writes a URL /_next/image?url=…&w=640&q=75; the server reads the parameters, resizes to w, encodes at q, and returns the format the request's Accept header allows. The sample app sets formats: ['image/avif', 'image/webp'] (the default is image/webp only).

Measured on that one 402 KB file: 640 wide gives 11 KB as AVIF or 14 KB as WebP; 1920 wide gives 48 KB as AVIF; 384 gives 5 KB and 1080 gives 23 KB. That is where most of the saving comes from: not the format, but not sending 3000 pixels to a 640-pixel slot.

One detail is easy to miss: the img still carries a src pointing at the **largest** width (here w=3840). A browser that does not understand srcset downloads exactly that; every current browser picks from srcset and sizes instead.

q=75 is the default. Dropping to 50 at 640 wide measured 5 KB, but AVIF's and WebP's quality numbers are not the same scale: at q=75, AVIF came out at 11 KB and WebP at 14 KB — do not read visual quality out of those two numbers, look at the picture.

srcset and sizes: which resolution the browser picks

next/image builds a srcset from deviceSizes (default 640, 750, 828, 1080, 1200, 1920, 2048, 3840). The top-of-page image's HTML carried 8 widths. The browser uses sizes to know how many CSS pixels the image occupies, multiplies by the device pixel ratio, and takes the smallest candidate that is enough.

So a wrong sizes costs real money: sizes="100vw" on an image that is only 640 pixels wide makes a phone download the 1080 or 1920 file instead of 640. In the sample, the hero declares sizes="(max-width: 640px) 100vw, 640px", and a fixed 480×320 image declares sizes="480px"; on a 2x screen a 480-pixel slot needs 960, so the browser takes 1080.

Rule of thumb: sizes must describe the width the image really takes at each breakpoint — usually the same expression as your CSS. An image in a three-column grid is 33vw, not 100vw.

preload replaced priority, and fetchpriority is yours to set

The Next.js 16 docs say priority has been deprecated in favour of preload, to make the behaviour clear. The sample uses preload; the result is a <link rel="preload" as="image"> in the head carrying imageSrcSet with all 8 widths and imageSizes — the browser starts fetching before it reaches the img tag.

One measured surprise: that image has **no** fetchpriority attribute on the img. preload only adds the link and keeps the image eager (no loading="lazy"). To ask for the highest priority, pass fetchPriority="high" yourself — it is forwarded to the img element.

Use preload for one image only: the LCP image. The docs also warn the other way — when several images could be the LCP depending on the viewport, use loading="eager" or fetchPriority="high" instead of preloading all of them, because several preloads at once leave nothing prioritised.

No layout shift, and a placeholder while you wait

The below-the-fold image rendered with loading="lazy", width="480", height="320" and a background-image that is a data URI. The known height means the page does not jump when the image lands; loading late means it does not compete with the content above.

That data URI is a blur placeholder. For a statically imported image Next.js generates it at build time: a small SVG wrapping a few-hundred-byte JPEG, scaled up and blurred. You do not need to make a blurDataURL by hand; it comes with a static import, or you pass blurDataURL when src is a string path.

Do not put placeholder="blur" on a large hero image: the image already loads immediately, so the blur only adds a frame of delay.

Two cache numbers, and why

Two quite different headers came back from the same mechanism. A statically imported image (content-hashed filename) returns cache-control: public, max-age=315360000, immutable — ten years, because when the content changes the name changes. An image in public/ returns cache-control: public, max-age=14400, must-revalidate — four hours, from images.minimumCacheTTL, because the same URL may be a different picture tomorrow.

A second request for the same URL returned x-nextjs-cache: HIT: Next.js had stored the encoded image on disk and served it without re-encoding.

If a CDN sits in front of Next.js, it must forward the Accept header, or every visitor receives the same format — usually WebP, losing AVIF's advantage. The Next.js docs say so explicitly; it is a common misconfiguration when you run your own VPS.

A quality outside the list: the component clamps, the URL returns 400

Declaring qualities: [50, 75, 90] in next.config limits which values may be encoded. The same value 65 got two different answers: <Image quality={65}> produced a q=75 URL (the component clamps to the nearest allowed value before building the URL, and a dev build logs a warning), while calling /_next/image?…&q=65 directly returned 400 with "q" parameter (quality) of 65 is not allowed.

Which means: do not hand-write optimizer URLs, and do not assume your quality survives. A shorter qualities list also means fewer variants your CDN has to keep.

Safety: the image optimizer is not an open proxy

The optimizer fetches images on a user's behalf, which makes it an SSRF surface if left open. Measured: an external URL that images.remotePatterns does not list returned 400 with "url" parameter is not allowed; an .svg file returned 400 with the log line dangerouslyAllowSVG is disabled.

Do not enable dangerouslyAllowSVG for convenience. SVG is a document that can carry script and external references; if you must serve user-uploaded SVG, serve it as a static file from another host, with Content-Security-Policy and Content-Disposition.

Keep remotePatterns as narrow as you can: exact host, exact protocol, exact path. The optimizer also does not forward authentication headers when fetching (the docs say so), so an image behind a login needs unoptimized.

A checklist for a page with images

1. Every image has width/height (or fill inside a sized box) so nothing shifts. 2. sizes matches the real width on the page at each breakpoint. 3. Fixed UI images are static imports, so they get a hashed name and a ten-year cache. 4. preload on exactly one LCP image, and consider fetchPriority="high". 5. formats: ['image/avif', 'image/webp'], then check that the CDN forwards Accept. 6. A short qualities list, and no quality outside it. 7. Narrow remotePatterns, dangerouslyAllowSVG off. 8. Re-measure in bytes: DevTools, or curl -H 'Accept: image/avif' as this page's script does.

Do not measure by feel. In this run the same image produced 11 KB and 48 KB from width alone, and 5 KB at a lower quality — three variables, three answers, all of them right.

Checked

  • the page prints a raw <img> and a next/image: raw + optimized
  • a plain <img>: the original file, unchanged: 402 KB image/jpeg
  • next/image at 640w, browser accepts AVIF: image/avif
  • next/image at 640w, browser accepts WebP only: image/webp
  • AVIF and WebP at 640w, from 402 KB: 11 KB AVIF vs 14 KB WebP — AVIF smaller at the same quality number
  • quality numbers are not comparable between codecs: AVIF q=50 against AVIF q=75 and WebP q=75: 5 KB AVIF q50, 11 KB AVIF q75, 14 KB WebP q75
  • w= in the URL controls the encoded width: 5 KB < 23 KB < 402 KB
  • cache-control for a static import (the file is hashed): public, max-age=315360000, immutable
  • cache-control for a file in public/ (same URL, different bytes tomorrow): 200 public, max-age=14400, must-revalidate
  • the second request for the same URL: HIT: public, max-age=14400, must-revalidate
  • q=65 in the URL when images.qualities is [50, 75, 90]: 400: "q" parameter (quality) of 65 is not allowed
  • quality={65} on a <Image> (the component clamps before it builds the URL): q=75
  • a remote host that images.remotePatterns does not allow: 400: "url" parameter is not allowed
  • an SVG through the optimizer, dangerouslyAllowSVG unset: 400: "url" parameter is valid but image type
  • preload (the 16 name for priority): a link in the head for the hero image: present, names the photo
  • the hero img: 8 widths in srcSet, sizes, no loading (eager)
  • the src the hero img carries, for a browser without srcset: w=3840
  • priority/preload no longer sets fetchpriority on the img (measured, 16.4.4): absent — pass fetchPriority="high" yourself
  • the image below the fold: loading=lazy, dimensions, blur data URI
  • the hero image at 640 CSS px, 1x screen: 11 KB at 640w, 97% smaller than the file
  • the same slot on a 2x screen, where the browser takes 1920w: 48 KB at 1920w, 88% smaller
  • what served these images: sharp 0.35.5; next 16.4.4

Re-run the checks: bun scripts/guides/verify-next-image.ts

Related

Sources

Our summary and wording; the code is rewritten from the examples in the Next.js docs. Where they differ, the Next.js docs win.