Generating BlurHash and LQIP Placeholders

During image processing, downscale the oriented original to about 32 pixels wide, encode those raw pixels with blurhash’s encode() at 4×3 components to get a ~28-character string, optionally also save a 16–24 px WebP as a base64 LQIP, store both on the asset row, and ship them inline with the API response so the client can paint the placeholder in the element’s reserved box before any image request completes.

A feed of user photos loading into grey rectangles looks broken for the second or two it takes on mobile; loading into nothing and then shifting the layout is worse. Placeholders fix the perception without changing the bytes that eventually load. This page is part of responsive image delivery in media processing and delivery pipelines, and it assumes you already reserve layout space with width and height attributes as shown in generating srcset variants at upload time.

When to use this approach

  • Images arrive in lists — feeds, galleries, search results — where many load at once and some take seconds on mobile.
  • You control the API that returns asset data, so a 30-byte string per image can ride along in the JSON.
  • You want the placeholder available at first paint, without an extra request — which rules out lazy-loaded placeholder images.

Prerequisites

  1. Node 20+, sharp 0.33+ and blurhash 2.x (npm i sharp blurhash) in the processing worker.
  2. A text column for the hash and, if you use LQIP, a short text column for a data URI (under 1 KB).
  3. On the client, the same blurhash package’s decode() and a <canvas>, or CSS-only rendering from the LQIP.

BlurHash versus LQIP

They solve the same problem differently. BlurHash stores a handful of DCT coefficients — the average colour plus a few low-frequency waves — as a compact base-83 string, and the client reconstructs a smooth gradient from it. LQIP stores an actual tiny image, usually 16–24 px wide, that the browser upscales with blur.

BlurHash and LQIP compared BlurHash is about 28 characters, needs JavaScript and a canvas to decode, and shows colour gradients only. LQIP is about 400 to 700 bytes as a base64 WebP, renders with CSS alone, and shows rough shapes. Two placeholder encodings BlurHash LEHV6nWB2yk8pyo0adR*.7kCMdnj size: ~28 characters (4×3) render: decode() → canvas, JS shows: colour fields, no edges fits: a VARCHAR, a JSON field, a URL query, a push payload best for large lists LQIP data:image/webp;base64,UklGRl… size: ~400–700 bytes (20 px) render: background-image, CSS shows: rough shapes and layout fits: inline in HTML for SSR, works with JavaScript disabled best for hero images
BlurHash is twenty times smaller; LQIP carries more shape and needs no script. Generate both and pick per surface.

The practical rule: BlurHash for anything that appears many times per response (a feed of 50 items adds 1.4 KB of hashes versus 30 KB of LQIPs), LQIP for server-rendered hero images where you want the placeholder to paint before JavaScript runs.

Implementation

import sharp from "sharp";
import { encode } from "blurhash";

export interface Placeholders {
  blurhash: string;
  lqip: string;          // data URI, < 1 KB
  dominant: string;      // #rrggbb, for a flat fallback colour
  aspect: number;        // height / width after orientation
}

/**
 * Compute placeholders from the ORIGINAL upload (or any large variant).
 * Runs in ~15 ms for a 12 MP JPEG because every step works on a tiny thumbnail.
 */
export async function makePlaceholders(input: Buffer): Promise<Placeholders> {
  // Orient first so the placeholder matches the displayed image, not the stored pixels.
  const oriented = sharp(input, { limitInputPixels: 100_000_000 }).rotate();

  // 1. BlurHash needs raw RGBA pixels. 32 px wide is plenty — hashing a larger
  //    image costs O(w × h × components) and produces an identical string.
  const { data, info } = await oriented
    .clone()
    .resize(32, 32, { fit: "inside" })
    .ensureAlpha()
    .raw()
    .toBuffer({ resolveWithObject: true });

  // Components: 4 across, 3 down suits landscape; swap for portrait.
  const [cx, cy] = info.width >= info.height ? [4, 3] : [3, 4];
  const blurhash = encode(new Uint8ClampedArray(data), info.width, info.height, cx, cy);

  // 2. LQIP: a 20 px WebP at low quality, base64-encoded.
  const lqipBuf = await oriented
    .clone()
    .resize(20, 20, { fit: "inside" })
    .webp({ quality: 40, alphaQuality: 40 })
    .toBuffer();
  const lqip = `data:image/webp;base64,${lqipBuf.toString("base64")}`;

  // 3. Dominant colour from sharp's stats — a flat fallback for surfaces with no JS.
  const { dominant } = await oriented.clone().stats();
  const hex = (n: number) => n.toString(16).padStart(2, "0");

  const meta = await oriented.metadata();
  const swapped = (meta.orientation ?? 1) >= 5;
  const w = (swapped ? meta.height : meta.width) ?? 1;
  const h = (swapped ? meta.width : meta.height) ?? 1;

  return {
    blurhash,
    lqip,
    dominant: `#${hex(dominant.r)}${hex(dominant.g)}${hex(dominant.b)}`,
    aspect: h / w,
  };
}

// Usage in the processing worker
import { readFile } from "node:fs/promises";
const p = await makePlaceholders(await readFile(process.argv[2]));
console.log({ ...p, lqipBytes: p.lqip.length });
// { blurhash: 'LEHV6nWB2yk8pyo0adR*.7kCMdnj', lqip: 'data:image/webp;base64,UklG…',
//   dominant: '#6b5a48', aspect: 0.75, lqipBytes: 566 }

And the client side, rendering the hash into the reserved box and swapping when the real image arrives:

import { decode } from "blurhash";

export function paintPlaceholder(img: HTMLImageElement, hash: string): void {
  const w = 32;
  const h = Math.max(1, Math.round(w * (img.height / img.width || 0.75)));
  const pixels = decode(hash, w, h);
  const canvas = document.createElement("canvas");
  canvas.width = w;
  canvas.height = h;
  const ctx = canvas.getContext("2d");
  if (!ctx) return;
  ctx.putImageData(new ImageData(pixels, w, h), 0, 0);
  img.style.backgroundImage = `url(${canvas.toDataURL()})`;
  img.style.backgroundSize = "cover";
  // Remove the placeholder once the real pixels are decoded, to free memory.
  img.addEventListener("load", () => { img.style.backgroundImage = ""; }, { once: true });
}

document.querySelectorAll<HTMLImageElement>("img[data-blurhash]").forEach((img) => {
  if (!img.complete) paintPlaceholder(img, img.dataset.blurhash!);
});

Line-by-line on the parameters that matter

  • resize(32, 32, { fit: "inside" }) before encoding. BlurHash only captures low frequencies, so encoding a 4000 px image gives the same string as a 32 px one — after several hundred milliseconds of CPU. Always hash a thumbnail.
  • ensureAlpha().raw(). encode() expects RGBA, four bytes per pixel. Passing RGB data produces a hash with shifted colours and no error.
  • 4×3 or 3×4 components. More components add detail and length (each extra component adds two characters). Beyond 5×4 the placeholder starts to look like a bad photo rather than an intentional blur.
  • .rotate() first. The placeholder must match the displayed orientation; a landscape blur behind a portrait photo is a jarring flash.
  • Decode at 32 px on the client and upscale with CSS. Decoding at the display size (say 800×600) costs tens of milliseconds per image on a phone; a 32 px canvas is sub-millisecond and looks identical once scaled.
  • Remove the background on load. Otherwise a transparent PNG shows the blur through its transparent regions forever.

What the user sees, frame by frame

Timeline of a feed card with and without a placeholder Without a placeholder, the card is empty grey from first paint at 200 milliseconds until the image arrives at 1400 milliseconds. With BlurHash, the card shows a colour gradient from first paint, then the image replaces it. Feed card on a mid-range phone, 4G no placeholder empty grey box photo with BlurHash colour gradient of the photo photo 0 200 ms 1400 ms Same bytes, same load time; the second card reads as "loading" instead of "broken" for 1.2 s.
Placeholders do not make images faster; they make the wait legible, which is what users actually judge.

Configuration gotchas

ValidationError: Width and height must match the pixels array. You passed info.width and info.height from a different pipeline than the one that produced data, or forgot ensureAlpha() so the buffer is three bytes per pixel. Always destructure data and info from the same toBuffer({ resolveWithObject: true }) call.

Placeholders look too saturated or too dark. BlurHash works in linear light and encodes sRGB input. Wide-gamut originals (Display P3 from iPhones) need .toColourspace("srgb") before .raw(), or the colours are interpreted as sRGB and shift.

Hydration mismatch warnings in React. The server renders data-blurhash and the client paints it into style after mount. Render the LQIP or dominant colour as a server-side style value instead, and let BlurHash enhance it after hydration.

Transparent PNG logos get a coloured blob. For images with an alpha channel, skip the placeholder or use the LQIP (which keeps alpha); BlurHash has no transparency and paints an opaque gradient.

Where placeholders go in the data model

Treat placeholders as part of the image’s identity, not as a rendering detail. Store blurhash, lqip, dominant and aspect in the same row as the width, height and variant list, write them in the same transaction that marks the asset ready, and return them from every API that returns the image URL. If they live anywhere else — a separate table, a cache, a later backfill job — some code path will eventually render an image without them and you are back to grey boxes.

Two rules keep this cheap. First, compute placeholders from the oriented original, never from a variant: a variant may not exist yet if generation is lazy, and computing from the original means the placeholder never depends on processing order. Second, recompute them only when the original changes. Because an asset’s original is immutable under its key, that means never — a replacement upload is a new asset version with its own placeholders.

For existing images uploaded before placeholders existed, run a one-off backfill that reads each original, calls makePlaceholders, and updates rows in batches of a few hundred. At about 15 ms per image, a million images take four CPU-hours; parallelise across workers and it is an afternoon. Until a row is backfilled, fall back to the dominant colour from the variant, or to a neutral tone that matches your page background, so the UI code never has to handle a missing value.

Storage and payload budget

Payload added to a 50-item feed response For fifty items, BlurHash adds about 1.4 kilobytes, dominant colour about 0.4 kilobytes, and LQIP about 30 kilobytes to the JSON response. Extra bytes in a 50-item feed JSON dominant 0.4 KB BlurHash 1.4 KB LQIP ≈30 KB Store all three; send BlurHash in list APIs and LQIP only where the page is server-rendered.
The hash is effectively free to send with every list item; the LQIP is not, so it belongs on single-item pages.

Verification

import { strict as assert } from "node:assert";
import { readFile } from "node:fs/promises";
import { decode, isBlurhashValid } from "blurhash";
import { makePlaceholders } from "./placeholders.ts";

const p = await makePlaceholders(await readFile("fixtures/portrait-iphone.jpg"));

assert.ok(isBlurhashValid(p.blurhash).result, "hash must decode");
assert.ok(p.blurhash.length <= 32, "4x3 hash should be ~28 chars");
assert.ok(p.lqip.length < 1024, "LQIP must stay under 1 KB");
assert.ok(p.aspect > 1, "portrait original must yield a portrait aspect");
assert.equal(decode(p.blurhash, 8, 8).length, 8 * 8 * 4);
console.log("placeholders ok", p.blurhash, p.dominant);

In the browser, throttle to “Slow 4G” and reload a feed: every card should show a colour field immediately, with no layout shift when the photo arrives (Lighthouse → CLS should stay at 0 for the image grid).

Frequently Asked Questions

Should I compute placeholders in the browser before upload?

You can — the uploader already has the pixels — but treat the result as untrusted and recompute server-side, because a client can send any string. Computing on the server during post-upload media transcoding costs about 15 ms per image and keeps the value authoritative.

Is ThumbHash better than BlurHash?

ThumbHash encodes aspect ratio and alpha, and gives slightly more detail at a similar size. If you are starting fresh and have transparent images, it is a good choice; the storage, API and rendering pattern here is identical.

Do placeholders help Largest Contentful Paint?

No. LCP measures the real image. Placeholders improve perceived loading and, together with width and height attributes, eliminate layout shift. To improve LCP, serve smaller variants and preload the hero image.