ditherkit

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 sharp

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

  1. Go to your Vercel project → SettingsDeployment Protection
  2. Enable Protection Bypass for Automation
  3. 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:

  1. unoptimized={false} was passed to the component (or the default was overridden some other way). With the optimizer enabled, next/image rewrites 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.
  2. style={{ imageRendering: 'auto' }} was passed (or some CSS class is setting image-rendering: auto). Even with unoptimized on, browsers will resample the bitmap at paint time under retina, zoom, or responsive scaling. The default imageRendering: '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

On this page