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:
- Removes the pending entry from its Map so the result is never routed back.
- Rejects the original promise with
AbortError, which the component's.catchswallows 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:
- Rejects all pending promises with a "worker crashed" error.
- 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
| Bundler | Works 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.tsand inspectpendingdirectly. - 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.