Internals
For the curious — the Web Worker architecture, the Sharp pipeline, and how ISR cache keys are generated.
For the curious. None of this is needed to use the toolkit, but understanding it can help when something goes wrong or when you want to build something custom on top.
The Web Worker (in @ditherkit/react)
<DitheredImage /> does its processing in a Web Worker so the main
thread stays free. The full architecture is covered in
Web Workers; the short version:
Shared singleton
One worker per page, shared across every <DitheredImage />
instance. Workers are expensive to instantiate, so paying the cost
ten times for ten components would be wasteful.
<DitheredImage A /> ─┐
<DitheredImage B /> ─┼──> worker-client ──> Worker
<DitheredImage C /> ─┘ (singleton)The worker-client (packages/react/src/worker-client.ts) lazily
creates the worker on first use, caches it at module scope, and
multiplexes requests by integer ID.
Split effects
The component has two useEffects with narrow dependencies:
- Image load effect (keyed on
[src]) — fetches and decodes the source image intoImageData, caches it in state - Processing effect (keyed on source + all control props) —
posts the cached
ImageDatato the worker, awaits the result, updates another piece of state
The split means control changes (slider drags, algorithm switches) never refetch the image or re-decode it. Only the minimum work happens per prop change.
Cancellation
Every worker request carries an AbortSignal. When a new prop
change supersedes an in-flight request, the cleanup aborts the
signal, which removes the pending entry from the worker-client's
promise map and rejects the promise with AbortError (swallowed
by the component's .catch). Stale results never reach the DOM.
The worker itself can't be cancelled mid-algorithm — JavaScript workers don't support that. So a rapid slider drag can produce some wasted worker work, but the main thread never renders superseded output.
The Sharp pipeline (in @ditherkit/next)
<DitheredImageSSR /> processes images on the server using
Sharp for I/O and
@ditherkit/core for the actual dithering. The pipeline lives in
packages/next/src/server/dither.ts:
1. Snap requested width/height/pixelSize → canonical SnappedDimensions
2. Sharp(buffer).resize(processWidth, processHeight) — bilinear downsample
3. Extract raw RGB buffer from Sharp
4. Convert RGB → RGBA (pad in an alpha channel)
5. Apply brightness (if non-zero)
6. Apply contrast (if non-zero)
7. Convert to grayscale (auto for ≤ 2-colour palettes)
8. Apply the chosen dither algorithm at process resolution
9. Convert RGBA → RGB (drop alpha)
10. Sharp the dithered RGB and .resize(outputWidth, outputHeight, { kernel: 'nearest' })
11. Re-encode as WebP via Sharp
12. Return the WebP bufferSteps 4 and 9 are necessary because Sharp works in RGB while
@ditherkit/core's algorithms expect RGBA (to match browser
ImageData). The conversions are trivial — just copy the
buffer with stride 3 → 4 or 4 → 3.
Process resolution vs. output resolution
The pipeline runs the dither at the process resolution (=
output / pixelSize) and then upscales to the output
resolution with nearest-neighbour. This is the same model as
the React component, with the same two consequences:
pixelSizeis honest. Each dither cell becomes exactly apixelSize × pixelSizesquare in the cached WebP. No CSS blur, no fudge.- Higher
pixelSizeis faster. The dither runs on1/N²as many pixels.
SnappedDimensions (from @ditherkit/core) rounds the user's
requested width/height down to a multiple of pixelSize so
the maths is always exact — a request for 1000×750 with
pixelSize=3 becomes a 999×750 output (333×250 × 3).
Why WebP?
WebP compresses well, is supported by every browser we care about, and Sharp encodes it cheaply. AVIF would be smaller but has longer encode times and worse library support. PNG is larger and pointless for dithered output (which has very few colors and compresses extremely well in WebP lossless mode).
ISR cache keys (in @ditherkit/next)
Every <DitheredImageSSR /> produces a deterministic URL based
on the source identity and the dither parameters. The key
generator lives in packages/next/src/server/image-hash.ts.
For imported images (StaticImageData)
function getStaticImageCacheKey(staticImage: StaticImageData): string {
// staticImage.src looks like: "/_next/static/media/portrait.a1b2c3d4.jpg"
const filename = staticImage.src.split('/').pop()! // "portrait.a1b2c3d4.jpg"
const webpackHash = filename.match(/\.([a-f0-9]{8,})\./)?.[1] ?? 'unknown'
const cacheData = `${webpackHash}-${staticImage.width}x${staticImage.height}`
return crypto.createHash('md5').update(cacheData).digest('hex').slice(0, 12)
}The webpack content hash changes when the source file's bytes change, so the cache key changes automatically on source edits. No manual invalidation.
For external URLs
function getExternalImageCacheKey(url: string): string {
return crypto.createHash('md5').update(url).digest('hex').slice(0, 12)
}Same URL → same cache key. Different URL → different key.
Content that changes under the same URL doesn't invalidate —
that's the main limitation of the URL cache and why
External URLs recommends overriding
Cache-Control with a finite max-age for mutable remote sources.
The full URL shape
/dithered/static/{cacheKey}/{paramString}?src={encodedSrc}where paramString is a deterministic serialisation of the
dither parameters:
{algorithmSlug}_th{threshold}_pl{paletteHex}_br{±}{abs(brightness)}_ct{±}{abs(contrast)}_w{outputWidth}_h{outputHeight}_px{pixelSize}_gs{a|t|f}e.g. fs_th128_pl000000ffffff_brp0_ctp0_w1200_h900_px4_gsa
The width and height in the URL are the canonical snapped values — what the cached WebP actually decodes to. So two configurations that snap to the same dimensions hit the same cache entry.
Same parameters → same string → same URL → same cache entry. Different parameters → different URL → fresh render. Simple and free of any mutable state.
Why no registry?
An earlier design proposed a "registry" for StaticImageData — some kind of map from logical names to file paths, maintained by the consumer. It was cut because the webpack hash extraction above does the same job without requiring anything from the consumer.
The consumer just does import portrait from './portrait.jpg'
and passes the result to <DitheredImageSSR />. The component
reads .src, extracts the hash, and builds a cache key.
Everything is automatic; the import location is irrelevant.
Why no content registry / no database?
@ditherkit/next is stateless. There's no database, no Redis, no
server-side cache key map. The only state is what Next.js's own
ISR and your CDN store — keyed by URL, which is all
deterministically derived from the source file and dither
parameters.
If you want to add per-user caching, rate limiting, allowlisting, or any other stateful behaviour, wrap the route handlers in your own middleware. See Route setup → Customising the route handlers.