Image compression benchmarks: ten fixtures, measured in the browser

Ten fixtures, five measured runs each, one browser. These are encoder measurements from the same worker path the site ships — including the cases where the answer is not flattering.

Published by tinyimg. The harness, the fixtures and the result files are in the repository, so the method and the numbers can be checked.

What was measured, and how?

Ten cases from seven files, with five measured runs each: the benchmark measures compression time and output size. The 12 MP photograph is encoded to JPEG, WebP and AVIF, and the 2 MP screenshot is encoded to PNG and WebP.

The harness drives the shipping path. It imports src/lib/pool.js and src/lib/worker.js — the same pool and the same worker the app uses for JPEG, PNG, WebP, AVIF and JXL. For MotionPhoto it imports src/lib/motion-photo/pipeline.js, which is the path the app takes for that format; MotionPhotos do not go through the worker pool in production either. There is no benchmark-only compressor and no second implementation to drift.

Each case runs once as a warm-up, then five measured times. Cases run sequentially, so they do not compete for the four-worker pool. The reported time is the median of the five samples. The p95 uses nearest rank, which at five samples is the slowest run — no interpolation, and no pretence that five samples can resolve a tail.

The browser is headless Chrome 155 with a fresh temporary profile per run; extensions and background networking are disabled. It is served from localhost, and timing starts after the worker pool and the WASM modules are ready. The run deliberately does not enable cross-origin isolation: that would make wasm-feature-detect report threads and switch oxipng to its threaded build, which is not the path production uses.

The machine is an Apple M4 with 10 cores and 16 GB of memory, running macOS on arm64. The browser reports 10 hardware threads; the app caps its pool at four workers.

Which files were tested?

Seven files, from a 0.5 MP JPEG to a 24 MP photograph, plus a 12 MP MotionPhoto.

FilePixelsInputWhat it represents
photo-12mp.jpg4000×30004.92 MBDetailed photograph
shot-2mp.png1920×10801.36 MBUI screenshot with sharp edges and text-like bars
flat-1mp.png1000×1000118.1 KBFlat, low-detail artwork
alpha-4mp.png2000×20008.26 MBRGBA image with a real alpha channel
large-24mp.jpg6000×40009.66 MB24 MP photograph
tiny-0.5mp.jpg800×600219.6 KBSmall image; per-call overhead is visible here
motion-12mp.jpg4000×3000 still + 6 s 1280×720 video9.98 MBSynthetic MotionPhoto, assembled by the repository's writer

All fixtures are generated by ffmpeg from built-in sources. Each file's SHA-256 is recorded in bench/fixtures/manifest.json and checked before the browser loads it, so a changed fixture is a failure rather than a silently moved baseline.

What are the results?

Ten cases, with ratios from 1.5% to 69.3% and medians from 42.0 ms to 1,983.2 ms. Ratio is output ÷ input; lower is smaller. Medians and p95 are wall-clock time for the whole case, including the worker round trip. ms/MP divides the median by the megapixel count.

The table and both charts read the same file, bench/published.json (run id, date, environment); if they disagree, the file wins.

Compression ratio by caseHorizontal bar chart. Ratio is output size divided by input size; lower is better. It ranges from 1.5% for shot-2mp-webp to 69.3% for motion-12mp.Compression ratio by case0255075photo-12mp-jpgphoto-12mp-webpphoto-12mp-avifshot-2mp-pngshot-2mp-webpflat-1mp-pngalpha-4mp-aviflarge-24mp-jpgtiny-0.5mp-webpmotion-12mp8.8%6.5%2.4%43.2%1.5%51.7%4.4%8.6%9.3%69.3%output ÷ input (%)lower is better
Smallest: shot-2mp-webp at 1.5% of the original. Largest: motion-12mp at 69.3%.
Encode time per megapixelHorizontal bar chart of median encode time per megapixel, in the same case order as the ratio chart. It ranges from 51.2 ms/MP for large-24mp-jpg to 516.2 ms/MP for flat-1mp-png.Encode time per megapixel0200400600photo-12mp-jpgphoto-12mp-webpphoto-12mp-avifshot-2mp-pngshot-2mp-webpflat-1mp-pngalpha-4mp-aviflarge-24mp-jpgtiny-0.5mp-webpmotion-12mp56.483.1165.3296.273.1516.2387.951.287.5111.2ms per megapixel (median of 5 runs)same case order as the ratio chart
Fastest per megapixel: large-24mp-jpg at 51.2 ms/MP. Slowest: flat-1mp-png at 516.2 ms/MP.
CaseFormatMPInputOutputRatioMedianp95ms/MP
photo-12mp-jpgJPEG → JPEG124.92 MB444.4 KB8.8%677.0733.456.4
photo-12mp-webpJPEG → WebP124.92 MB325.2 KB6.5%997.51021.783.1
photo-12mp-avifJPEG → AVIF124.92 MB121.8 KB2.4%1983.22026.8165.3
shot-2mp-pngPNG → PNG2.071.36 MB600.6 KB43.2%613.1618.1296.2
shot-2mp-webpPNG → WebP2.071.36 MB21.0 KB1.5%151.3156.173.1
flat-1mp-pngPNG → PNG1118.1 KB61.0 KB51.7%516.2562.9516.2
alpha-4mp-avifPNG → AVIF48.26 MB370.1 KB4.4%1551.71626.3387.9
large-24mp-jpgJPEG → JPEG249.66 MB846.1 KB8.6%1228.11231.051.2
tiny-0.5mp-webpJPEG → WebP0.48219.6 KB20.4 KB9.3%42.043.487.5
motion-12mpMotionPhoto129.98 MB6.92 MB69.3%1334.01377.3111.2

What do the numbers say?

AVIF is 3.65× smaller than JPEG and 2.93× slower on the 12 MP photograph; WebP sits between them, and PNG is the lossless outlier at 296.2 ms/MP.

How much smaller is AVIF than JPEG?

About one third of JPEG's size: 2.4% against 8.8% on the 12 MP photograph, or 121.8 KB against 444.4 KB. WebP produced 325.2 KB. AVIF is 3.65× smaller than JPEG and 2.67× smaller than WebP. It is also the slowest: 1,983.2 ms median, 2.93× the JPEG time and 1.99× the WebP time. Per megapixel, 165.3 ms against 56.4 for JPEG and 83.1 for WebP.

That is the trade in one line: just over a quarter of the JPEG's bytes for three times the encode time. On a batch of hundreds of images the time is real; on a single hero image it may not matter.

How much smaller and slower is WebP than JPEG?

26.8% smaller and 1.47× slower: 325.2 KB against 444.4 KB, and 997.5 ms against 677.0 ms on the same 12 MP source. Against AVIF it is 2.67× larger and 1.99× faster. It is not the best at either end; it is the middle in both directions.

How fast is JPEG?

677.0 ms for the 12 MP photograph (56.4 ms/MP) and 1,228.1 ms for the 24 MP one (51.2 ms/MP), the fastest of the three codecs here. The per-megapixel cost is roughly flat, which is what a fast block codec looks like. Its output is larger than both modern formats, but it is the format to beat on time, and on this machine nothing did.

Why is PNG the slowest per megapixel?

296.2 ms/MP on the 2.07 MP screenshot, 4.05× WebP's 73.1 ms/MP, because lossless optimisation must search filter and DEFLATE strategies for every pixel. The screenshot took 613.1 ms as PNG and 151.3 ms as WebP. The flat 1 MP PNG was worse per pixel: 516.2 ms, because the optimiser's fixed cost is spread over only one megapixel.

PNG is lossless. Every pixel is preserved, and oxipng spends its time searching filter and DEFLATE strategies for a smaller stream; the search is the point, and it is also the cost. A lossy codec can stop when the quality target is met. A lossless optimiser has to establish that there is nothing left to remove, and the number of combinations grows with the image.

The size difference is just as blunt: the screenshot was 600.6 KB as PNG and 21.0 KB as WebP. Those are not equivalent outputs — one is lossless and one is not — but they explain why PNG is the right choice for a diagram you will edit and the wrong one for a photograph you will ship.

Does a higher oxipng level always make PNG smaller?

No: level 3 was 9.6–13.6% smaller on the three screenshot fixtures but saved only four bytes on the 8.29 MP photographic PNG, at 8.2× the time. PNG level was measured separately with bench/png-levels.mjs, on the same fixtures, with one warm-up and five runs per level. On the three screenshot fixtures (2.07, 8.29 and 15.73 MP), level 3 took 7.8–8.5× as long as level 1 and produced files 9.6–13.6% smaller. On the 8.29 MP photographic PNG it produced four bytes fewer than level 1 — 15,474,918 against 15,474,922 — and took 8.2× as long. On the RGBA image it saved 2.0% and took 7.1× as long. The extra search buys almost nothing when the image is already photographic.

That is why the shipping worker now uses level 2 by default and drops to level 1 above 4 MP. At 16 MP, level 2 was 1.0% larger than level 1 and about twice as slow, so a higher level is not automatically a smaller file. The cost of the change is visible on flat, low-detail PNGs: the 1 MP flat fixture is 62,501 B at level 2 against 48,138 B at level 3 — 29.8% larger — but encodes in 437.9 ms instead of 2,028.3 ms, 4.6× faster. Screenshots pay less: at 2.07 MP the level-2 file is 7.2% larger than level 3 and 4.1× faster. The site chose the faster default and accepts the bytes.

Does MotionPhoto compress the video too?

No: only the still is re-encoded and the video is copied byte-for-byte, so the synthetic 12 MP file fell 30.7% while half of it was never touched. A MotionPhoto is one file: a JPEG still with an appended video, and on some handsets a gain map. The shipping path decodes and re-encodes the still, then rebuilds the container around it. It is not decoded, not re-encoded, and not offered as a target. The gain map, when present, is also copied, and its absolute offset in the MPF is repointed so HDR display keeps working.

That means the whole-file ratio is dominated by how much of the file the video is. In the synthetic 12 MP fixture, the still is 5,156,418 B of the 10,463,084 B input (49.3%) and the video is 5,306,666 B (50.7%); there is no gain map. The still came out at 1,944,569 B, 62.3% smaller, and the whole file went from 9.98 MB to 6.92 MB — 30.7% smaller. The still's own saving is large; the file's saving is limited by the half of it that was never touched.

The real-sample check uses a OnePlus 13 MotionPhoto: still 7,140,978 B (44.7%), video 7,968,544 B (49.9%), gain map 848,042 B (5.3%), total 15,957,564 B. After the still-only path: still 1,862,140 B (73.9% smaller), video and gain map byte-identical, total 10,712,466 B — 15.96 MB to 10.71 MB, 32.9% smaller. The rebuilt file was recognised as a MotionPhoto and played on the handset. That is the gain-map path verified on real hardware, not inferred from the synthetic fixture.

What are the limitations?

Six caveats, starting with synthetic fixtures and a single machine.

How can these numbers be reproduced?

Five commands, from npm ci to npm run bench.

npm ci
npm run build
npm run bench:fixtures
npm run bench:build
npm run bench

The run writes a JSON file to bench/results/ and prints the table above to stdout. The PNG level comparison is npm run bench:png-levels. Fixtures are checked against bench/fixtures/manifest.json before the browser loads them; a changed file is a hard failure, not a silent new baseline.

Frequently asked questions

Which format compresses smallest?

AVIF: on the 12 MP photograph it produced 121.8 KB, 2.4% of the input, against 6.5% for WebP and 8.8% for JPEG.

Which format encodes fastest?

JPEG: 677.0 ms for the 12 MP photograph, against 997.5 ms for WebP and 1,983.2 ms for AVIF.

Why is PNG slower per megapixel?

It is lossless: oxipng searches filter and DEFLATE strategies for every pixel. The 2.07 MP screenshot took 296.2 ms/MP as PNG and 73.1 ms/MP as WebP, 4.05× less.

Does MotionPhoto compress the video?

No. The video is copied byte-for-byte and only the still is re-encoded; in the synthetic 12 MP fixture the still fell 62.3% and the whole file 30.7%, from 9.98 MB to 6.92 MB.

What does level 2 versus level 3 do to PNG output size?

Level 3 is smaller but much slower. The 1 MP flat PNG is 62,501 B at level 2 against 48,138 B at level 3, 29.8% larger, but encodes in 437.9 ms instead of 2,028.3 ms.

How many runs is each case measured over?

One warm-up and five measured runs; the table reports the median of those five, and p95 is the slowest sample.

About this article

Published by tinyimg. The harness, the fixture generator and the result files are in the repository, and the raw JSON for this run is written to bench/results/. The byline is the project, not a person: the claim here is not authority, it is that the method is written down and the numbers can be reproduced.

← Back to tinyimg

en · zh-CN · ja · ko · es · fr · ru · ar