DEV Community

Cover image for Tiny image placeholders with sharp: bytes for 8 to 32 px LQIPs
Sahadat Husain
Sahadat Husain

Posted on

Tiny image placeholders with sharp: bytes for 8 to 32 px LQIPs

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

Bytes for a 16 px placeholder: WebP 108 B, JPEG 330 B, AVIF 344 B, PNG 600 B

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:

The placeholders at 8, 16, 24 and 32 px, each stretched and blurred, next to the full photo of an iguana on a branch

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 ]
Enter fullscreen mode Exit fullscreen mode

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;">
Enter fullscreen mode Exit fullscreen mode

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.jpg from 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.

Top comments (0)