DEV Community

Cover image for Generating srcset widths with sharp: bytes per width, measured
Sahadat Husain
Sahadat Husain

Posted on

Generating srcset widths with sharp: bytes per width, measured

A srcset with 6 to 7 widths between 320 and 2560 pixels covers phones to large retina screens, and sharp can write all of them from one script. On a camera photo, the AVIF files ran from 4.7 KB at 320w to 106.6 KB at 2560w. Bytes grow slower than pixels: each doubling of width cost 2.7 to 3.0 times the bytes, not 4.

Below is the script, the full table for AVIF, WebP and JPEG, and a rule for choosing the widths.

How do you generate srcset widths with sharp?

Read the source once, then clone() the pipeline for each width and format. Each clone runs its own resize and encode, so you never write a temporary file.

// srcset.mjs: node srcset.mjs photo.jpeg
import sharp from 'sharp';
import { writeFile } from 'node:fs/promises';

const SRC = process.argv[2];
const WIDTHS = [320, 640, 960, 1280, 1600, 1920, 2560];
const FORMATS = {
  avif: (s) => s.avif({ quality: 50, effort: 3 }),
  webp: (s) => s.webp({ quality: 75 }),
  jpg: (s) => s.jpeg({ quality: 75, mozjpeg: true }),
};

const input = sharp(SRC).rotate(); // read once; each clone() runs its own resize and encode
const rows = [];
for (const width of WIDTHS) {
  const row = { width };
  for (const [ext, encode] of Object.entries(FORMATS)) {
    const { data, info } = await encode(input.clone().resize({ width }))
      .toBuffer({ resolveWithObject: true });
    await writeFile(`out/photo-${width}.${ext}`, data);
    row.height = info.height;
    row[ext] = data.length;
  }
  rows.push(row);
}

for (const r of rows) {
  const mp = (r.width * r.height) / 1e6;
  console.log(`${String(r.width).padStart(4)} x ${String(r.height).padEnd(4)}`,
    ...['avif', 'webp', 'jpg'].map((f) => `${f} ${(r[f] / 1000).toFixed(1).padStart(6)} KB`),
    `avif ${(r.avif / 1000 / mp).toFixed(1)} KB/MP`);
}
for (const f of ['avif', 'webp', 'jpg']) {
  console.log(`\n${f}: ` + rows.map((r) => `photo-${r.width}.${f} ${r.width}w`).join(', '));
}
Enter fullscreen mode Exit fullscreen mode

rotate() with no argument applies the EXIF orientation before the resize, so a portrait phone photo does not come out sideways.

How many bytes does each srcset width cost?

I ran it on a 4964 × 2782 camera photo. It printed:

 320 x 179  avif    4.7 KB webp    6.3 KB jpg    8.2 KB avif 81.7 KB/MP
 640 x 358  avif   12.7 KB webp   16.9 KB jpg   23.0 KB avif 55.5 KB/MP
 960 x 538  avif   22.6 KB webp   28.9 KB jpg   42.6 KB avif 43.8 KB/MP
1280 x 717  avif   36.0 KB webp   46.4 KB jpg   66.6 KB avif 39.3 KB/MP
1600 x 897  avif   51.2 KB webp   63.6 KB jpg   94.1 KB avif 35.7 KB/MP
1920 x 1076 avif   69.3 KB webp   85.5 KB jpg  125.8 KB avif 33.5 KB/MP
2560 x 1435 avif  106.6 KB webp  131.2 KB jpg  196.7 KB avif 29.0 KB/MP

avif: photo-320.avif 320w, photo-640.avif 640w, photo-960.avif 960w, photo-1280.avif 1280w, photo-1600.avif 1600w, photo-1920.avif 1920w, photo-2560.avif 2560w
Enter fullscreen mode Exit fullscreen mode

Grouped bar charts of bytes per srcset width from 320w to 2560w for AVIF, WebP and JPEG

The last column is the useful one. AVIF spent 81.7 KB per megapixel at 320w and 29.0 KB per megapixel at 2560w. Small images carry more detail per pixel, and the fixed container overhead weighs more on a tiny file.

Why don't bytes grow with pixel count?

Doubling the width quadruples the pixels. The bytes did not follow:

From To Pixels AVIF bytes JPEG bytes
320w 640w 4.0x 2.7x 2.8x
640w 1280w 4.0x 2.8x 2.9x
1280w 2560w 4.0x 3.0x 3.0x

Downscaling averages neighbouring pixels, so a small version of a photo is denser in edges and texture per pixel. A bigger version has more smooth area for the encoder to spend almost nothing on.

The practical result: a big width costs less than you expect, and a missing width costs more. If a phone with a 400 CSS pixel slot at 2x density gets the 1280w file because you skipped 960w, it downloads 36.0 KB instead of 22.6 KB of AVIF.

Which widths should a srcset list?

The browser picks the first candidate at least as wide as the slot times the screen's density, according to MDN's responsive images guide. So the steps between widths are the waste you accept on the way up. Three rules from the table:

  • Space widths by bytes, not pixels. Steps of 320 pixels up to 1280w kept each AVIF jump between 8.0 and 13.4 KB. Above that, wider steps still cost 15 to 37 KB each.
  • Stop at the largest slot times 2. For a 1280 CSS pixel hero, 2560w is the top. A 3x screen at that size is rare, and 2560w already cost 106.6 KB in AVIF.
  • Serve AVIF first. At 1280w the AVIF was 36.0 KB against 66.6 KB for the JPEG, 46% smaller. Put it in a <picture> with WebP and JPEG fallbacks.

The markup the script's last lines feed:

<picture>
  <source type="image/avif"
    srcset="photo-320.avif 320w, photo-640.avif 640w, photo-960.avif 960w,
            photo-1280.avif 1280w, photo-1600.avif 1600w, photo-1920.avif 1920w,
            photo-2560.avif 2560w"
    sizes="(min-width: 1280px) 1280px, 100vw">
  <img src="photo-1280.jpg" alt="A boat on a lake at sunset"
    width="1280" height="717"
    srcset="photo-320.jpg 320w, photo-640.jpg 640w, photo-1280.jpg 1280w, photo-2560.jpg 2560w"
    sizes="(min-width: 1280px) 1280px, 100vw">
</picture>
Enter fullscreen mode Exit fullscreen mode

Note: The w value must be the file's real pixel width. If your source is smaller than the largest width, sharp enlarges it by default and you ship a bigger file with no new detail. Pass resize({ width, withoutEnlargement: true }) and drop the widths the source cannot fill.

How much storage does one photo need?

All 21 files together came to 1,238.9 KB: 303.1 KB of AVIF, 378.8 KB of WebP and 557.0 KB of JPEG. That is about an eighth of the 9.8 MB source. Storage is rarely the reason to cut a width; the decode and encode time in your build is.

For the whole picture of where image bytes go on a page, from formats to lazy loading, see the image optimization guide.

How I measured

  • Photo: apps/web/public/images/index/image-compressor-sample-camera-photo.jpeg, 4964 × 2782, 9.8 MB, our own camera photo.
  • sharp 0.35.4, libvips 8.18.6, Node 24.22.0. AVIF quality: 50, effort: 3; WebP quality: 75; JPEG quality: 75, mozjpeg: true. sharp's default Lanczos 3 resize kernel.
  • Bytes are the encoded buffers, one run (the encodes are deterministic). KB = 1,000 bytes.
  • Apple M1 Pro, macOS, 2026-10-17. The 1600w and 1920w AVIF and WebP sizes match the engine's own numbers for this photo.

What I did not test

One photo only. I did not test screenshots or graphics, or how real browsers choose among these files on real devices.


I build Blinkimg, an image compressor and converter with a developer API. I wrote this article with help from an AI assistant (Claude), which ran the code on my machine; every number in it comes from those runs.

Top comments (0)