Squoosh's codecs are published as standalone npm packages under @jsquash/*, and the basic usage really is as short as the README says: give encode() an ImageData, get an ArrayBuffer back. Getting from that snippet to something you can point a stranger's 12-megapixel photo at is where the actual work lives.
I build ArtboardLab, a free browser-based design toolkit where nothing gets uploaded — that's where these lessons came from. The compressor there uses six jSquash packages:
"@jsquash/avif": "^2.1.1",
"@jsquash/jpeg": "^1.6.0",
"@jsquash/oxipng": "^2.3.0",
"@jsquash/png": "^3.1.1",
"@jsquash/resize": "^2.1.1",
"@jsquash/webp": "^1.5.0"
Below are the seven decisions I had to make myself. (One clarification up front, since I mixed these up in an earlier draft of my own docs: WASM here means the image codecs and nothing else. The .ai file tools on the same site run on pdf.js, which is plain JavaScript.)
Single-threaded WASM was a constraint I chose, not one I hit
The multithreaded builds of these codecs need SharedArrayBuffer, which needs cross-origin isolation, which means shipping COOP/COEP headers. On an ad-funded site those headers break AdSense iframes. So the deploy config carries a permanent note instead:
# NOTE: never add COOP/COEP (Cross-Origin-Opener-Policy / Cross-Origin-Embedder-Policy).
# Cross-origin isolation breaks Google AdSense iframes.
# The WASM codecs on this site run single-threaded by design, so they don't need it.
Once that's settled, per-image encode speed is fixed and the only parallelism left is between images: several workers, each with its own independent single-threaded WASM instance. Which turns the problem into a scheduling problem.
Pool size: half the cores, capped at three, one on mobile
export function poolSize(): number {
const nav = navigator as Navigator & { deviceMemory?: number };
const cores = nav.hardwareConcurrency || 4;
const isMobile = /Android|iPhone|iPad|Mobile/i.test(nav.userAgent);
const lowMemory = nav.deviceMemory !== undefined && nav.deviceMemory < 4;
if (isMobile || lowMemory) return 1;
return Math.max(1, Math.min(3, Math.floor(cores / 2)));
}
hardwareConcurrency is the wrong number to use directly. Each worker holds a decoded RGBA buffer plus a WASM heap, so the ceiling that matters is memory, not cores — and the browser tab you're competing with is the user's, on a machine that's also running everything else they had open. Half the cores, hard-capped at three, and a single worker on mobile or anything reporting under 4 GB.
Reserve the worker synchronously, before the file read
This is the ordering the pool's own comment exists to protect. The dispatch loop wants to hand a worker some bytes, and reading those bytes is async:
const slot = workers.reserve(current.format, job.id);
if (!slot) break;
updateJob(job.id, { status: 'reading', format: current.format });
void job.file.arrayBuffer().then((buffer) => {
slot.post({ type: 'process', jobId: job.id, buffer, /* … */ }, [buffer]);
});
If you mark the worker busy inside the .then(), the loop keeps iterating while every arrayBuffer() is still pending, sees the same idle workers over and over, and over-dispatches the whole queue at once. reserve() flips busy synchronously and returns a handle; the async read then posts through that handle. The pool's own comment says it plainly: "Synchronously reserves a worker (spawning if needed) so async file reads can't over-dispatch."
Note the [buffer] transfer list — the ArrayBuffer moves to the worker rather than being cloned, and the finished encode is transferred back the same way.
AVIF gets a concurrency limit of exactly one
The pool exposes a single boolean for this, and the dispatch loop consults it before every AVIF job:
/** true while any worker is encoding AVIF (max 1 concurrent AVIF job). */
get avifInFlight(): boolean {
return this.handles.some((h) => h.busy && h.format === 'avif');
}
// AVIF is the heavy codec: never run two at once (memory + heat)
if (current.format === 'avif' && workers.avifInFlight) continue;
continue, not break — a queued AVIF job doesn't stall jobs behind it.
AVIF speed is the single highest-leverage parameter
AVIF's speed option (higher = faster encode, lower = smaller file) has a much steeper cost curve single-threaded than the docs' framing prepares you for. Measured on my machine on a 12 MP image: speed: 7 took 143 seconds. speed: 9 came in around 40. Same file, same build.
143 seconds is not a slow tool, it's a broken one — the user has closed the tab. So the encoder is pinned:
case 'avif': {
// speed 9: measured on 12MP — speed 7 took 140s+ single-threaded,
// which is unusable; 9 trades a little size for a big time win
const buffer = await (await import('@jsquash/avif')).encode(imageData, {
quality,
speed: 9,
});
return { buffer, mime: 'image/avif' };
}
If you benchmark AVIF in a multithreaded Node script and then ship the number to a single-threaded browser build, you will be wrong by an order of magnitude. Measure where it runs.
JPEG has no alpha channel, and mozjpeg won't tell you
Hand a decoded transparent PNG straight to the JPEG encoder and it reads the RGB channels while ignoring alpha — so whatever colour was hiding under alpha = 0 ships in the output, usually black or a bleeding edge colour. No error, no warning. The fix is to composite first, which means owning a blend function:
for (let i = 0; i < src.length; i += 4) {
const a = src[i + 3];
if (a === 255) {
// identity fast path — opaque pixels must survive bit-exact
out[i] = src[i];
out[i + 1] = src[i + 1];
out[i + 2] = src[i + 2];
} else if (a === 0) {
out[i] = bgR; out[i + 1] = bgG; out[i + 2] = bgB;
} else {
const inv = 255 - a;
out[i] = Math.round((src[i] * a + bgR * inv) / 255);
// …g, b
}
out[i + 3] = 255;
}
Two details worth stealing. The a === 255 branch isn't only about speed: routing opaque pixels through the multiply-and-round would introduce drift on the pixels that were supposed to be untouched. And keeping this as a pure function over ImageData — no DOM, no worker globals — means it's unit-testable under Node, which is where its five tests run. The one wrinkle is that ImageData doesn't exist there, so construction is guarded:
if (typeof ImageData === 'function') return new ImageData(data, width, height);
return { data, width, height, colorSpace: 'srgb' } as unknown as ImageData;
jSquash only reads data, width and height, so the plain object is enough.
Only the JPEG path flattens. WebP, PNG and AVIF take the ImageData as-is and keep their alpha. The background colour is user-selectable, defaults to white, and parseHexColor falls back to white on anything it can't parse.
Vite pre-bundling breaks WASM asset resolution
This is the one that costs an afternoon if you don't know it, and it's a two-line fix:
vite: {
optimizeDeps: {
// jSquash WASM modules must not be pre-bundled (worker + wasm asset resolution)
exclude: [
'@jsquash/jpeg', '@jsquash/png', '@jsquash/oxipng',
'@jsquash/webp', '@jsquash/avif', '@jsquash/resize',
],
},
},
Every jSquash package you use goes in the list, @jsquash/resize included.
Keeping them out of the pre-bundle also preserves lazy loading. Every codec in the worker is imported dynamically at the point of use:
case 'image/webp': return (await import('@jsquash/webp')).decode(buffer);
so a visitor converting JPEG to WebP never downloads the AVIF module at all. The PNG output path chains two of them — encode with @jsquash/png, then run @jsquash/oxipng's optimise() at level 2 — and neither is fetched unless PNG is the chosen output.
What I'd do differently
- Decide the isolation question first. COOP/COEP versus ad revenue determines whether you're writing a scheduler at all. Discovering it after building the fast path is the expensive order.
-
Benchmark in the browser, single-threaded, on a big image, before choosing codec parameters. The AVIF
speednumber was the difference between a usable tool and an abandoned tab. -
Assume every codec fails silently at least once. JPEG's missing alpha, unusual-but-valid files the codecs reject (the compressor falls back to
createImageBitmap+OffscreenCanvasfor those) — none of it throws. It just looks slightly wrong, and only in the output. -
Keep the pixel math out of the worker. Anything expressible as a pure function over
ImageDatashould live in a testable module. That's the only part of this pipeline I could write tests for without a browser.
Top comments (0)