Image optimization for the web: formats, sizes and lazy loading

Image optimization means sending each screen the smallest image file that still looks right. That comes down to the right format, the width the screen draws, a compression budget, and loading at the right moment. We ran one 9.8 MB camera photo through each step and watched what Chromium downloaded. Part of the image API.

Image optimization for the web: formats, sizes and lazy loading

By Updated 23 min readEngineering

Share

Image optimization in 7 steps

Here is the whole job, in the order a page needs it. Each step has a section below with the measurement behind it.

  1. Pick the format: AVIF, with WebP or JPG behind it

    A picture element lists AVIF first, and each browser takes the first format it can read.

  2. Set width and height on every img

    The browser reserves the space before the file arrives, so nothing on the page jumps.

  3. Serve each screen its own width

    List several widths in srcset and the width of the slot in sizes.

  4. Compress to a quality or a byte cap

    Auto quality suits most photos; a Max file size suits a page with a budget.

  5. Lazy load what starts below the first screen

    Add loading="lazy" there, with a colour or a tiny blurred copy as the placeholder.

  6. Load the LCP image first

    Give the largest image at the top fetchpriority="high", and never lazy load it.

  7. Cache it for a year

    Put a version in the file name and send Cache-Control: max-age=31536000, immutable.

The camera file is 557 times the size of the phone’s. On a 390-pixel screen at 2x, the larger files add bytes and no detail you can see.

Choose the format: AVIF first, with a fallback

For photos on a site you run, serve AVIF and keep WebP or JPG behind it. Take the 800-pixel file our phone downloaded. It was 17.6 KB as AVIF, 22.4 KB as WebP and 35.2 KB as JPG, each at Auto.

The picture element offers all three. The browser reads its sources in order and takes the first type it supports. The img inside is the fallback, and it carries the alt, the width and the height.

<picture>
  <source type="image/avif" srcset="photo-800.avif">
  <source type="image/webp" srcset="photo-800.webp">
  <img src="photo-800.jpg" alt="A boat on a lake at sunset"
       width="800" height="448">
</picture>
Each source names its type, so a browser skips the ones it cannot read.

Why AVIF wins on photos, and when PNG or lossless WebP is the better pick, is in best image format. To make the files, the WebP converter turns a JPG or a PNG into WebP, and the AVIF converter turns it into AVIF.

More like this by mail: measured results and what shipped, a few times a month.

HTML image size: set width and height on every image

Give every img its width and height attributes. The browser then knows the shape before the file arrives and holds the space open. Leave them out, and the text below jumps when the picture lands.

We measured the jump. Our test page put a block of text, then an image that arrived 600 ms late, then more text, on a 390-pixel phone. Without width and height, Chromium 149 scored a cumulative layout shift (CLS) of 0.215. With them, it scored 0.

One image took the page past web.dev’s good line of 0.1, into “needs improvement”. The fix is two attributes. Even so, the HTTP Archive’s 2025 Web Almanac found at least one unsized image on 62% of mobile pages.

<img src="photo-800.jpg" alt="A boat on a lake at sunset"
     width="800" height="448">

<style>
  img { max-width: 100%; height: auto; }
</style>
The attributes give the shape. The CSS lets the image shrink with its column and keep that shape.

How to change image size in HTML

Change the size an image shows at with CSS. Leave the width and height attributes at the file’s own pixels. MDN’s img reference describes them as the image’s intrinsic size, in whole numbers with no unit. CSS then sets what the reader sees.

  • width: 100%; height: auto fills the column and keeps the shape.
  • max-width: 400px caps the image on a wide screen.
  • aspect-ratio: 1 / 1; object-fit: cover crops it to a square without stretching it.

Shrinking an image with CSS does not shrink the download. The browser still fetches every pixel of the file. To send fewer bytes, send a smaller file, which is what srcset does.

Responsive images: srcset and sizes

A srcset lists the same picture at several widths, and sizes tells the browser how wide the slot will be. The browser multiplies the slot by the screen’s pixel density, then picks a file. Our list has five widths, from 400 to 1920.

<img src="photo-800.jpg"
     srcset="photo-400.jpg 400w, photo-800.jpg 800w,
             photo-1200.jpg 1200w, photo-1600.jpg 1600w, photo-1920.jpg 1920w"
     sizes="(min-width: 720px) 720px, 100vw"
     width="1920" height="1076"
     alt="A boat on a lake at sunset">
Each w is the file's real width. sizes says the image fills the screen, up to a 720-pixel column. With srcset, width and height can be any one file's; with the CSS above, the browser only needs the ratio.

Which file each screen downloaded

We opened that markup in Chromium 149 on five screens and logged the one request each made. Every screen took the smallest file at least as wide as it needed, and no file was more than 11% wider than its slot.

The file Chromium 149 downloaded from one srcset on five screens, and that file's size from our engine at Auto. The slot is the screen's width up to the 720-pixel column.
ScreenSlot × densityFile takenAs AVIFAs JPG
Phone, 390 pixels at 1x390 × 1 = 390 px400 wide6.3 KB12.2 KB
Phone, 390 pixels at 2x390 × 2 = 780 px800 wide17.6 KB35.2 KB
Phone, 390 pixels at 3x390 × 3 = 1,170 px1200 wide32.9 KB67.0 KB
Laptop, 1440 pixels at 1x720 × 1 = 720 px800 wide17.6 KB35.2 KB
Laptop, 1440 pixels at 2x720 × 2 = 1,440 px1600 wide51.2 KB104.9 KB
The file Chromium 149 downloaded from one srcset on five screens, and that file's size from our engine at Auto. The slot is the screen's width up to the 720-pixel column.
The bow of the boat as a 390-pixel phone at 2x paints it, enlarged three times, from three files: the 400-wide AVIF (6.3 KB) stretched to 780 pixels is soft; the 800-wide AVIF (17.6 KB) is sharp; the 1920-wide AVIF (69.3 KB) shrunk to 780 pixels looks the same as the 800.
Each file drawn at the 780 device pixels the phone paints, resized by libvips in place of the browser, then a 120 × 80 piece enlarged three times.

Without the srcset, every screen gets whatever src names. Serve the 1920 file to everyone, and the phone pays 69.3 KB. It shows no more detail than the 17.6 KB file: 3.9 times the bytes for nothing. A phone at 3x took the 1200 file and a laptop at 2x the 1600, so keep a width near each of those.

The camera photo at each srcset width, our engine at Auto. The camera file itself is 9.8 MB.
WidthHeightJPGWebPAVIF
400 px224 px12.2 KB8.6 KB6.3 KB
800 px448 px35.2 KB22.4 KB17.6 KB
1,200 px673 px67.0 KB42.8 KB32.9 KB
1,600 px897 px104.9 KB63.6 KB51.2 KB
1,920 px1076 px140.3 KB85.5 KB69.3 KB
The camera photo at each srcset width, our engine at Auto. The camera file itself is 9.8 MB.

For a lazy-loaded image, sizes="auto" lets the browser measure the slot itself. MDN says it only works with loading="lazy". caniuse counts 74.1% of users whose browser supports it, so keep a written sizes list for the rest.

Compress to a budget: quality or a byte cap

Compression sets how many bytes each pixel costs. You can pick a quality number, or name a byte cap and let the encoder find the quality that fits.

Quality: where the curve bends

Our engine’s 1200-wide JPEG fell from 258.3 KB at quality 95 to 67.0 KB at Auto, 74% smaller. Its PSNR, a decibel score of how close the pixels stay to the source, fell from 44.3 to 37.2 dB. Going lower buys less. From Auto to quality 40 saved 37.2 KB more and cost 3.0 dB. Each decibel given up below Auto saved under half the bytes of one above it.

A patch of sunset sky from the 1200-wide JPEG at four qualities, enlarged four times: quality 90 is 174.0 KB at 42.0 dB, Auto is 67.0 KB at 37.2 dB, quality 60 is 42.7 KB at 35.5 dB, quality 40 is 29.8 KB at 34.2 dB. Quality 90 and Auto keep a smooth gradient; at 60 and 40 it breaks into square blocks.
A 128 × 80 patch of sky from the 1200-wide JPEG, enlarged four times. The sizes and the PSNR are for the whole image.
The camera photo at 1200 × 673 as a JPEG from our engine, by quality. PSNR is against a lossless copy at the same width; higher is closer.
QualitySizePSNR
95258.3 KB44.3 dB
90174.0 KB42.0 dB
Auto67.0 KB37.2 dB
6042.7 KB35.5 dB
4029.8 KB34.2 dB
The camera photo at 1200 × 673 as a JPEG from our engine, by quality. PSNR is against a lossless copy at the same width; higher is closer.

A byte cap: Max file size

A byte cap fits a page budget. We gave the photo a 50 KB Max file size at 1,600 pixels wide. Our engine searched for the quality that fits. The JPG landed at 49.8 KB, the WebP at 49.4 KB and the AVIF at 48.0 KB, all still 1,600 wide.

At the same bytes, the AVIF kept the most detail. That is the case for a cap plus a modern format. The budget holds, and the format decides how good the picture looks inside it. The image compressor takes the same cap in its Advanced settings.

Lazy loading images, and what to show while they load

Add loading="lazy" to images that start below the first screen. The browser then waits to fetch each one until the reader scrolls near it. Support is close to universal: caniuse counts 95.82% of users.

We built a test page of 12 photos, each under a block of text. Then we opened it on a 390 × 844 phone at 2x. Without the attribute, Chromium fetched all 12 as the page loaded. With it on every image but the first, it fetched 4 and left 8 for later.

The last image fetched started 915 pixels below the screen, and the first one held back started 1,502 pixels below. That matches web.dev’s account of Chrome. It fetches a lazy image once it comes within 1,250 pixels of the screen on a fast connection, or 2,500 on a slow one. At 17.6 KB a photo, the 8 deferred images were 140.8 KB a reader pays for only by scrolling.

<picture>
  <source type="image/avif" srcset="photo-800.avif">
  <img src="photo-800.jpg" loading="lazy" decoding="async"
       width="800" height="448"
       alt="A boat on a lake at sunset">
</picture>
decoding="async" lets the browser decode the picture after it paints the rest of the page, per MDN. The default, auto, leaves that choice to the browser.

Lazy load placeholder: a colour or a tiny blurred image

A placeholder fills the reserved space until the photo arrives. The cheapest is a background colour. The next step up is a tiny copy of the photo, stretched and blurred.

Three stages of one image slot: a flat #48454b grey-purple, the photo's average colour; the 24 × 13 pixel WebP of 214 bytes stretched and blurred into soft blue, orange and teal shapes; and the 800-pixel AVIF of 17.6 KB, a boat on a lake at sunset.
What the slot shows, in order: the colour, the blurred placeholder, the photo. All three keep the same shape, because width and height are set.

Our tiny copy is 24 × 13 pixels, a WebP of 214 bytes. As a data URI it is 311 characters, small enough to sit inline so it paints with the page. The colour, #48454b, is the mean of the photo’s pixels, which sharp reports in one call.

<picture>
  <source type="image/avif" srcset="photo-800.avif">
  <img src="photo-800.jpg" loading="lazy"
       width="800" height="448" alt="A boat on a lake at sunset"
       style="background: #48454b url('data:image/webp;base64,UklGR…')
              center / cover no-repeat">
</picture>
The background shows until the photo paints over it. The full data URI is 311 characters; the browser stretches the 24-pixel image smoothly.

The LCP image: fetchpriority, and never lazy

The largest image in the first screen is usually the page’s Largest Contentful Paint (LCP), the Core Web Vital for loading. web.dev’s LCP guide counts 2.5 seconds or less as good and over 4 seconds as poor, at the 75th percentile of page loads.

Images are the LCP on 76.0% of mobile pages and 85.3% of desktop pages, per the Web Almanac 2025 performance chapter. The same chapter found 16% to 17% of pages lazy-loading their LCP image, and fetchpriority="high" on 17% of mobile pages with an image LCP.

Never put loading="lazy" on the LCP image: web.dev’s guide to optimizing LCP says that always delays it. Give it fetchpriority="high" so the browser fetches it ahead of other images. caniuse counts 93.74% of users whose browser reads the attribute; the rest ignore it.

<picture>
  <source type="image/avif" srcset="photo-1600.avif">
  <img src="photo-1600.jpg" fetchpriority="high"
       width="1600" height="897"
       alt="A boat on a lake at sunset">
</picture>
The hero: no loading attribute, so it loads at once, and a high fetch priority so it goes first.

Keep the LCP image in the HTML as an img. When script inserts it, or a CSS background holds it, the browser’s preload scanner cannot find it early. The same guide counts that as delay.

A CDN for images: resize at the edge, cache for a year

A CDN for images keeps copies of your files on servers near your readers. An image CDN also resizes and converts on request, usually from parameters in the URL, and caches each result.

When one URL can return AVIF or WebP, the cache must key on the browser’s Accept header. Otherwise a browser can get a format it cannot read. The Next.js docs make a related point: a proxy or CDN in front of its image optimizer must forward that header.

The cache header matters with or without a CDN. MDN recommends max-age=31536000 with immutable for files whose names change when their contents do, such as photo.3f9a2c.avif. The max-age lets a browser reuse the file for a year without asking again; immutable, MDN adds, also skips the recheck when the reader reloads the page.

Cache-Control: max-age=31536000, immutable
One year, in seconds. Use it only on versioned names, because a file under the same name will not be fetched again.

Image to base64: when a data URI costs more than it saves

Base64 turns an image’s bytes into text, so the image can sit inside HTML or CSS as a data URI. Every 3 bytes become 4 characters. Our 800-pixel AVIF went from 17,647 bytes to a data URI of 23,555 characters, 33% more.

Then the surprise: HTTP compression wins almost all of it back. Gzipped, the data URI came to 17,722 bytes, only 76 more than the gzipped file. With Brotli the gap was 67 bytes. Base64 uses 64 symbols, and the compressor packs each one back into about 6 bits.

What a data URI really costs

So the bytes on the wire are not the problem. The costs are where the image lives:

  • No cache of its own. A file is fetched once and reused on every page. A data URI comes again inside every page or stylesheet that holds it.
  • No lazy loading, no priority. The image arrives inside the document, so its bytes come before the content after it, even if the reader never scrolls that far.
  • A heavier document. Once decompressed, the HTML or CSS carries all 23,555 characters, and the browser parses every one.

Inline only what is tiny and needed at once, like our 214-byte placeholder. Serve everything else as a file.

How to turn an image into base64

import { readFile } from 'node:fs/promises';

const b64 = (await readFile('photo.webp')).toString('base64');
const uri = `data:image/webp;base64,${b64}`;
Node reads the file and writes it as base64; the prefix names the type, so the browser knows how to decode it.

Framework helpers

Most frameworks wrap these steps in one component. Read its defaults before you trust it: a default format or quality decides your bytes on every page.

Next.js: the next/image component

Next.js’s Image component writes the srcset, lazy loads by default and serves resized copies from its own endpoint. It needs src, alt, and width and height, or fill. The Next.js 16 docs set quality to 75 by default and require a qualities list in the config. The formats setting defaults to WebP alone.

// next.config.js
module.exports = {
  images: {
    formats: ['image/avif', 'image/webp'],
    qualities: [75],
  },
};
AVIF first, WebP for browsers without it. The docs note that each format is cached separately.
import Image from 'next/image';

export function Hero() {
  return (
    <Image
      src="/photo.jpg"
      alt="A boat on a lake at sunset"
      width={1920}
      height={1076}
      sizes="(min-width: 720px) 720px, 100vw"
      fetchPriority="high"
    />
  );
}
The hero. Next.js 16 deprecates priority; its docs point to loading="eager" or fetchPriority="high" instead in most cases.

Without sizes, the docs say the browser assumes the image is as wide as the viewport, and Next.js writes a short 1x and 2x srcset. Give sizes whenever the image is narrower than the screen.

Automate it: the image API and the MCP server

Five widths in three formats is fifteen exports for every photo. Our image compression API makes them from one upload: send the file once, then ask for every width in one batch.

const api = 'https://api.blinkimg.com/v1';
const auth = { Authorization: `Bearer ${process.env.BLINKIMG_KEY}` };
const json = { ...auth, 'Content-Type': 'application/json' };

// One batch: the same upload at every width, AVIF at Auto
const widths = [400, 800, 1200, 1600, 1920];
let batch = await fetch(`${api}/batches`, {
  method: 'POST',
  headers: json,
  body: JSON.stringify({
    jobs: widths.map((width) => ({
      uploadId,
      options: { format: 'avif', resize: { width } },
    })),
  }),
}).then((r) => r.json());

// Wait until no job is queued or processing
while (batch.counts.queued + batch.counts.processing > 0) {
  await new Promise((r) => setTimeout(r, 500));
  batch = await fetch(`${api}/batches/${batch.id}`, { headers: auth })
    .then((r) => r.json());
}

// A signed link for each file
for (const job of batch.jobs) {
  const { url } = await fetch(`${api}/jobs/${job.id}/result`, { headers: auth })
    .then((r) => r.json());
  console.log(job.result.width, url);
}
Node 18 or later. uploadId comes from the upload step, the first call on the API page.

Large originals are their own problem. How the engine resizes a 400-megapixel JPEG on a small server is in input image exceeds pixel limit.

From an AI coding agent

In Claude, Cursor or another AI coding agent, the image compression MCP server does the same from a prompt. Connect it, then ask:

Convert every PNG in ./public to AVIF under 100 KB
The agent uploads each file, converts it, and saves each result with the download line the server returns.

Measure a page with the website image analyzer

Check a live page before you change it and again after. Our website image analyzer loads a public URL in Chrome at 1366 × 768, scrolls one screen at a time so lazy images load, and lists up to 60 images. For each one it records:

  • Its natural size and the size it is shown at, so an oversized file stands out.
  • Whether it sits in the first screen, and its loading attribute.
  • Whether it has a srcset or a picture source, and whether it has an alt.
  • What our engine saves on it, compressed during the same scan.

The next step is one page: run it through the analyzer, fix the largest image first, and run it again.

The guides

Questions

What is image optimization?
Image optimization is sending each screen the smallest image file that still looks right: a modern format, the width the screen draws, sensible compression, and loading at the right time. In our test, a 390-pixel phone at 2x needed the 800-pixel AVIF of 17.6 KB, where the camera file was 9.8 MB.
How could you optimize an image?
Seven steps cover a page: pick AVIF with a WebP or JPG fallback, set width and height, serve each screen its width with srcset, compress to a quality or a Max file size, lazy load below the first screen, give the LCP image fetchpriority="high", and cache versioned files for a year.
What is the best photo optimizer software?
The best one fits where your images live. For a few files, use a web tool that lets you set a quality or a byte cap and shows the result. For a whole site, use the build step or an image CDN that resizes on request. For automation, use an API. Test it on your own photos first.
Is image optimizer free?
Many are. Blinkimg's web compressor is free, no signup. Every account also gets 500 free API credits a month: 1 to compress an image, 2 to convert one. Pro and Pro Max raise the limits. Image CDNs usually bill by images served or bandwidth, so read their pricing before you route a site through one.
What size should images be for a website?
As wide as the slot they fill, times the screen's pixel density. A 390-pixel phone at 2x needs about 780 pixels; a 720-pixel column on a laptop at 2x needs 1,440. List several widths in srcset, such as 400, 800, 1200, 1600 and 1920, and let the browser choose.
How do I change the size of an image in HTML?
Keep the width and height attributes at the image file’s own pixels, then size it with CSS: width: 100% and height: auto make it fill its column and keep its shape. CSS changes how large it looks, not what downloads, so serve a smaller file through srcset when it shows small.
Should every image have width and height attributes?
Yes. They let the browser reserve the image’s space before the file arrives, so the page does not jump. In our phone test, one late image without them scored a layout shift of 0.215, past web.dev’s good limit of 0.1. With them, it scored 0. CSS still controls the size it shows at.
Should I lazy load every image?
No. Lazy load the images below the first screen, and never the largest image at the top, which is usually the Largest Contentful Paint. web.dev says lazy loading the LCP image always delays it. In our test, loading="lazy" cut the images fetched with the page from 12 to 4 of 12.
What is the best placeholder for a lazy-loaded image?
A background colour costs nothing and stops the blank flash. A tiny copy of the image, blurred, shows its shapes and colours: ours was 24 × 13 pixels and 214 bytes as a WebP, small enough to inline as a data URI. Either way, set width and height so the space is reserved.
Does base64 make images load faster?
Rarely. A data URI saves one request, but the image can no longer be cached, lazy loaded or prioritised on its own. Base64 adds a third to the bytes, yet gzip wins nearly all of it back: our AVIF of 17,647 bytes gzipped to 17,722 bytes as a data URI. Inline only tiny images.
How do I convert base64 to an image?
Drop the data URI's prefix, such as data:image/webp;base64, and decode the rest back to bytes. In Node, Buffer.from(text, 'base64') gives the bytes to write to a file. In a browser, fetch() on the whole data URI answers a response whose blob() is the image. Save it with the extension the prefix names.
Do I need a CDN for images?
Not to start. A year-long cache header on versioned file names helps every site, with or without one. A CDN helps most when readers are far from your server, or when you want resizing and AVIF conversion on request. If one URL can return several formats, the cache must vary on the Accept header.
Does Next.js serve AVIF images?
Only if you ask. The next/image component's formats setting defaults to WebP alone. Set it to ['image/avif', 'image/webp'] and Next.js sends AVIF to browsers that accept it, with WebP as the fallback. Its docs note that each format is cached separately, so the cache holds both versions of every image.
What is a good LCP time?
2.5 seconds or less at the 75th percentile of page loads, per web.dev; over 4 seconds is poor. The 2025 Web Almanac found an image as the LCP element on 76.0% of mobile pages, so the hero image's size, format and fetch priority are the first things to check.
Does image optimization help SEO?
Indirectly. Google’s Search docs say good Core Web Vitals align with what its core ranking systems seek to reward. Two of the three vitals often hinge on images: Largest Contentful Paint on how fast the main image loads, and Cumulative Layout Shift on whether its space was reserved before it arrived.
  • Tags
  • Image optimization
  • srcset
  • Lazy loading
  • Core Web Vitals
  • CDN
  • Base64
Share

More to read