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:
- 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. - The browser / CDN HTTP cache — stores the final delivered
image bytes, keyed by URL, using standard
Cache-Controlsemantics.
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:
cacheKey—md5(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-cacheEvery 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, immutableResponses 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, orpixelSize→ 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 URL → cache is stale. This is the main failure mode for external URLs.
Options for handling stale external URLs:
- Override
Cache-Controlwith a finitemax-age— wrap the URL route handler and rewrite the response header (see External URLs → Cache keys). - Use content-addressed remote URLs — S3 with versioned keys, CDNs that embed a hash, etc. The URL itself changes when the content changes.
- Bust the cache manually — append a query parameter like
?v=2to yoursrcstring. 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-EncodingThe 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:
- Deleting
.next/cache/— clears the ISR cache - Hard-reloading your browser (Cmd-Shift-R / Ctrl-Shift-R) — bypasses the browser cache
- 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 content | Typical size per entry |
|---|---|
| 1-bit / 2-colour palette dither (the common case) | 2–20 KB |
| 4–16 colour palette dither | 5–50 KB |
| Photographic preserved-colour dithering | 50–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.