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
- Node 20+,
sharp0.33+ andblurhash2.x (npm i sharp blurhash) in the processing worker. - A text column for the hash and, if you use LQIP, a short text column for a data URI (under 1 KB).
- On the client, the same
blurhashpackage’sdecode()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.
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
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
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.