ditherkit

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 into ImageData, caches it in state
  • Processing effect (keyed on source + all control props) — posts the cached ImageData to 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 buffer

Steps 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:

  1. pixelSize is honest. Each dither cell becomes exactly a pixelSize × pixelSize square in the cached WebP. No CSS blur, no fudge.
  2. Higher pixelSize is faster. The dither runs on 1/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.

On this page