ditherkit
@ditherkit/next

ISR caching

How @ditherkit/next caches processed images, dev vs prod behaviour, and when the cache invalidates.

The whole point of @ditherkit/next over running dithering in the browser is caching. The server does the expensive work once, stashes the result, and every subsequent request is free. This page covers exactly how that happens.

The cache lifecycle

  Request 1              Request 2..N
  ─────────              ─────────────
  1. next/image fetches URL     1. next/image fetches URL
  2. Route handler runs Sharp   2. Route handler checks cache
  3. WebP bytes returned        3. Cache hit — bytes returned
  4. Response cached            4. (no Sharp work, no fetch)

There are actually two caches at play:

  1. Next.js ISR cache — stores the route handler's response keyed by the full URL (path + query string). Lives on disk in .next/cache/fetch-cache/ or in Vercel's CDN.
  2. The browser / CDN HTTP cache — stores the final delivered image bytes, keyed by URL, using standard Cache-Control semantics.

Both are immutable in production. The first cache makes subsequent server requests free; the second makes subsequent browser requests skip the server entirely.

How cache keys are built

The URL path contains a deterministic cache key. For imported images, with routeBase: '/api/dithered':

/api/dithered/static/{cacheKey}/{paramString}?src={webpackPath}

Where:

  • cacheKeymd5(webpackHash + "-" + widthXheight).slice(0, 12). The webpack hash changes when the source file content changes, so the cache key changes automatically.
  • paramString — a deterministic serialisation of the dither parameters: fs_th128_pl000000ffffff_brp0_ctp0_w800_h600_px1_gsa. Each underscore-separated chunk encodes one parameter (algorithm slug, threshold, palette hex, brightness sign+magnitude, contrast sign+magnitude, width, height, pixelSize, grayscale a/t/f). Same parameters produce the same string.

For external URLs the structure is the same — both cacheKey and paramString appear in the path:

/api/dithered/url/{cacheKey}/{paramString}?src={encodedUrl}

The cache key for URL sources is md5(url).slice(0, 12), so the remote URL determines the path-level cache identity and the parameters live in the second segment.

Dev vs production

Development

Cache-Control: no-cache

Every request is processed fresh. This means:

  • Changes to your source image show up immediately. No cache to clear, no restart, no rebuild.
  • Changes to algorithm/palette props show up immediately.
  • It's slow. Every request waits for Sharp to run. That's the trade-off for instant feedback during development.

Production

Cache-Control: public, max-age=31536000, immutable

Responses are cached permanently — one year, marked immutable. Combined with ISR on the Next.js side, this means:

  • First request triggers the Sharp pipeline and populates both caches.
  • Every subsequent request for the same URL skips Sharp entirely and returns the cached bytes.
  • CDNs treat the response as permanently cacheable and serve it from the edge. Your server and Sharp don't get touched at all after the first request.

The immutable directive is a promise that the content at this URL will never change. That's why cache keys are content-addressed from webpack hashes — so the URL actually does change whenever the content changes, and we're not lying to the CDN.

When the cache invalidates

Imported images

  • Edit the source file → webpack generates a new hash → new cache key → new URL → fresh cache entry. Old entry ages out.
  • Change dither parameters in your JSX → new paramString → new URL → fresh cache entry.
  • Change width, height, or pixelSize → same story, new URL.

You never need to manually invalidate. Redeploy and the cache regenerates as needed.

External URLs

  • Change the URL string → new cache key → new URL → fresh entry. Obvious.
  • The remote image changes under the same URLcache is stale. This is the main failure mode for external URLs.

Options for handling stale external URLs:

  1. Override Cache-Control with a finite max-age — wrap the URL route handler and rewrite the response header (see External URLs → Cache keys).
  2. Use content-addressed remote URLs — S3 with versioned keys, CDNs that embed a hash, etc. The URL itself changes when the content changes.
  3. Bust the cache manually — append a query parameter like ?v=2 to your src string. Different string → different cache key → fresh render.

Dither parameters

Any change to algorithm, threshold, palette, brightness, contrast, width, height, or pixelSize produces a different paramString in the URL and therefore a fresh cache entry. Same for the source switching between different imported files or different external URLs.

What gets cached

The response body is lossless WebP bytes, post-dithering, post-Sharp. It's already in the final delivery format, ready to serve.

Content-Type: image/webp
Cache-Control: public, max-age=31536000, immutable
Vary: Accept-Encoding

The Vary: Accept-Encoding header ensures the CDN keeps separate entries for clients that can accept different compressions (gzip, brotli, identity). Normally there's only one WebP encoding so this doesn't add much to the cache fanout.

Why lossless WebP?

The whole point of dithering is exact pixel reproduction — the algorithms place each pixel deliberately to approximate continuous tone with a small palette. Lossy encoding (which is what Sharp's default webp({ quality: 90 }) would produce) treats those crisp high-frequency edges as noise and smooths them away. By the time the bytes reach the browser, the dither pattern looks like a quietly-blurred grayscale.

ditherImage calls .webp({ lossless: true }) so the bitmap is preserved byte-exact through the encode boundary. That's the correct trade-off for this content type — and it pairs with the unoptimized: true default on <DitheredImageSSR />, which prevents next/image from re-running its own lossy optimizer on the lossless bytes downstream.

WebP (rather than PNG) because it's universally supported by every browser we care about, Sharp encodes it cheaply, and lossless WebP is competitive with PNG on bilevel content while remaining smaller on slightly more complex palettes.

If you need a different output format, the current answer is to fork ditherImage from @ditherkit/next/server and swap the encoder. A format option on <DitheredImageSSR /> is plausible future work but isn't on the roadmap today.

Dev cache bypass

If you're debugging caching issues, you can force a clean slate by:

  1. Deleting .next/cache/ — clears the ISR cache
  2. Hard-reloading your browser (Cmd-Shift-R / Ctrl-Shift-R) — bypasses the browser cache
  3. Clearing your CDN's cache for your ${routeBase}/** path if you're testing in production

In development, no-cache means you should never see stale output. If you do, it's probably a service worker or a proxy sitting between you and Next.js — try a private window.

Cache size and cleanup

Processed image sizes depend almost entirely on how much colour the output preserves, since we encode lossless WebP:

Output contentTypical size per entry
1-bit / 2-colour palette dither (the common case)2–20 KB
4–16 colour palette dither5–50 KB
Photographic preserved-colour dithering50–300 KB

A site with 100 source images and 5 bilevel algorithm variants each would land around 1–10 MB total. Multi-colour palette work or photographic content can push that higher, but the per-entry ceiling is still well under what next/image would have produced for the original photo. Manual cleanup isn't a concern at typical sizes.

If you do want to reclaim space:

  • Delete .next/cache/ — rebuilt on next request
  • On Vercel, the cache is managed automatically and ages out based on their policies

Cache size grows with the number of distinct (source, parameter) pairs you actually render, not with your source image count. If you render one image with one algorithm, you have one cache entry.

On this page