DEV Community

kyungju lee (Benjie)
kyungju lee (Benjie)

Posted on

Image codecs in the browser with jSquash: seven decisions the README doesn't make for you

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"
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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)));
}
Enter fullscreen mode Exit fullscreen mode

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]);
});
Enter fullscreen mode Exit fullscreen mode

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');
}
Enter fullscreen mode Exit fullscreen mode
// AVIF is the heavy codec: never run two at once (memory + heat)
if (current.format === 'avif' && workers.avifInFlight) continue;
Enter fullscreen mode Exit fullscreen mode

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' };
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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',
    ],
  },
},
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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 speed number 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 + OffscreenCanvas for 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 ImageData should live in a testable module. That's the only part of this pipeline I could write tests for without a browser.

Top comments (0)