---
title: "aadhar.sh/garage/thumbnail encoding study"
description: "One photo through AVIF, WebP and JPEG (zenc, the encoder this site ships), with real byte counts, quality scores and the tradeoffs the site makes."
path: "/garage/encoding"
section: "garage"
kind: "content"
updated: "2026-06-07"
source: "https://aadhar.sh/garage/encoding"
---

> Site index: https://aadhar.sh/llms.txt
> Section index: https://aadhar.sh/garage/llms.txt
> This is the Markdown twin of a page on aadhar.sh. The HTML at the source
> URL below is the original, and is hand-written and unminified on purpose.

# Thumbnail encoding study shipped

Every grid thumbnail ends a chain of choices: *format, quality, [chroma subsampling][chroma-subsampling], resolution*. Here I run one photo through all of them with real byte counts, so you can see the tradeoffs instead of taking them on faith. The actual encoders the site uses (avifenc, zenc/zenjpeg, cwebp, sips) produced the shipped numbers; the corpus study that put zenjpeg in the pipeline is below.

**The verdict shipped:** the live pipeline (`tools/photos/add-photos.sh`) encodes every thumbnail as [AVIF][avif]-primary + a JPG fallback. The JPG encoder is now **zenc** (`tools/photos/zenc/`, a zenjpeg wrapper: hybrid [trellis][trellis-quantization] + [progressive][progressive-jpeg] scan search) at q84, which retired the from-source [jpegli][jpegli] build in 2026-07 for a few percent fewer bytes at equal quality. The corpus comparison that drove that switch is below.

## Format showdown

The same detailed color frame at 400×266px. PNG holds the lossless baseline; everything below it throws bits away. Eyeball the quality, then read the bytes. `b/px` = bytes per pixel, the size-density that actually scales.

**PNG** lossless · 178.7 KB · 1.72 b/px

**JPG·sips** q82 · 4:2:0 · 31.7 KB · 0.31 b/px

**JPG·zenc** q62 · 4:2:0 · 9.7 KB · 0.09 b/px

**JPG·zenc** q84 · 4:2:0 · **shipped** · 15.9 KB · 0.15 b/px

**JPG·zenc** q95 · 4:2:0 · 32.0 KB · 0.31 b/px

**WebP** q60 · 4:2:0 · 7.8 KB · 0.07 b/px

**WebP** q80 · 4:2:0 · 11.8 KB · 0.11 b/px

**AVIF** q40 · 4:2:0 · 4.6 KB · 0.04 b/px

**AVIF** q63 · 4:2:0 · 10.4 KB · 0.10 b/px

**AVIF** q85 · 4:2:0 · 20.1 KB · 0.19 b/px

**AVIF** q63 · 4:4:4 · 11.3 KB · 0.11 b/px

## Why the site built jpegli from source (then moved past it)

Look at the two JPEGs above at the shipped quality: **system/sips** q82 lands at 31.7 KB, **zenc** q84 (the same visual quality) at 15.9 KB: *~50% fewer bytes*. Google's [jpegli](https://github.com/google/jpegli) first showed the win (re-implement libjpeg, tune it psychovisually); that is why the pipeline shelled out to a from-source `cjpegli` for years instead of the OS encoder. The site has since moved to **zenc/zenjpeg** (the corpus study below), which takes another few percent at equal quality. Being a Rust crate, it's dependabot-tracked and builds on any machine with no cmake/ninja toolchain. AVIF beats both, but JPEG still backs up every `<img>` as the universal fallback, so shrinking it still pays off.

## Zenjpeg won and shipped shipped

"Zenjpg" is a good shorthand, but the crate is published as [`zenjpeg`](https://docs.rs/crate/zenjpeg/latest) ([imazen/zenjpeg](https://github.com/imazen/zenjpeg)). It ships no CLI, so the site checks in a small Rust wrapper (`tools/photos/zenc/`) and the photo Action builds it with cargo; dependabot tracks the pin. That retired the from-source jpegli build in 2026-07. The corpus run below is the evidence that drove the switch.

**Corpus verdict (158 photos):** the single-fixture read below was a lead, so I ran the whole library through it (112 Fuji X-T50 + 46 Leica frames, resized to the 600px thumbnail tier, each normalized so both encoders and the metric see identical pixels). Across all 158, zenjpeg's realistic mode (hybrid trellis) needs about **1.6% fewer bytes** than `cjpegli -q82 -p2` at equal [SSIMULACRA2][ssimulacra2]; its aggressive mode (hybrid plus a 64-candidate scan search) about **4%**. zenjpeg's fast path ties cjpegli. A real win, but a small one on a fallback format AVIF already beats by ~40%.

### The knobs that matter

| knob | what it changes | thumbnail read |
| --- | --- | --- |
| `quality` | Quantization strength; zenjpeg's scale is jpegli-like and makes no promise of equal bytes. | Calibrate by output size and ignore the `82` on the label. |
| `ChromaSubsampling` | `None` = 4:4:4; `Quarter` = 4:2:0. | 4:2:0 is the likely production candidate, but this first matched run used 4:4:4 because current cjpegli did. |
| `progressive` | Progressive versus baseline JPEG; progressive mode also optimizes [Huffman][huffman-coding] coding. | Use progressive for the jpegli comparison; baseline is a separate decode/compatibility tradeoff. |
| `ProgressiveScanMode` | jpegli script, MozJPEG script, or `ProgressiveSearch` over 64 scan candidates. | On the corpus, scan search took the [BD-rate][bd-rate] from about 1.6% to 4%, at roughly 4× hybrid's encode time (13× cjpegli). |
| `auto_optimize(true)` | Hybrid trellis: jpegli adaptive quantization plus rate-distortion optimization. | The mode that actually beats cjpegli (+0.5 SSIMULACRA2 at matched bytes), about 3× the base encode time. |
| `OptimizationPreset` | `Jpegli*`, `Mozjpeg*`, and `Hybrid*` bundle quant tables, AQ/trellis, scan strategy, and deringing policy. | Compare named profiles rather than accumulating unexplained flags in the Action. |
| `deringing(true)` | Overshoot deringing around saturated, high-contrast edges; enabled by default for jpegli/hybrid paths. | Useful protection that saves no bytes: it changed nothing on this photo fixture, so it still needs an edge-heavy test image. |
| `parallel` | Optional Rayon-backed multi-threaded encoding, with restart markers supporting larger-image parallel work. | Moot at this scale: a 600px frame stays single-threaded, and the 158-photo batch already parallelizes at the job level. The whole library encodes in seconds either way. |

### Full-corpus read

158 real frames resized to 600px, each normalized to one canonical RGB buffer so both encoders and the metric read identical pixels (the grayscale frames carry a Gray [ICC][icc-profile] that different decoders expand differently, which will fake a quality collapse if you skip this). Quality is **SSIMULACRA2**, the perceptual metric zenjpeg's own tuning targets, cross-checked with PSNR/SSIM. BD-rate is the average byte difference at equal quality over a 5-point sweep; negative means fewer bytes for the same quality. Single thread, Apple Silicon.

| encoder / mode | BD-rate vs cjpegli 4:2:0 | encode speed | whole library |
| --- | --- | --- | --- |
| **cjpegli 4:2:0** (incumbent) | baseline | 123.5 MP/s | 0.32 s |
| zenjpeg base (no trellis) | +2.8% | 111.9 MP/s | 0.35 s |
| **zenjpeg hybrid** (trellis) | −1.6% | 38.1 MP/s | 1.02 s |
| zenjpeg hybrid + scan search | −4.1% | 9.3 MP/s | 4.23 s |

Read together: zenjpeg's **base** path is basically cjpegli, a hair slower and a hair larger. The story is the **trellis**: it buys the byte win by spending 3× to 13× the encode time. At matched bytes that lands as **+0.5 SSIMULACRA2** (hybrid) to **+0.8** (scan search), real but below the threshold I would trust without a visual pass. And even the 13× mode encodes the whole library in about 4 seconds, so encode speed does not gate this decision at thumbnail scale.

**Where this landed:** shipped. A 1.6% to 4% win is marginal, and on a JPEG fallback that AVIF already beats by ~40%, the number alone does not force the move. But the site already maintains a whole toolchain for marginally-superior output (jpegli-from-source, the minified build, dependabot on a one-worker repo), so marginal-superior clears the bar here. zenjpeg is [AGPL-3.0-or-later (or a $1 startup license)](https://docs.rs/crate/zenjpeg/latest), a paper cut for a build-time encoder whose JPEG output is not a derivative work of it; the Rust wrapper (`tools/photos/zenc/`) is dependabot-tracked and builds anywhere, no cmake/ninja. So it replaced the from-source `cjpegli` in the pipeline.

**The knob I hoped would change the answer, and did not:** "encode every photo to a fixed perceptual score instead of a fixed `q`" would be worth real effort, because a fixed `q` scatters wildly. On these 158 photos, `cjpegli -q84` lands anywhere from SSIMULACRA2 66.8 to 85.5. But zenjpeg 0.8.4's score targets (`Quality::ApproxSsim2`, `ApproxButteraugli`) are open-loop approximations with no measured loop behind them. Asked for SSIMULACRA2 85, the corpus landed at a mean of 77 with roughly the same spread as fixed `q` (1 photo of 158 inside ±2 of the target). It is a relabeled quality scale. The measured closed loop that would actually hold a score is not in the forward encoder in this release. Revisit if that ships.

The single-frame lead that prompted the corpus run

One 400×266 color fixture, `zenjpeg 0.8.4` progressive 4:4:4 vs `cjpegli -q82 -p2`. Directional only; the corpus above supersedes it.

| encoder / mode | bytes | SSIM | PSNR |
| --- | --- | --- | --- |
| **cjpegli q82** | 17,234 | .964791 | 40.7492 dB |
| zenjpeg baseline q82 | 17,252 | .964550 | 40.7186 dB |
| **zenjpeg hybrid q80** | 17,286 | .967684 | 41.2312 dB |
| zenjpeg hybrid q82 | 18,317 | .969240 | 41.5352 dB |

## Grayscale: drop the chroma planes

A black-and-white Leica frame (400×267). In a grayscale image the two chroma planes carry almost nothing, so AVIF **4:0:0** (luma only) both encodes it honestly and rules out any faint color cast. Dropping to 4:0:0 saves barely any bytes over 4:2:0 *because nothing lived in those planes to begin with*. Correctness is the reason here, since the byte savings are negligible. The pipeline detects `sips -g space → Gray` and switches to `--yuv 400`.

**PNG** lossless · 51.0 KB · 0.49 b/px

**JPG·zenc** q84 · 9.3 KB · 0.09 b/px

**AVIF** q63 · 4:2:0 · 10.6 KB · 0.10 b/px

**AVIF** q63 · 4:0:0 · 10.5 KB · 0.10 b/px

## Resolution scales bytes ~quadratically

The color frame at three long edges, AVIF q63 vs zenc q84. Doubling the long edge roughly triples the bytes, and AVIF holds a steady *~34–36%* lead over JPEG at every size, which is why the grid stays AVIF-primary and small and square.

| resolution | AVIF q63 | zenc q84 | AVIF saves |
| --- | --- | --- | --- |
| 400×266 | 10.4 KB | 15.9 KB | 34% |
| 800×533 | 33.3 KB | 52.1 KB | 36% |
| 1200×800 | 73.1 KB | 110.2 KB | 34% |

## Progressive is a rendering choice as well as a byte one

Everything above treats `progressive` as a compression knob: it optimizes Huffman coding, and `ProgressiveSearch` over 64 scan candidates took the BD-rate from ~1.6% to ~4%. That is only half of what the setting does. Scan order also decides *what the viewer sees while the file is still arriving*, and on a big enough image that is the entire loading experience rather than a footnote to it. Kornel Lesiński [made the case at perf.now() 2018](https://www.youtube.com/watch?v=jTXhYj2aCDU): the same bytes, reordered, show a recognisable picture at a quarter of the file, and it costs nothing, because it needs no extra request and no placeholder tier.

### What a scan actually is

A JPEG stores each 8×8 block as 64 quantized [DCT][dct] coefficients: one DC term, the block's average, and 63 AC terms for finer and finer detail. Baseline writes every block complete in a single pass, top to bottom, so half the file is the top half of the picture. Progressive writes the same coefficients over several passes, each called a *scan* and each covering the whole frame. A scan header names its channels, a band of coefficients (`Ss`..`Se`: DC alone, then coarse AC, then fine) and a bit precision (`Ah`/`Al`: the high bits first, the low bits in later refinement scans). Nothing is quantized twice, so the finished pixels match.

The [/lwe/encoding demo](https://aadhar.sh/lwe/encoding#demo-progressive) takes that last claim off the honour system. `jpegtran` rewrote the shipped q84 fixture into a baseline file and into libjpeg's standard 10-scan script without decoding it, all three decode to byte-identical pixels, and a slider hands each cut-off file to *your* browser's decoder. The shipped file's own scan headers read:

| scan | channels | carries | starts at |
| --- | --- | --- | --- |
| 1 | Y | DC (block averages) | 2% |
| 2 | Y | AC 1–2 | 11% |
| 3 | Y | AC 3–63 | 24% |
| 4 | Cb | DC (block averages) | 83% |
| 5 ★ | Cr | DC (block averages) | 85% |
| 6 | Cb | AC 1–63 | 86% |
| 7 | Cr | AC 1–63 | 92% |

The three files are also the byte half of the story. A progressive AC scan can code a run of all-zero blocks as one symbol, and each scan carries Huffman tables fitted to its own statistics; refinement scans give some of that back in correction bits. So the libjpeg script lands **1.7% over** baseline (16,609 against 16,324 bytes) while zenc's searched order lands **0.6% under** it (16,234). A smaller progressive file is a property of a good script.

### Chrome paints nothing until every channel has started measured

Cut a progressive JPEG anywhere and Chromium draws what the coefficients so far imply, missing rows and all, with one exception that decides everything: it paints nothing until Y, Cb and Cr have each begun a scan. The gate is exact to the byte. On the fixture the Cr DC scan header starts at byte 13,753 and its data at 13,763; a prefix ending anywhere up to 13,760 fails to decode, and one ending at 13,765 paints. `ImageDecoder` fed the same prefixes as a stream agrees, so this is the streaming path a visitor gets, and truncated blobs are a faithful stand-in for it. Measured in Chromium 152; the starred row above is the gate, and the LWE demo shows whether your engine keeps it.

zenc's search scores bytes and nothing else, and at thumbnail size the winning order often sends the whole luma plane before either chroma plane. On the fixture that puts the gate at 84.8%, so the shipped file paints once, at the end, like a slow baseline. Across the library's 255 colour JPG tiles the gate sits at a median 80% of the file: 99 paint within their first quarter, and the other 156 wait past halfway.

**On a thumbnail that barely matters.** The JPG tile is the fallback most browsers never fetch, and it is small: a median 48.7 KB, with the gate at a median 19.3 KB. A 10-packet initial [congestion window][congestion-window] carries about 14 KB, so 116 of the 255 reach their gate in the opening flight and the rest one round trip later. The scan search still earns its place at this size, purely for the bytes it saves.

It matters at [the full-size archive](https://aadhar.sh/photos). `/images/full/` is the one image surface here with no AVIF tier and no `<picture>` around it: the worker streams a median 21.6 MB progressive JPEG from R2 (since October 2026 as its lossless JPEG XL copy, about 8% smaller), and that single file is the whole payload. That is also the awkward part of the format story, because **AVIF structurally cannot do this**. Its layered mode caps at four stages, is effectively Chromium-only, and is staged rather than incremental the way a JPEG scan script is; nothing here emits layered AVIF anyway. So the format that wins every byte comparison on this page loses the one property that matters when a single large image *is* the page. Both are true at once, which is why the grid is AVIF and the archive is a progressive JPEG.

### A fifth of the archive had the order wrong fixed

The gate applies to the archive too, where it costs seconds. Each of the 258 files was streamed from R2 until every channel had started, giving up at 3 MB, then again after the repair below. Each cell is the group's median first paint, as a share of the file:

| archive files | count | before | after |
| --- | --- | --- | --- |
| colour, all of luma before any chroma | 53 | 93.4% | 0.023% |
| colour, zenc's DC-first script | 154 | 3.9% | 0.022% |
| colour, baseline, top rows down | 1 | 0.080% | 0.084% |
| colour, JPG original through `jpegtran` | 48 | 0.53% | unchanged |
| grayscale, one channel | 2 | 0.34% | unchanged |

All 53 were HIF re-encodes, and the cause is narrower than "the search picked badly". zenjpeg trial-encodes two scripts and keeps the smaller: jpegli's default, which opens with each channel's DC, and the winner of a mozjpeg-style search, which zenjpeg serializes with luma's DC and every luma AC scan ahead of chroma's DC. On 53 of 207 40 MP frames the second script won by bytes, and every one of them started colour between 66 and 99% of the file. XT508165 was the worst: 98.4% of 23.8 MB, about nine seconds of blank frame on a 20 Mbps link before a finished picture appeared in one step. The archive is progressive precisely to avoid that.

The repair never decodes a pixel. `jpegtran -progressive` rewrote the scan script of every HIF archive, all 207, around the same quantized coefficients: collapsing old and new to one baseline encoding gives byte-identical files for every one, each metadata segment survives byte for byte, and `djpeg` decodes both to the same pixels. mozjpeg's own search is free to interleave DC, so each file now opens with one scan that starts all three channels, and Chrome's first paint lands 4 to 6 KB in.

The bytes split by group. The 53 came out 0.11% smaller. The 154 that already painted by 5% came out 0.27% larger, a median 74 KB each, and that was a deliberate trade: 74 KB more on a ~22 MB download to start painting about 800 KB sooner. Across all 207 the pass costs 0.17%, and `add-photos.sh` now runs it after zenc on every new HIF.

The one baseline file, XT508887, is the camera's own JPEG, kept on purpose when the HIF archives were re-encoded in July. Fuji writes a single baseline scan with stock Huffman tables, so the same lossless pass made it progressive and 7.4% smaller, 16.4 to 15.2 MB, with its first paint at 0.084%.

### What the archive tier actually costs

The archive is a second encode: a HIF is decoded to lossless PNG, then re-encoded at `q100 4:2:2`. Scored against that decode with SSIMULACRA2, one 7728×5152 frame reads ~88, which looks alarming for *q100* until you sweep it. It is simply where JPEG tops out on 40 MP of sensor grain, and the metric is harsh on exactly the high-frequency noise JPEG discards first.

| quality | SSIMULACRA2 | bytes | b/px |
| --- | --- | --- | --- |
| `q100` (shipped) | 88.15 | 26.1 MB | 5.24 |
| `q95` | 85.13 | 16.3 MB | 3.28 |
| `q90` | 80.74 | 11.5 MB | 2.31 |
| `q84` | 74.26 | 7.8 MB | 1.57 |

Two other suspects for that ceiling were checked rather than assumed, and both are clean. Re-encoding at 4:4:4 instead of 4:2:2 scores 88.28 against 88.15, so the chroma round-trip is not the limit and 4:2:2 keeps its ~13% byte saving for free. Flattening the reference to 8-bit first scores 88.08 against 88.15, so the 10-bit→8-bit step is not the limit either: JPEG is 8-bit, and a HIF's extra depth earns its keep in the edit.

That generation also costs far less than it looks once an image is resized for anywhere else. Comparing a direct downscale of the HIF against a downscale of the archive JPEG, the same pair that reads 88.15 at full resolution reads **94.62 at 1080px**: averaging ~50 source pixels into one washes most encoding artifacts out. Delivery scale forgives the second encode. A 20 MB download does not, which is the whole reason the archive is progressive.

## Chroma: hold the bytes, then compare shipped

The two AVIF q63 cards in the showdown put 4:4:4 at 11.3 KB against 4:2:0's 10.4 KB, and this page used to conclude from them that full chroma was a byte tax. Those cards hold the *quality knob* fixed, so the 4:4:4 card also comes out better, and the two halves of that conclusion were never measured at once. The fair comparison fixes the bytes and lets quality move. Scored that way, on the tiles the grid actually serves, the call reverses:

| tier | shipped 4:2:0 | 4:4:4, same bytes | 4:4:4 ahead on |
| --- | --- | --- | --- |
| 600×600 | 78.25 | +0.45 | 18 of 24 |
| 400×400 | 79.25 | +0.52 | 16 of 24 |
| 200×200 | 79.80 | +0.59 | 15 of 24 |

SSIMULACRA2 over 24 photos from both cameras, and [butteraugli][butteraugli] agrees on every tier. In bytes, 4:2:0 needs a median **2.2% more** on the 600 tile to reach 4:4:4's score.

**Why more chroma helps when the camera recorded less.** Every color original here is 4:2:2, but that describes the 7728×5152 frame. A 600px tile is the frame's centre square reduced 8.6×, so each tile pixel averages about four color samples across and nine down, and at its own size the tile carries full color. 4:2:0 then discards half of it in each direction. With no compression at all, halving the color and restoring it scores 93.1 to 93.5 on the three tiles against 96.3 on a crop at native resolution: the downscale is what makes subsampling expensive. It barely costs bytes to keep, because full-resolution color in a reduced photo is smooth, and AV1 codes smooth planes for very little.

It matters most under red light. Red carries only 0.299 of the brightness in the [BT.601][bt-601] matrix these files signal, so fine detail in a red scene lives mostly in the color planes. One night frame of bare branches against red windows needs **35% more bytes** at 4:2:0 to match 4:4:4.

Each layout was scored at the shipped tile's exact byte count. avifenc's quality knob moves in steps of a few percent, so every score is interpolated between the two steps on either side of the budget, and the reproduced 4:2:0 tiles matched the shipped ones byte for byte (72 of 72) before anything was compared. `tools/photos/avif-chroma-probe.ts` reruns it.

## JPEG XL: where it loses, and what it would buy

Safari has decoded [JPEG XL][jpeg-xl] since version 17, Chrome Canary turned it on by default at 155, and Firefox has it on the way. So the format this page never tested needs an answer. It could help this site in three places: replacing the AVIF tiles, repacking the archive JPEGs without loss, or decoding faster. All three were measured.

### The tiles: AVIF wins every one

24 photos from both cameras. Each shipped AVIF tile was first reproduced byte for byte (72 of 72), then JPEG XL was searched onto exactly its byte count, at `-e 9` with its gaborish and EPF filters off, the best of the settings tried:

| tier | AVIF q63 4:4:4 | JPEG XL, same bytes | JPEG XL ahead on |
| --- | --- | --- | --- |
| 600×600 | 79.50 | 73.65 | 0 of 24 |
| 400×400 | 80.70 | 73.50 | 0 of 24 |
| 200×200 | 81.54 | 74.24 | 0 of 24 |

SSIMULACRA2, and butteraugli agrees on 70 of the 72. To match the 600 tile, JPEG XL needs a median **36.6% more bytes** (11% to 58%, over the 22 photos where the search reached a match). Every other setting trailed too: filters on by 6.09, `-e 7` by 6.53, and modular mode by 4.08, ahead on 2 of 24 by SSIMULACRA2 and on none by butteraugli. Both metrics came out of the JPEG XL project, so if the scoring leans anywhere, it leans toward JPEG XL.

These tiles sit at 0.57 to 0.92 bits per pixel, which is AV1's home ground: its video tools code smooth areas like sky and out-of-focus background for almost nothing. JPEG XL's published advantage is at higher bitrates, which this page hasn't measured.

### The archive: an 8% lossless repack

JPEG XL can repack an existing JPEG and give back the identical file on decode. Twelve archive originals from production (3 Leica Monochrom, 9 X-T50) went from 238.1 MB to 218.7 MB, **8.17% smaller** (4.45% to 11.27% per file), and all 12 decoded back to the original bytes. `-e 9` buys about 0.3% more. That's well under the ~20% usually quoted, probably because every file here already carries optimized Huffman tables and the X-T50 ones are `q100`. Which of the two matters hasn't been isolated.

### Decode time

Chrome Canary 156 on an M3 Max: `createImageBitmap` on a fresh Blob, median of 40 runs per tile and 7 per frame, formats interleaved. The 40 MP AVIF and lossy JPEG XL files are matched to 3.37 MB.

| format | 600 tile | 40 MP frame |
| --- | --- | --- |
| JPEG (zenc q84 tile, progressive archive) | 1.6 ms | 716 ms |
| AVIF q63 4:4:4 | 4.1 ms | 228 ms |
| JPEG XL `-e 7` | 4.6 ms | 151 ms |
| JPEG XL, filters off | 3.2 ms | 88 ms |
| JPEG XL, lossless copy of the JPEG | n/a | 144 ms |

On a tile everything lands under 5 ms, so the format costs a visitor nothing there. At 40 MP JPEG XL is fastest because it decodes in parallel: the image is cut into independent blocks. libjxl took 1.5 to 1.9 s on one thread for the same frame and about 0.25 s on all of them, so the lead is spare cores and should narrow on a machine with fewer. The two filters run while decoding, which is why switching them off speeds it up. The archive JPEG is slowest of all, since Chrome appears to decode a progressive JPEG on one thread.

Hardware AV1 decoding doesn't change any of this. The color tiers are 4:4:4, which is AV1's High profile, and most consumer hardware decoders implement only Main (4:2:0). As far as I know, Chrome and Firefox decode AVIF in software with dav1d anyway.

### What paints before the download finishes

The progressive section above says AVIF can't render incrementally. Measured: a server sent Chrome the first part of a 40 MP file and held the connection open, and a screenshot recorded what had painted.

| format | first paint | after that |
| --- | --- | --- |
| progressive JPEG (the archive) | part at 2%, all at 5% | refines |
| JPEG XL, lossless copy of it | 5% | refines |
| JPEG XL `-p` (+4.5% bytes) | 2% | sharpens at every step |
| JPEG XL, default | between 10% and 25% | near-final at once |
| AVIF, layered | 5% | unchanged until complete |
| AVIF (what the tiles ship) | only when complete |  |

The screenshots were 400×600, where a 1:8 preview already looks nearly final, so the early frames would read blurrier full-screen.

**Where this lands: nothing changes.** The tiles stay AVIF. Every browser that decodes JPEG XL also decodes AVIF, and AVIF wins at these sizes, so a JPEG XL `<source>` would either never be picked or make the tiles worse. The JPG fallback only reaches browsers without AVIF, which rules JPEG XL out there too. The archive is the one real opening: a lossless copy would be 8% smaller, decode about 5× faster in Chrome, and still paint early. It wasn't shipped at first, because a visitor saving the original would get JPEG XL bytes behind a `.jpg` link, and serving it meant two R2 objects per photo and a cache keyed on `Accept`.

**Update, 2026-10-07: the archive is JPEG XL now.** Chrome 155 shipped JPEG XL the day before, and both objections came from keeping the JPEG. Each original now lives in R2 as `<stem>.jxl` only, under its own URL, so a saved file is named for what it holds and nothing varies on `Accept`. The JPEG survives: `djxl` rebuilds it byte for byte, which every upload proves with `cmp` before it lands. The old `.jpg` links answer 301. The cost is a browser without JPEG XL, which can't open an original: Chrome before 155, and Firefox until it turns JPEG XL on (planned for 158).

## What aadhar.sh actually ships

- **AVIF primary** (`q63`, `4:4:4` color / `4:0:0` gray) + **zenc JPG q84** (zenjpeg hybrid+scan) as the universal fallback, via `<picture>`.
- **Pre-cropped 600/400/200 squares**, metadata-stripped: the file *holds exactly* the displayed pixels, so no off-square or EXIF bytes ride along.
- **4:4:4 over 4:2:0** for color, since 2026-09-26: at equal bytes it scores higher on every tile size, because a downscaled photo has full color to keep (the chroma section above). The tiles ship at the same `q63`, where 4:4:4 lands about 6% heavier across the library (+5.6%, +6.5% and +7.0% on the 600, 400 and 200 tiles) and scores 1.3 to 1.7 higher: the equal-bytes win, plus a deliberate spend on quality. Holding bytes flat would have meant `q62`, at +0.4% to +1.7% and +0.5 to +1.0.
- **Full-res share copies in R2 at JPEG `q100 4:2:2`**, progressive: 4:2:2 matches the HIF's native sensor chroma, and the scan order is the point at 20 MB. The true HEIF originals stay on local disk. A JPG-source original is uploaded byte-for-byte from the camera, rearranged to progressive by `jpegtran` on the way up: a lossless reorder of the same coefficients.

← back to the [garage](https://aadhar.sh/garage) · [aadhar.sh](https://aadhar.sh/)

shipped byte counts are real output from `avifenc · zenc · cwebp · cjxl · sips`; the corpus table is a reproducible `zenjpeg 0.8.4` run.

[chroma-subsampling]: https://en.wikipedia.org/wiki/Chroma_subsampling "Storing colour at lower resolution than brightness, since eyes notice brightness detail more. 4:4:4 keeps full colour and 4:2:0 keeps a quarter."
[avif]: https://en.wikipedia.org/wiki/AVIF "An image format that stores a picture compressed with the AV1 video codec. Every current major browser can display it."
[trellis-quantization]: https://en.wikipedia.org/wiki/Trellis_quantization "An encoder search that tries alternative rounding choices for each block and keeps whichever gives the best trade of file size against error."
[progressive-jpeg]: https://en.wikipedia.org/wiki/JPEG#JPEG_compression "A JPEG saved as several passes over the whole image, coarse first, so a browser can show a rough picture before the download finishes."
[jpegli]: https://github.com/google/jpegli "Google's JPEG library, a drop-in libjpeg replacement that writes ordinary JPEG files smaller than older encoders at the same visual quality."
[ssimulacra2]: https://github.com/cloudinary/ssimulacra2 "A perceptual image quality score from Cloudinary, built to match how people judge compressed photos. Higher is better, and about 90 is hard to tell from the original."
[huffman-coding]: https://en.wikipedia.org/wiki/Huffman_coding "A lossless coding method that gives short bit codes to common symbols and longer ones to rare symbols, shrinking the total."
[bd-rate]: https://arxiv.org/abs/2401.04039 "Bjøntegaard delta rate: the average share of bytes one encoder saves over another at equal quality, read off their size and quality curves."
[icc-profile]: https://en.wikipedia.org/wiki/ICC_profile "A block of data in an image file that describes its colour space, so software knows how to turn the stored numbers into real colours."
[dct]: https://en.wikipedia.org/wiki/Discrete_cosine_transform "Discrete cosine transform: math that rewrites a block of pixels as a sum of smooth wave patterns. JPEG stores those wave strengths instead of pixels."
[congestion-window]: https://en.wikipedia.org/wiki/TCP_congestion_control#Congestion_window "How much data a TCP sender may have in flight before it hears back. Connections usually start at 10 packets and grow as replies arrive."
[butteraugli]: https://github.com/google/butteraugli "Google's perceptual metric that estimates how visible the differences between two images are to a human eye. Lower is better."
[bt-601]: https://en.wikipedia.org/wiki/Rec._601 "A video standard whose matrix converts red, green and blue into one brightness and two colour channels, with red weighted at 0.299 of brightness."
[jpeg-xl]: https://en.wikipedia.org/wiki/JPEG_XL "A newer image format from the JPEG committee. It compresses well, decodes in parallel, and can repack an existing JPEG losslessly into a smaller file."

Source: https://aadhar.sh/garage/encoding
