# Compression in the browser

> What the compressor does, what it costs your bundle, and the one failure that used to make photos 24× heavier.

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

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

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

`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`.

:::danger[Label by the bytes you got, never by the format you asked for]
Read the blob's actual `type` after encoding and name the file from that. The
fallback is correct behaviour; naming the file for the *request* is the bug —
and it was found in five separate places once anyone looked, because every one
of them assumed the request was the answer.
:::

### 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

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.

:::caution[This was broken between 2026-06-02 and 2026-08-28]
Versions **0.6.0 – 0.6.2** computed the resize target from the *stored* pair and
then asked the decoder for exactly those dimensions, while decoding with
`imageOrientation: 'from-image'` — which applies the rotation first. Portrait
content was forced into a landscape box: a measured **1.78× horizontal stretch**
on every affected photo.

It required all three of: a header the engine can parse (JPEG/PNG/WebP/GIF/BMP —
**HEIC was never affected**), an EXIF orientation of **5–8**, and a long side
**above** the resize box. That combination is why it looked random.

**Fixed in 0.6.3.** Upgrade if you are below it. The damage happens in the
browser before upload, so already-stored photos cannot be repaired
server-side — they have to be uploaded again from the original.
:::

### 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:

```ts
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

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.