Compression in the browser
Compression runs in the browser, before a byte crosses the network. A raw phone photo typically shrinks 5–10×, which is bandwidth the user does not spend and storage you do not buy.
It runs in a worker on OffscreenCanvas, so the main thread never blocks —
scrolling stays smooth while a dozen photos encode.
What it costs your bundle
Section titled “What it costs your bundle”Measured with bun build --minify --splitting, which is what a real bundler
does. (Without --splitting the number is 1.38 MB and it is a lie — everything
lands in one chunk whether or not you ever reach it.)
| gzipped | who pays | |
|---|---|---|
| the compressor, eager | 4,363 B | everyone |
compressorjs, lazy |
4,994 B | Safari < 16.4, or a failed decode |
heic2any, lazy |
341,352 B | each HEIC — and only if the engine cannot decode it natively |
hash-wasm |
7,223 B | everyone. Tree-shakes cleanly; there is no alternative, since WebCrypto has no incremental digest |
That last column is the whole design. Only 4 KB is unconditional; the expensive pieces load when something actually needs them.
HEIC: native first
Section titled “HEIC: native first”Engines that can decode HEIC do it in about 136 ms. The WASM fallback takes roughly 1,700 ms — and costs 341 KB to fetch first. So the native path is tried first, and the fallback only runs where it must.
That is a 12× difference on the exact file type phones produce by default, so it is worth confirming on a real device rather than in a simulator.
WebKit cannot encode WebP at all
Section titled “WebKit cannot encode WebP at all”canvas.convertToBlob({ type: "image/webp" }) is allowed to ignore you, and
WebKit always does. MDN’s compatibility data records
canvas.toBlob(type="image/webp") as unsupported in Safari — desktop macOS
included — and every browser on iOS is WebKit underneath, so Chrome, Firefox and
Edge on an iPhone inherit it. Nothing throws; you simply get another format.
Two different bad outcomes came out of that, and only one of them is fixed by the engine being honest:
Left alone, it returns PNG. Measured on an 11.9 MP photo: PNG 21.8 MB vs JPEG 3.8 MB — 5.7×, and 24× against a well-tuned WebP. A photo uploaded as a lossless screenshot format.
Handled, it returns JPEG. The compressor plans a JPEG attempt behind the
WebP one, so WebKit lands on the right fallback. Measured on iOS 27 / Chrome
151, a 9.1 MP photo: 2.05 MB → 743.7 kB, labelled jpeg.
Your visitors still get WebP
Section titled “Your visitors still get WebP”This changes what crosses the wire on the way in, not what you serve. Every variant is re-encoded server-side:
WebKit browser ──JPEG──▶ server ─────────▶ thumb · sm · md · lg · xl (WebP)Chromium/Firefox ──WebP──▶ server ─────────▶ thumb · sm · md · lg · xl (WebP)So the cost of the fallback is a slightly larger upload and an archived
original in JPEG — not a worse experience for anyone visiting the site.
Rotation: a photo held upright stays upright
Section titled “Rotation: a photo held upright stays upright”A phone taking a portrait photo usually does not store portrait pixels. It
writes the sensor buffer as-is — landscape — and tags it with an EXIF
Orientation of 5–8 meaning “rotate this before showing it”. Two numbers
therefore describe every such file, and they are transposes of each other:
| | 4032×3024 | the stored buffer | | | 3024×4032 | what it displays as |
The compressor resolves that before it resizes, and the upload comes out portrait, at the right proportions. You do not have to strip, pre-rotate, or otherwise prepare anything: hand it the file the camera produced.
Reading dimensions yourself
Section titled “Reading dimensions yourself”If you parse image headers in your own code, the same trap is waiting. Header
fields — the JPEG SOF marker, PNG IHDR — carry the stored pair and know
nothing about EXIF. readImageDimensions() returns exactly that, plus the
orientation tag, so the two spaces stay distinguishable:
import { readImageDimensions } from "@nitida/asset-compressor-web";
const dims = readImageDimensions(headerBytes);// → { width: 4032, height: 3024, orientation: 6 }Use the raw pair for anything orientation-invariant (area, a megapixel budget).
For anything geometric — a resize target, an aspect ratio, a layout box — you
want the displayed pair: transpose when orientation is 5, 6, 7 or 8.
⚠️ And if you resize with createImageBitmap, pass ONE axis, not both.
Giving resizeWidth and resizeHeight resizes to exactly that box without
preserving the ratio, so any disagreement between your numbers and the decoded
bitmap becomes a stretch. With one axis the spec derives the other, and the
proportions survive by construction — which is the second half of the 0.6.3 fix,
and the reason the geometry no longer depends on reading EXIF correctly.
When it declines
Section titled “When it declines”Non-image inputs — video, PDF — skip compression entirely and upload raw. An image the engine cannot decode also uploads raw rather than failing the upload: the original arriving intact beats a clever error.