ditherkit
@ditherkit/react

How it works

The shared singleton Web Worker — how it's bundled, how it multiplexes requests, and how it stays fast under rapid prop changes.

<DitheredImage /> does its image processing in a Web Worker so the main thread stays responsive during the heavy loop-over-every-pixel work. How the worker is set up matters because an earlier version of this package created a fresh worker per component instance per prop change, which made slider drags feel terrible. This page describes how the current architecture works and why it's built that way.

The shared singleton

There is one worker per page, shared across every <DitheredImage /> instance. It lives at module scope in worker-client.ts inside @ditherkit/react:

 <DitheredImage A />  ─┐
 <DitheredImage B />  ─┼──> worker-client (singleton) ──> Worker
 <DitheredImage C />  ─┘

When the first <DitheredImage /> mounts and needs to process an image, the worker-client lazily spins up the Worker and caches it. Every subsequent mount reuses the same worker instance. The worker script is fetched, parsed, and initialised once per page, no matter how many components you render.

Why a singleton

Workers are expensive to spin up — the browser has to fetch the script, create a new JavaScript context, re-initialise modules, and wire up message channels. If you had ten <DitheredImage /> components on a page and each had its own worker, you'd pay that cost ten times. With a singleton, you pay it once.

There's no downside to sharing: the worker processes messages sequentially anyway (single-threaded JavaScript), and request routing is handled by multiplexing message IDs — see below.

Message protocol

Every request carries an integer id. Requests and responses are matched by this id so multiple in-flight requests from multiple components don't get mixed up.

main thread (worker-client)          worker
 ─────────────────────────             ─────
 postMessage({ id: 7, imageData, options })  ──→

                                             │ processes...

                                      ←──  postMessage({ id: 7, processedImageData })
                                      or
                                      ←──  postMessage({ id: 7, error: '...' })

The worker-client keeps a Map<id, { resolve, reject }> of pending promises. When a message comes back, the client looks up the id, calls resolve(imageData) or reject(new Error(...)), and deletes the entry.

Request cancellation via AbortSignal

Every request accepts an AbortSignal. When <DitheredImage /> detects that a prop change has superseded an in-flight request (the user is dragging a slider), it calls controller.abort() on the previous request. The worker-client:

  1. Removes the pending entry from its Map so the result is never routed back.
  2. Rejects the original promise with AbortError, which the component's .catch swallows silently.

The worker itself cannot be cancelled mid-algorithm. There's no way to interrupt a busy Worker in JavaScript short of worker.terminate(), which we don't want to do because it kills the whole shared instance. So if a slider drag queues up 10 superseded requests during a 30ms algorithm pass, the worker will still run all 10 — but only the last result reaches the DOM. The main thread never renders stale output.

Optimisation still open. Main-thread request coalescing at the worker-client level would skip the redundant worker passes entirely. Roughly 20 lines to add a "next pending" slot per caller. Worth adding once the playground supports user-uploaded large images; not urgent for the 400×300 sample case.

Crash recovery

The worker-client subscribes to worker.onerror. If the worker itself crashes (not a per-request error), the handler:

  1. Rejects all pending promises with a "worker crashed" error.
  2. Calls worker.terminate() and nulls the singleton.

The next ditherOnWorker(...) call after a crash spins up a fresh worker transparently. Components don't need crash-handling code of their own — they just see a one-shot rejection they can retry.

How the worker is bundled

The worker source lives at packages/react/src/dither.worker.ts and is emitted as a separate entry by tsdown:

// packages/react/tsdown.config.ts
export default defineConfig({
  entry: ['src/index.ts', 'src/dither.worker.ts'],
  format: ['esm'],
  // ...
})

Output goes to dist/dither.worker.mjs. The worker-client then references it with the standard ESM worker URL pattern:

new Worker(new URL('./dither.worker.mjs', import.meta.url), {
  type: 'module',
})

This pattern is understood by every modern bundler. No bundler config changes needed in your app.

Bundler compatibility

BundlerWorks out of the box?
Next.js 16 / Turbopack✅ Yes
Next.js 14/15 / Webpack✅ Yes
Vite✅ Yes (native worker support)
Rspack✅ Yes
esbuild (standalone)⚠️ Needs --bundle and ESM target

If your bundler doesn't understand new URL(..., import.meta.url) it won't pick up the worker as a separate chunk and the dynamic import will fail at runtime. File an issue with your bundler — this pattern is standard ESM and well-supported.

Why @ditherkit/core isn't loaded on the main thread

The worker imports @ditherkit/core directly:

// dither.worker.ts
import {
  floydSteinbergDither,
  atkinsonDither,
  // ...
} from '@ditherkit/core'

Because this import lives inside the worker entry, @ditherkit/core ends up in the worker chunk — not the main bundle. Your main JavaScript payload stays small; @ditherkit/core only ships to the browser once the user actually interacts with a <DitheredImage /> and triggers the worker to instantiate.

If you use @ditherkit/core directly from main-thread code, it will of course also end up in the main bundle — that's the expected trade-off and the reason @ditherkit/react exists as a wrapper.

Safari quirks

Safari does not support ctx.filter inside an OffscreenCanvas in a Web Worker. The worker detects Safari (via a main-thread user-agent check passed in the options) and falls back to pure-JS brightness and contrast implementations from @ditherkit/core instead of using canvas filters. The fallback is slightly slower but produces identical output.

This is handled automatically — you don't need to do anything.

Debugging

  • Inspect the worker — DevTools → Application → Web Workers shows the singleton instance and lets you pause/inspect it.
  • See requests in flight — the worker-client doesn't currently expose its pending map for debugging. If you're chasing a suspected leak, set a breakpoint in worker-client.ts and inspect pending directly.
  • Worker crashed? Look for the [DitheredImage] Worker error: log line. The next request will spin up a fresh worker automatically, but the crash itself still needs investigation.

On this page