A blurred placeholder only needs to be 16 pixels wide, and as WebP it costs about 100 bytes. With sharp, a 16 × 11 WebP of a 1920 × 1280 photo came out at 108 bytes, or 167 characters as a data URI you can inline in your HTML. At 32 px it is 248 bytes. Format matters more than width at this size: the same 16 px placeholder is 330 bytes as JPEG and 600 bytes as PNG.
How do you make one?
Resize to a tiny width, encode, and base64 the buffer:
import sharp from 'sharp'; const src = 'jpg-sample-photo-original.jpg'; console.log('width format bytes base64 chars data URI chars'); for (const w of [8, 16, 24, 32]) { for (const [fmt, opts] of [['webp', { quality: 50 }], ['jpeg', { quality: 50 }], ['png', { compressionLevel: 9 }], ['avif', { quality: 50 }]]) { const buf = await sharp(src).resize(w).toFormat(fmt, opts).toBuffer(); const b64 = buf.toString('base64'); const uri = `data:image/${fmt};base64,${b64}`; console.log(String(w).padEnd(6), fmt.padEnd(7), String(buf.length).padEnd(6), String(b64.length).padEnd(13), uri.length); } } resize(w) keeps the aspect ratio, so the four sizes came out at 8 × 5, 16 × 11, 24 × 16 and 32 × 21.
How many bytes does each size cost?
What the script printed:
width format bytes base64 chars data URI chars 8 webp 78 104 127 8 jpeg 292 392 415 8 png 211 284 306 8 avif 300 400 423 16 webp 108 144 167 16 jpeg 330 440 463 16 png 600 800 822 16 avif 344 460 483 24 webp 188 252 275 24 jpeg 375 500 523 24 png 1162 1552 1574 24 avif 360 480 503 32 webp 248 332 355 32 jpeg 457 612 635 32 png 1928 2572 2594 32 avif 446 596 619 The same numbers as a data URI, which is what lands in your HTML:
| Width | WebP | JPEG | AVIF | PNG |
|---|---|---|---|---|
| 8 px | 127 | 415 | 423 | 306 |
| 16 px | 167 | 463 | 483 | 822 |
| 24 px | 275 | 523 | 503 | 1,574 |
| 32 px | 355 | 635 | 619 | 2,594 |
Why does WebP win at this size?
Container overhead. A JPEG carries quantization and Huffman tables before the first pixel; at 8 px that header is much of the 292 bytes. AVIF wraps its data in an ISO box structure that costs a few hundred bytes too. A lossy WebP's RIFF header is 20 bytes or so, and the rest is picture.
PNG goes the other way. It is lossless, so every one of the 32 × 21 pixels is stored exactly, and the photo's noise makes that expensive: 1,928 bytes at 32 px.
Note: base64 grows bytes by a third, and the
data:image/webp;base64,prefix adds 23 characters. Compare placeholders by the data URI length if you inline them, not by the file size. A 600-byte PNG is 822 characters in your HTML.
How small can you go before it stops helping?
The placeholders, stretched to 360 × 240 with a blur the way a page shows them:
At 8 px you get the colour and a light patch in the middle, and nothing more. At 16 px the iguana's shape and the branch appear. At 24 and 32 px the shape sharpens a little, for 80 and 140 more bytes than 16 px. For most layouts 16 px is the point where you stop paying for detail nobody waits to see.
What about a single colour?
A flat colour is cheaper still: no bytes beyond your CSS. But pick it carefully. sharp's stats().dominant returned { r: 8, g: 8, b: 8 } for this photo, near black, because the dark background has the most pixels. The iguana is orange.
Two better choices from the same file:
const { channels } = await sharp(src).stats(); console.log(channels.map((c) => Math.round(c.mean))); // [ 67, 54, 26 ] const { data } = await sharp(src).resize(1, 1).raw().toBuffer({ resolveWithObject: true }); console.log([...data]); // [ 81, 59, 28 ] The channel mean and a 1 × 1 resize both give a dark olive brown, which matches the photo's overall tone. Use one of them as a background-color when a 167-character URI is still too much, for example in a long grid of cards.
How do you use it on the page?
Inline the URI as a background on the image box, and let the real image cover it:
<img src="iguana-1200.webp" width="1920" height="1280" loading="lazy" alt="An iguana on a branch" style="background: url(data:image/webp;base64,UklGRmQAAABXRUJQVlA4IFgAAADwAQCdASoQAAsAAsBMJaACdAEDHZBTWgAA/vkj8Bo1wNbZdUdcxmtM2/Tv4dDTBQ52v/GCf9yAu+CEHeT5yhIgSkX+3CJh5tSaDAboDe9Kb0zaqjfn+VAA) center / cover;"> That is the real 16 px placeholder from the run. The browser scales a tiny bitmap smoothly, which gives you most of the blur for free. Add filter: blur() on a wrapper if you want it softer, not on the <img> itself, or the real image blurs too.
Keep width and height on the image. The placeholder fills a box; the attributes are what reserve it before anything loads. For the rest of an image's journey to the page, the image optimization guide covers formats, sizing and lazy loading.
How I measured
- Apple Silicon Mac (M1 Pro), 2026-10-17, Node 24.22.0, sharp 0.35.4 with libvips 8.18.6.
- Source:
jpg-sample-photo-original.jpgfrom the Blinkimg site, 1920 × 1280, 576,828 bytes, a real camera photo. - WebP, JPEG and AVIF at quality 50; PNG at
compressionLevel: 9. Defaults otherwise. - The figure scales each placeholder to 360 × 240 with
resize(..., { fit: 'fill' }).blur(6)in sharp; a browser's scaling will look a little different.
What I did not test
I did not compare BlurHash or ThumbHash strings, which need a client-side decoder. One photo with a dark background is not every photo.
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.


