Blurkit
Universal image placeholder generation across Node, Deno, browser, edge, Cloudflare, and WASM runtimes. 🖼️

Universal image placeholder generation across Node, Deno, browser, edge, Cloudflare, and WASM runtimes. 🖼️

blurkit started with a simple frustration. Every time I built an app that loaded images, I wanted those blurry placeholder previews that make the experience feel polished. BlurHash and ThumbHash are the go-to algorithms for that, but hooking them up across different environments like Node, Deno, and the browser meant wiring platform-specific libraries for each target. The code was never portable and the setup was always fiddly. So I built the library I wished existed: one encode API that works everywhere, with the runtime internals handled under the hood.
Imagine writing the same encode call for a server-side batch job and a browser upload preview. That is the whole point. The library exposes a single encode function across seven runtimes including Node, Bun, Deno, browser, edge workers, Cloudflare Workers, and standalone WASM. It abstracts image decode, pixel processing, and hash computation behind a consistent interface. Swap the runtime, keep the code. ✨
Each runtime exposes the same public API from dedicated entrypoints (blurkit/node, blurkit/deno, blurkit/browser, etc.). Conditional exports in package.json resolve the right implementation at import time. There is no configuration, no environment sniffing, and no runtime checks.
sharp for native-speed decodeImageDecoder plus OffscreenCanvas, falling back to WASMget and set) with caller-owned invalidationencodeMany is fail-fast and encodeManySettled returns per-item result envelopesblurkit-wasm-codecs)get and set) and shipped exactly one in-memory helperThe core of the multi-runtime strategy is in the export map:
{
".": {
"deno": { "import": "./dist/root-deno.js" },
"browser": { "import": "./dist/root-browser.js" },
"worker": { "import": "./dist/root-edge.js" },
"node": { "import": "./dist/root-node.js" },
"default": { "import": "./dist/root-edge.js" }
}
}Each entrypoint imports only the runtime-specific modules it needs. The Deno entrypoint avoids Node.js builtins entirely. The browser entrypoint uses Web APIs. The edge entrypoint is optimised for worker environments.
A simplified view of the flow:
async function encode(input: Input, options: Options): Promise<BlurResult> {
const reader = resolveInputReader(input) // runtime-aware
const raw = await reader.decode(options) // codec-specific
const hash = computeHash(raw, options) // BlurHash or ThumbHash
const dataURL = await renderPlaceholder(raw, options) // format-specific
return { algorithm, hash, dataURL, width, height }
}| Runtime | File Path | Buffer | Blob/File | URL |
|---|---|---|---|---|
| Node | ✅ | ✅ | ❌ | ✅ |
| Deno | ✅ | ✅ | ❌ | ✅ |
| Browser | ❌ | ❌ | ✅ | ✅ (CORS) |
| Cloudflare | ❌ | ❌ | ❌ | ✅ |
This project was an exercise in disciplined abstraction. The hardest part was not implementing BlurHash or ThumbHash. Those algorithms are well-documented. The real challenge was designing a pipeline that could degrade gracefully across environments without leaking complexity to the caller. Every runtime-specific constraint is surfaced in the API rather than hidden: Node needs sharp, Deno needs the WASM codec package, the browser rejects file paths and depends on CORS. The library does not pretend these differences do not exist. It just makes them manageable.