Use a blurry placeholder for images. It's a little bit harder when the images are unknown in advance, so we generate the blurhash string at runtime, and use several caches for the HTML and blurhash string.
We use the @substrate-system/blur-hash web component to do
the blur-up technique.
The placeholder hash string is computed on the Cloudflare backend.
The strings are computed by the Durable Objects whenever they get a new feed
item. The Durable Object resolves the og:image tag, then uses that image
to generate a blurhash string, and we save the blurhash string globally in
a KV cache.
- When the DO ingests a new item and resolves its
og:image, it callsupdateBlurhashFromCacheOrQueue(src/server/durable-objects/index.ts). - That method hashes the image URL with SHA-256 (see
blurhashCacheKeyinsrc/server/blurhash.ts) and looks up the result in theBLURHASH_KVnamespace (expirationTtl90 days). - Cache hit: the cached
{ blurhash, image_width, image_height }entry is written straight onto theitemsrow and the DO's feed version is bumped. - Cache miss: the DO enqueues a
BlurhashJob(image URL, item id, DO id) onto theBLURHASH_QUEUECloudflare Queue and continues serving the request — the rest of the work happens asynchronously.
The queue consumer is registered on the worker
(worker.queue in src/server/index.ts) and dynamically imports
blurhash-runtime.ts so the WASM image codec is only loaded in the
consumer isolate. For each job, handleBlurhashQueueBatch
(src/server/blurhash-consumer.ts):
- Re-checks
BLURHASH_KVin case another item with the same image URL populated it concurrently. - Fetches the image with a 10 s timeout, a 5 MiB ceiling, and a browser
user-agentso origins that block bots still serve the og image. - Decodes the bytes with
@cf-wasm/photon(PhotonImage.new_from_byteslice), resizes to 32×32 withSamplingFilter.Nearest, and encodes the raw pixels withblurhash'sencode()using 4×4 components. - Writes
{ blurhash, image_width, image_height }intoBLURHASH_KVwith a 90 day TTL, thenPOSTs it back to the originating DO at/internal/blurhash/items/:id, which updates theitemsrow and bumps the feed version.
The HTML served to the client is rendered server-side and cached per user
in HTML_KV under html:v3:<did>:<version>. Because the DO bumps its
feed_version whenever an item's blurhash is filled in, the next request
from that user misses the HTML cache and re-renders.
Rendering happens in src/server/lazy-html.ts:
injectInitialFeedwrites a<script id="initial-feed" type="application/json">block into<head>. That JSON payload is the same shape the client would otherwise fetch from/api/sync, and every item carries itsblurhash,image_width, andimage_heightfields.renderInitialFeedImagereplaces the#rootplaceholder with a fully hydrated item list. When an item has a valid blurhash plus dimensions, it emits<blur-hash placeholder="..." src="..." width="..." height="...">; otherwise it falls back to a plain<img>. That is the markup@substrate-system/blur-hashupgrades when the component definition registers on the client.
This is a build-on-demand model: HTML is generated the first time a
signed-in user requests it after their feed version changes, then served
from HTML_KV until the next item (and therefore the next blurhash) lands.
Items that never get a blurhash (image fetch failed, 4xx, oversized, or
undecodable) are simply rendered as <img> — the queue consumer acks
the job and moves on, and the cache key is left empty so retries can
happen later.