Troubleshooting
Common errors when using ditherkit and how to fix them.
The shortlist of failure modes that account for almost every issue people hit. Each entry has the symptom, the cause, and the fix.
<DitheredImage /> shows a small red "error" label
The component caught an error and rendered its fallback. Open the
browser console — the real error is logged with the prefix
[DitheredImage]. The most common causes:
Cross-origin image without CORS headers
Symptom: the image loads visually for a moment, then the canvas
shows the error label. Console says SecurityError: Failed to execute 'getImageData' on 'CanvasRenderingContext2D'.
Cause: the image host doesn't send Access-Control-Allow-Origin.
The browser loads the image but won't let JavaScript read its pixels.
Fix: either configure the image host to send CORS headers, or
proxy the image through your own server. If you're on Next.js,
@ditherkit/next does the proxying automatically — pass
the URL as a string src to <DitheredImageSSR /> and it'll route
through /dithered/url/* server-side. See
Cross-origin images.
Worker file not found
Symptom: console error mentions dither.worker.mjs 404 or "Worker
script could not be loaded".
Cause: your bundler doesn't understand the
new URL('./dither.worker.mjs', import.meta.url) pattern. This is
rare — Vite, Next.js (Webpack and Turbopack), Rsbuild, and modern
esbuild all handle it natively.
Fix: check the
bundler compatibility table
to confirm your bundler is supported. If you're on standalone esbuild,
use --bundle and an ESM target.
next/image refuses to load the dithered URL
Symptom: in the browser console, next/image complains that
/api/dithered/... (or whatever your routeBase is) doesn't match
the images config.
Cause: you didn't call withDitherkit() from your next.config.
That helper adds ${routeBase}/** to images.localPatterns so
next/image will accept the URL — and it writes the env bridge that
the singleton <DitheredImageSSR /> reads its routeBase from, so
the component will throw a clear "routeBase is not configured" error
if withDitherkit is missing entirely.
Fix:
// next.config.ts
import type { NextConfig } from 'next'
import { withDitherkit } from '@ditherkit/next/config'
const config: NextConfig = {
// your existing config
}
export default withDitherkit(config, { routeBase: '/api/dithered' })See Route setup
for the full list of things withDitherkit adds.
"Cannot find module 'sharp'" on a Next.js production build
Symptom: build fails or the route handler crashes at runtime with a missing-module error pointing at Sharp.
Cause: sharp is a peer dependency of @ditherkit/next — it
intentionally isn't bundled because it has native modules. You need to
install it explicitly in your app.
Fix:
pnpm add sharpVercel serverless: 404 or "ENOENT" reading a static image
Symptom: the dithered-image route handler returns a 404 or logs
ENOENT: no such file or directory pointing at
.next/static/media/....
Cause: on Vercel, _next/static/ files live exclusively on the
CDN — they are not bundled into the serverless function container.
The route handler's on-disk readFileSync always fails, and the
handler falls back to fetching the image over HTTP from the CDN. This
HTTP fallback is the primary read path on Vercel, not a dev-mode
workaround.
If the fallback fetch also fails (e.g. returns 401), the handler returns a 404 to the client.
Fix: make sure you're calling withDitherkit() in your
next.config. The handler's HTTP fallback handles the rest
automatically. See
StaticImageData → Vercel serverless.
Vercel preview deployments: 401 on dithered images
Symptom: dithered images work in production but return 404 on
Vercel preview deployments. The route handler logs show
HTTP 401 — Vercel deployment protection is blocking the internal image fetch.
Cause: Vercel preview deployments with deployment protection enabled require authentication for all requests — including the internal fetch the route handler makes to read static image files from the CDN. The handler's self-fetch gets a 401, so it can't read the source image.
Fix:
- Go to your Vercel project → Settings → Deployment Protection
- Enable Protection Bypass for Automation
- Redeploy
This makes VERCEL_AUTOMATION_BYPASS_SECRET available as a system
environment variable. The route handler automatically sends it as an
x-vercel-protection-bypass header on the internal fetch, bypassing
protection for that request only.
Multi-colour palette looks wrong (everything mapped to nearest brightness)
Symptom: you're using a colour palette like Game Boy or PICO-8 with
@ditherkit/core directly, and the output looks like a brightness map
instead of a colour palette — green where you'd expect a mix of greens,
or one colour band where there should be several.
Cause: you called grayscale(pixels) before the dither algorithm.
That collapses the source to luminance, so the algorithm can only
match palette entries by brightness.
Fix: skip grayscale() for any palette with more than 2
colours. The dithering algorithms do nearest-colour matching in full
RGB space and need the original colours to work with. See
Algorithms → Grayscale is optional
for the rule.
The <DitheredImage /> and <DitheredImageSSR /> wrappers auto-decide
based on palette length, so this only bites you when you call the core
algorithms directly.
<DitheredImageSSR /> output looks smudged or anti-aliased
Symptom: the route handler returns a crisp dithered bitmap when
you fetch it directly (open ${routeBase}/static/... in the browser
and zoom in — every pixel is crisply on/off), but the rendered
<DitheredImageSSR /> on the page looks smoothed, blurry, or
washed out. Sampling the displayed pixels in DevTools shows
hundreds of unique grey values where there should be only two.
Cause: something downstream of the route handler is resampling the image. The two suspects:
unoptimized={false}was passed to the component (or the default was overridden some other way). With the optimizer enabled,next/imagerewrites the URL to/_next/image?url=...&w=...&q=75, which lossily re-encodes the bitmap and treats the dither pattern as noise to smooth away.style={{ imageRendering: 'auto' }}was passed (or some CSS class is settingimage-rendering: auto). Even withunoptimizedon, browsers will resample the bitmap at paint time under retina, zoom, or responsive scaling. The defaultimageRendering: 'pixelated'switches the browser to nearest- neighbour upscaling so each source pixel becomes a square block.
Fix: use the component defaults — unoptimized={true} and
style={{ imageRendering: 'pixelated' }} are both set automatically
by <DitheredImageSSR /> precisely to prevent this. Only override
them if you genuinely want a smoothed derivative (an art-directed
placeholder, an LQIP). See Pixel-preservation
defaults
for the full explanation.
If you're rendering a plain <img> or <picture> from
getDitheredImageUrl(...) instead of using the component, you have
to apply the same style={{ imageRendering: 'pixelated' }}
yourself — there's no next/image machinery in between to do it
for you.
Cache stale after editing a remote image
Symptom: you updated the contents of an image at a remote URL,
redeployed your Next.js app, but <DitheredImageSSR /> still shows
the old version.
Cause: for external URLs, the cache key is md5(url). The URL
didn't change, so the cache key didn't change, so the CDN serves the
old entry.
Fix: wrap the URL route handler and override Cache-Control
with a finite max-age, or use content-addressed URLs (S3 with
versioning, CDN paths with hash). See
External URLs → Cache keys
for the full options.
Slider drags feel laggy
Symptom: dragging a brightness or contrast slider feels janky on a slow device or with large source images.
Cause: every slider tick fires a re-render and a worker request. On a slow machine, the worker can't keep up.
Fix: wrap the slider value in useDeferredValue so React batches
updates. The worker queue drops superseded requests via AbortSignal
automatically, so once you stop dragging the worker only does the
final pass. See
Recipes → Reacting to brightness/contrast.
Still stuck?
Open an issue with:
- The package version (
@ditherkit/react,@ditherkit/next, or@ditherkit/core) you're using - A minimal reproduction (CodeSandbox, StackBlitz, or a
git clone-able repo) - The full console error
- Your bundler and framework versions