Generating BlurHash Placeholders with Sharp

blurhash.encode() is a pure function over a flat byte array. It trusts the width and height you hand it, checks only that bytes.length === width * height * 4, and will cheerfully return a well-formed 28-character string from a buffer whose channel count, orientation or colourspace is wrong. sharp, for its part, returns raw pixels in whatever shape the source happened to have. The gap between those two contracts is where every broken BlurHash comes from — and a broken hash does not throw, it just renders a muddy grey-green rectangle that nobody notices until a designer complains. This page is the exact sharp-to-blurhash wiring that survives EXIF-rotated phone photos, CMYK TIFFs, 16-bit PNGs and palette GIFs, inside a build that has to process thousands of assets without blowing the time budget. It is the implementation detail behind the placeholder taxonomy in Progressive Rendering and Image Placeholders, and it assumes you already decided that a ~28-byte hash beats a ~640-byte inline data URI for your page.

Prerequisite checklist

Why the wiring is fragile

Five transformations sit between a file on disk and the byte array encode() wants, and four of them can go wrong silently. The diagram below is the pipeline as it should run, annotated with the exact buffer shape at each hop for a portrait iPhone photo carrying EXIF orientation 6.

Sharp to BlurHash Pipeline, Annotated with Buffer Shape A left-to-right chain of five stages: source file, rotate, resize, ensureAlpha and raw, and encode. Beneath each stage the buffer shape is given, starting at a 4032 by 3024 JPEG with EXIF orientation 6, becoming 3024 by 4032 pixels after rotation, 24 by 32 pixels after resize, 3072 bytes of RGBA after the raw read, and finally a 28 character string. A lower band shows the raw buffer as a row of bytes grouped in fours. Every hop changes the buffer shape — the encoder only sees the last one source file EXIF orient 6 .rotate() orientation baked .resize(32,32) fit: inside .ensureAlpha() .raw() encode(cx, cy) base83 string 4032 × 3024 JPEG 3024 × 4032 px 24 × 32 px 3072 bytes RGBA 28 chars, 4×3 The buffer encode() actually walks: row-major, no row padding, stride fixed at 4 R G B A R G B A R G B A … × 768 pixel 0 pixel 1 pixel 2 encode() reads bytes[4 * (y * width + x)] for red — the alpha byte is skipped, but the slot must exist. width and height must come from the resize result, never from the pre-resize metadata. A three-channel buffer has the right byte count for a different image — the length assertion still passes.

That last line is the trap worth internalising. encode() throws only when pixels.length !== width * height * 4. Hand it a 24×32 RGB buffer (2304 bytes) with width = 24, height = 32 and it throws — good. Hand it that same buffer with width = 24, height = 24 (2304 bytes exactly) and it does not throw; it produces a plausible hash of a sheared, hue-rotated version of your photo. Anything that computes dimensions independently of the buffer can hit that.

The complete build-time encoder

One module, one pass per asset, every non-obvious flag annotated. It emits a manifest keyed by source path, and skips any asset whose content hash and encoder settings are unchanged since the last run.

// build/blurhash.mjs — node build/blurhash.mjs src/images src/_data/blurhash.json
import sharp from 'sharp';
import { encode, isBlurhashValid } from 'blurhash';
import { createHash } from 'node:crypto';
import { readFile, writeFile, readdir } from 'node:fs/promises';
import path from 'node:path';

// libvips spawns a thread pool per sharp instance. If you also run your own
// concurrency limiter (below), the two oversubscribe and throughput DROPS.
// One libvips thread + N JS-level jobs is measurably faster for tiny outputs.
sharp.concurrency(1);
// The operation cache holds decoded inputs in memory. For a one-shot build over
// thousands of distinct files it only costs RSS — nothing is ever reused.
sharp.cache(false);

// Bump this whenever anything below changes shape. It is part of the cache key,
// so a settings change invalidates every record without a manual purge.
const ENCODER_VERSION = 'bh-3';
const LONG_EDGE = 32;          // px on the long edge fed to encode()

/** Pick components from the TRUE aspect ratio so the decode is not stretched. */
function pickComponents(width, height) {
  const ratio = width / height;
  // BlurHash allows 1..9 per axis. Cost is O(w*h*cx*cy) and string length is
  // 6 + 2*(cx*cy - 1), so keep the product at or below ~20 for inline use.
  if (ratio >= 1.7) return { cx: 5, cy: 3 };   // 16:9 and wider  -> 30 chars
  if (ratio >= 1.2) return { cx: 4, cy: 3 };   // 4:3 landscape   -> 28 chars
  if (ratio > 0.83) return { cx: 4, cy: 4 };   // square-ish      -> 36 chars
  if (ratio > 0.58) return { cx: 3, cy: 4 };   // 4:3 portrait    -> 28 chars
  return { cx: 3, cy: 5 };                     // tall portrait   -> 34 chars
}

async function hashFor(file) {
  const bytes = await readFile(file);
  return {
    bytes,
    key: createHash('sha256')
      .update(ENCODER_VERSION)
      .update(bytes)
      .digest('hex')
      .slice(0, 16),          // 64 bits is plenty for a build cache
  };
}

async function encodeOne(bytes) {
  const pipeline = sharp(bytes, {
    failOn: 'truncated',      // tolerate warnings, reject genuinely broken files
    limitInputPixels: 268402689, // libvips default (~16k x 16k); raise only if you
                                 // trust the source — a decompression bomb here
                                 // stalls the whole build.
    animated: false,          // take frame 0 of GIF/WebP/APNG, not the filmstrip
  })
    // rotate() with NO arguments applies the EXIF Orientation tag and then strips
    // it. It MUST come before resize(): libvips reorders lazily, and a portrait
    // photo tagged orientation 6 is 4032x3024 on disk but 3024x4032 on screen.
    .rotate()
    // Force sRGB. A CMYK TIFF or a Display-P3 PNG otherwise reaches .raw() with
    // 4 ink channels or wide-gamut primaries, and the hash's colours are wrong
    // in a way that looks "slightly off" rather than obviously broken.
    .toColourspace('srgb')
    .resize(LONG_EDGE, LONG_EDGE, {
      fit: 'inside',          // preserve aspect; the shorter axis comes out < 32
      withoutEnlargement: true, // never upscale a 16px icon into 32px of mush
      kernel: 'cubic',        // lanczos3 (the default) rings on hard edges at
                              // this reduction ratio; cubic is smoother AND
                              // ~15% cheaper. The output is blurred anyway.
      fastShrinkOnLoad: true, // let the JPEG decoder shrink by 1/2,1/4,1/8 during
                              // decode — this is the single biggest speed win
    })
    // Flatten transparency onto a neutral base BEFORE forcing alpha. BlurHash
    // ignores the alpha channel entirely, so an un-flattened PNG logo encodes
    // its transparent regions as whatever garbage sits in the RGB slots.
    .flatten({ background: '#ffffff' })
    // Now guarantee exactly 4 channels. Without this, a JPEG yields 3 and the
    // stride assumption inside encode() silently shifts by one byte per pixel.
    .ensureAlpha()
    .raw({ depth: 'uchar' }); // 8 bits per channel; 16-bit PNG sources would
                              // otherwise emit ushort and double the length

  const { data, info } = await pipeline.toBuffer({ resolveWithObject: true });

  // info.width/height are the POST-resize, POST-rotate dimensions. Using
  // metadata().width here is the most common way to produce a valid-looking
  // but wrong hash.
  if (info.channels !== 4) throw new Error(`expected RGBA, got ${info.channels}ch`);
  if (data.length !== info.width * info.height * 4) throw new Error('stride mismatch');

  const { cx, cy } = pickComponents(info.width, info.height);
  const hash = encode(new Uint8ClampedArray(data), info.width, info.height, cx, cy);

  if (!isBlurhashValid(hash).result) throw new Error('encoder produced invalid hash');
  // Length is fully determined by the components — assert it as a cheap tripwire.
  const expected = 6 + 2 * (cx * cy - 1);
  if (hash.length !== expected) throw new Error(`len ${hash.length} != ${expected}`);

  return { hash, cx, cy, sampleW: info.width, sampleH: info.height };
}

const [srcDir = 'src/images', outFile = 'src/_data/blurhash.json'] = process.argv.slice(2);
const previous = JSON.parse(await readFile(outFile, 'utf8').catch(() => '{}'));
const manifest = {};
let hits = 0, misses = 0;

const files = (await readdir(srcDir))
  .filter(n => /\.(jpe?g|png|webp|avif|tiff?|gif|heic)$/i.test(n));

// A small fixed-size worker pool. 4 is a good default: the work is CPU-bound in
// libvips, and with sharp.concurrency(1) each job uses exactly one core.
const POOL = 4;
const queue = files.slice();
await Promise.all(Array.from({ length: POOL }, async () => {
  for (let name = queue.shift(); name; name = queue.shift()) {
    const file = path.join(srcDir, name);
    const { bytes, key } = await hashFor(file);
    if (previous[name]?.key === key) { manifest[name] = previous[name]; hits++; continue; }

    // Full-size metadata for the aspect ratio the TEMPLATE needs — the hash
    // carries no dimensions, so width/height must travel with it.
    const meta = await sharp(bytes).rotate().metadata();
    const { hash, cx, cy, sampleW, sampleH } = await encodeOne(bytes);
    manifest[name] = {
      key, hash, cx, cy, sampleW, sampleH,
      width: meta.width, height: meta.height,
      aspect: +(meta.width / meta.height).toFixed(4),
    };
    misses++;
  }
}));

await writeFile(outFile, JSON.stringify(manifest, null, 2) + '\n');
console.log(`blurhash: ${misses} encoded, ${hits} cached, ${files.length} total`);

Warning: .rotate() before .resize() is not a style preference. libvips builds a lazy operation graph and honours the order you declare; putting resize first fits a portrait photo into a landscape box and then turns it sideways, so every phone photo in your gallery gets a hash of a differently-cropped image.

Choosing componentX and componentY

The component counts are the only genuinely aesthetic decision in the pipeline, and they trade three things against each other: perceived detail, string length, and encode cost. The grid below shows which DCT basis functions each configuration retains — the top-left cell is the DC term (flat average colour), moving right adds horizontal frequency, moving down adds vertical frequency.

BlurHash Component Selection and Its Cost On the left, a nine by nine grid of basis components with three nested rectangles marking the four by three, six by four and nine by nine selections. On the right, a table listing four configurations with their component product, hash length in characters, encode time in milliseconds at a thirty-two pixel sample, and a short verdict. Columns add horizontal frequency, rows add vertical frequency; the top-left cell is the flat average componentX: 1 → 9 componentY: 1 → 9 downward DC config terms chars encode 3 × 3 9 22 0.4 ms composition lost 4 × 3 12 28 0.5 ms the default 6 × 4 24 52 1.0 ms hero images only 9 × 9 81 166 3.2 ms costs more than LQIP length = 6 + 2 × (cx × cy − 1) encode cost ∝ w × h × cx × cy Above 24 terms the string is longer than a gzipped 16×9 WebP data URI, and blurrier. Encode times measured on the 24 × 32 sample buffer produced by the pipeline above, Node 20, Apple M1.

The asymmetric configurations matter more than the absolute count. A 4×3 hash on a 9:16 portrait allocates the extra horizontal term to the axis with the least variation, so a vertical composition decodes to horizontal bands. pickComponents() in the script above flips the bias below a 0.83 ratio for exactly that reason. If you serve art-directed crops through the picture element, remember each crop is a different aspect ratio and needs its own hash — one hash per source file is wrong the moment the same file ships as both a 16:9 banner and a 1:1 thumbnail.

The build cache is not optional

Encoding is cheap per image and ruinous in aggregate. At roughly 7 ms of wall time per asset (decode-with-shrink plus resize plus encode), a 3 000-image catalogue costs 21 seconds of a cold build on four cores — tolerable once, intolerable on every save in watch mode. Keying on the source bytes plus ENCODER_VERSION gives a cache that is correct under every mutation you care about.

Content-Hashed BlurHash Cache States A state diagram. An asset is discovered, then hashed over its bytes plus the encoder version. If the manifest already holds that key the record is reused at zero cost. If not, the image is decoded, resized and encoded, taking about seven milliseconds, and the new record is written to the manifest. Two invalidation triggers are shown feeding back into the hash step: editing the source file and bumping the encoder version. asset discovered readdir + filter sha256(bytes + ver) 64-bit key, 1.2 ms key in manifest? yes reuse record 0 ms, no decode no decode → encode ≈ 7 ms per asset write manifest row hash + w/h + cx/cy next run reads it back invalidates: source edited · re-crop ENCODER_VERSION bump Hashing bytes rather than mtime survives git checkouts and CI caches, where every file's mtime is the clone time.

Tradeoff: hashing the full source file costs ~1.2 ms per megabyte of I/O plus digest, so on a catalogue that is already 100% cached you pay roughly 3.6 s of pure hashing for 3 000 assets. That is still 6× faster than re-encoding, and it is the only key that is correct across a CI runner where every mtime is the checkout timestamp. If you need it faster, hash the first 64 KB plus the file size — collisions in that space are not a realistic concern for a placeholder.

Verification

1. Round-trip the hash back to an image

The only real proof that a hash is correct is looking at it. Decode to a small PNG and compare with a downsample of the source side by side.

// build/verify-blurhash.mjs — node build/verify-blurhash.mjs hero.jpg
import sharp from 'sharp';
import { decode } from 'blurhash';
import { readFile } from 'node:fs/promises';

const manifest = JSON.parse(await readFile('src/_data/blurhash.json', 'utf8'));
const name = process.argv[2];
const rec = manifest[name];

// decode() returns RGBA at whatever size you ask. punch > 1 exaggerates
// contrast; keep it at 1 while verifying or you cannot compare colours.
const W = 64, H = Math.round(64 / rec.aspect);
const pixels = decode(rec.hash, W, H, 1);

await sharp(Buffer.from(pixels), { raw: { width: W, height: H, channels: 4 } })
  .png().toFile('/tmp/blurhash-decoded.png');

// A same-size downsample of the original, for eyeball comparison.
await sharp(`src/images/${name}`).rotate().resize(W, H, { fit: 'fill' })
  .png().toFile('/tmp/blurhash-source.png');

console.log(rec.hash, `${rec.cx}×${rec.cy}`, `${W}×${H}`);

If the decoded PNG is rotated 90°, .rotate() is missing or misplaced. If it is a smear of horizontal stripes with no vertical structure, the component bias is inverted for that aspect ratio. If the hues are shifted toward magenta or green, the buffer had three channels and the stride walked off.

2. Assert the invariants in CI

# Every record must carry a valid hash whose length matches its components,
# non-zero dimensions, and an aspect ratio consistent with them.
node -e '
const m = require("./src/_data/blurhash.json");
const { isBlurhashValid } = require("blurhash");
let bad = 0;
for (const [k, v] of Object.entries(m)) {
  const expect = 6 + 2 * (v.cx * v.cy - 1);
  const ok = isBlurhashValid(v.hash).result
    && v.hash.length === expect
    && v.width > 0 && v.height > 0
    && Math.abs(v.aspect - v.width / v.height) < 1e-3;
  if (!ok) { console.error("BAD", k, v.hash, v.hash.length, "want", expect); bad++; }
}
process.exit(bad ? 1 : 0);
'

# Placeholder payload budget: the manifest ships inline in the HTML, so cap it.
# 28 chars x 60 grid items = 1.7 KB, which is the practical ceiling per document.
node -e '
const m = require("./src/_data/blurhash.json");
const total = Object.values(m).reduce((n, v) => n + v.hash.length, 0);
console.log("hash bytes across catalogue:", total,
            "| mean:", (total / Object.keys(m).length).toFixed(1));
'

3. Benchmark the encode step in isolation

# Cold cache: delete the manifest and time a full run over a fixed corpus.
hyperfine --warmup 1 --runs 5 \
  --prepare 'rm -f src/_data/blurhash.json' \
  'node build/blurhash.mjs src/images src/_data/blurhash.json'

# Warm cache: the same command with the manifest left in place. The delta is
# what the cache buys you; if it is under 5x, your hashing is the bottleneck.
hyperfine --warmup 1 --runs 5 'node build/blurhash.mjs src/images src/_data/blurhash.json'

Expect roughly 7 ms per asset cold and 1.2 ms warm on a four-core machine with fastShrinkOnLoad enabled. If cold time exceeds 25 ms per asset, fastShrinkOnLoad is being defeated — it only applies to JPEG and only when the reduction is at least 2×, so a corpus of pre-resized 200 px PNGs sees none of it.

4. Confirm the placeholder actually paints

In DevTools, throttle to Slow 3G, reload, and pause the first frame after HTML parse. The image box should already carry the decoded blur. If it flashes empty first, your decode is running after DOMContentLoaded rather than inline — decode the above-the-fold hashes synchronously in a small inline script, and defer the rest behind an observer as described in advanced IntersectionObserver patterns for media.

Common mistakes

1. Omitting .ensureAlpha() and getting a three-channel buffer

A JPEG source yields channels: 3. If your code computes width/height from metadata() rather than from the raw info block, the length assertion inside encode() can still pass by coincidence, and the encoder reads red from what is actually green.

// WRONG — 3 channels, and dimensions taken from the pre-resize metadata.
const { width, height } = await sharp(file).metadata();
const data = await sharp(file).resize(32, 32, { fit: 'inside' }).raw().toBuffer();
encode(new Uint8ClampedArray(data), width, height, 4, 3);  // garbage or throw

// CORRECT — force 4 channels and read dimensions back from the same operation.
const { data, info } = await sharp(file)
  .resize(32, 32, { fit: 'inside' }).ensureAlpha().raw()
  .toBuffer({ resolveWithObject: true });
encode(new Uint8ClampedArray(data), info.width, info.height, 4, 3);

The mechanism is worth seeing byte by byte, because it explains why the failure looks like a colour shift rather than noise. The encoder’s index arithmetic is fixed at four bytes per pixel; a three-byte buffer therefore drifts by one byte per pixel, so red is read from green, green from blue, blue from the next pixel’s red — a hue rotation that increases across each row.

Stride Drift When a Three-Channel Buffer Is Read as RGBA A single row of twelve labelled bytes from an RGB buffer. Brackets above group them in threes as the true pixels zero, one, two and three. Brackets below group the same bytes in fours as the pixels the encoder believes it is reading, showing that the encoder's first pixel takes the red, green and blue of pixel zero plus the red of pixel one, and its second pixel starts at the green of pixel one. Same twelve bytes, two readings — the true layout above, what encode() assumes below true pixels, stride 3 p0 p1 p2 p3 R0 G0 B0 R1 G1 B1 R2 G2 B2 R3 G3 B3 read as pixel 0 read as pixel 1 read as pixel 2 pixel 0 red ← R0 (correct) pixel 1 red ← G1 (green read as red) pixel 2 red ← B2 The offset advances by one byte per pixel and wraps every three, so the hue rotates in bands across each row. Length check: 12 bytes passes as 3 pixels × 4, so encode() never throws — it just encodes a different picture. Fix: .ensureAlpha() before .raw(), then assert info.channels === 4 and data.length === w × h × 4.

2. Passing a Node Buffer where a Uint8ClampedArray is expected

Buffer is a Uint8Array subclass, so most of the time this works by accident. It stops working the moment the encoder’s internal arithmetic clamps: Uint8Array wraps on overflow, Uint8ClampedArray saturates. Some blurhash builds also feature-detect the constructor. Always wrap explicitly — new Uint8ClampedArray(data) reuses the underlying ArrayBuffer and costs nothing.

3. Encoding at full resolution “for accuracy”

BlurHash’s output is a handful of DCT coefficients. Feeding it a 4000 px image instead of a 32 px downsample changes the coefficients by well under one quantisation step while multiplying encode cost by 15 000×.

// WRONG — ~1 900 ms per image, and the resulting hash is visually identical.
const { data, info } = await sharp(file).ensureAlpha().raw()
  .toBuffer({ resolveWithObject: true });

// CORRECT — 32 px long edge; also lets fastShrinkOnLoad skip most of the decode.
const { data, info } = await sharp(file)
  .rotate()
  .resize(32, 32, { fit: 'inside', fastShrinkOnLoad: true })
  .ensureAlpha().raw()
  .toBuffer({ resolveWithObject: true });

4. Leaving transparency un-flattened

BlurHash has no alpha channel. sharp’s .ensureAlpha() adds an opaque alpha byte but leaves the RGB of fully transparent pixels as whatever the encoder wrote there — frequently pure black for PNG, frequently uninitialised-looking noise for WebP. A logo on a transparent background then encodes as a dark smear on a page with a white background.

// WRONG — transparent regions contribute their hidden RGB values to the hash.
.resize(32, 32, { fit: 'inside' }).ensureAlpha().raw()

// CORRECT — composite onto the colour the element will actually sit on first.
// Use your real page background, not white, if the two differ.
.resize(32, 32, { fit: 'inside' })
.flatten({ background: '#ffffff' })
.ensureAlpha().raw()

If you genuinely need alpha in the placeholder, BlurHash is the wrong tool — ThumbHash encodes an alpha channel in a comparable 25 bytes.

5. Running the encoder at request time

BlurHash generation belongs in the same build stage as your derivative generation, alongside the resize matrix described in sharp and FFmpeg media build pipelines. Doing it inside a request handler — a Next.js getStaticProps that runs on every ISR revalidation, or a serverless image route — puts 7 ms of libvips work plus a full file read on a path that should be serving from cache. The hash is a property of the bytes; compute it once, store it, ship it in the HTML.

6. Storing the hash without the dimensions

Worth repeating because it is the failure that reaches production most often: a BlurHash string is dimensionless. decode(hash, 32, 32) on a 16:9 photo returns a squashed square, and if you paint that into a background-size: cover box the composition is wrong in a way that is subtly disorienting rather than obviously broken. The manifest record must carry width, height and aspect, and your template must set the box geometry from them before anything paints — the same geometry contract that keeps srcset and sizes honest.

Field reference

Setting Where Recommended Why
.rotate() sharp, first op always, no args Applies EXIF orientation before any geometry op
.toColourspace('srgb') sharp always Normalises CMYK, P3 and greyscale sources
fit .resize() 'inside' Preserves aspect so components map to real axes
kernel .resize() 'cubic' No ringing at extreme reduction; ~15% cheaper than lanczos3
fastShrinkOnLoad .resize() true JPEG DCT-domain shrink; the single biggest speed win
.flatten() sharp page background colour BlurHash ignores alpha; transparent RGB leaks otherwise
depth .raw() 'uchar' Forces 8-bit; 16-bit sources otherwise double the buffer
componentX/Y encode() 3–5 per axis, biased long Product ≤ 20 keeps the string under ~46 chars
punch decode() 1 Contrast multiplier; >1 oversaturates dark photos
sharp.concurrency global 1 with a JS pool Avoids oversubscribing cores twice