How I built a 3.6 KB image compressor that never returns a bigger file
Every app that accepts photos runs into the same thing sooner or later. A user picks a 6 MB iPhone photo, the upload is slow, the server rejects it, or you pay to store pixels nobody will ever see. The usual fix is to c
Every app that accepts photos runs into the same thing sooner or later. A user picks a 6 MB iPhone photo, the upload is slow, the server rejects it, or you pay to store pixels nobody will ever see.
The usual fix is to compress in the browser before upload. I tried the popular libraries and kept running into three problems:
- Size. 15–50 KB gzipped, for something that sits on every upload form.
- HEIC. iPhones shoot HEIC by default, and only Safari can decode it natively.
- Sometimes the "compressed" file is bigger. If you re-encode an already-optimized image at quality 0.8, the result can be larger than what you started with.
So I built Compresso. Here's how it handles each of those.
1. Stay small: only the Canvas API
The core uses only what the browser already has: decode into an Image or ImageBitmap, draw it to a canvas at the target size, and encode with canvas.toBlob (or OffscreenCanvas.convertToBlob in a worker). There's no WASM and no bundled codecs. The main entry is 3.6 KB gzipped, and size-limit enforces that in CI, so it can't creep up.
format: 'auto' checks once what the browser can encode and picks AVIF → WebP → JPEG.
import { compress } from 'compresso.js';
const result = await compress(file, { quality: 0.8, maxWidth: 1920, format: 'auto' });
console.log(`${result.savings}% smaller`); // e.g. "87.3% smaller"
upload(result.file);
2. HEIC everywhere, without paying for it
Safari and iOS decode HEIC natively. Everywhere else, Compresso dynamically import()s a WASM decoder (heic-to), but only the first time a HEIC file actually shows up. Users who never pick a HEIC file never download it.
Before an untyped, unnamed blob reaches the decoder, Compresso checks its ftyp magic bytes. That way arbitrary data can't get there by accident.
3. The never-bigger guarantee
This is the part I'm proudest of. For lossy formats, the output size is capped at the smaller of your maxSizeMB and the original file's size:
- Encode at the requested quality. If it fits, you're done.
- If not, binary-search the quality for the highest value that fits under the cap.
- Sometimes no quality fits. For example, WebKit can only encode JPEG, but the source is a tiny AVIF. In that case Compresso returns the source file itself, honestly labeled with its real format.
A regression test pins this behavior down, so the promise can't quietly break.
4. Batches off the main thread
1.0 adds compresso.js/pool, a Web Worker pool for compressing many files at once without freezing the UI:
import { createPool } from 'compresso.js/pool';
const pool = createPool();
const results = await pool.compressMany(files, { format: 'auto' });
// Shaped like Promise.allSettled: one bad file never fails the batch.
Workers in the wild do odd things. iOS suspends background tabs, and a native decoder can crash. The pool notices a worker that crashed or stopped answering and replaces it. Where Workers or OffscreenCanvas aren't available, or a CSP blocks them, createPool() returns a main-thread version with exactly the same API, so you never branch on the environment.
5. Safe by default
A crafted file can claim enormous dimensions in a few bytes. Compresso rejects inputs above 100 megapixels by default (maxInputPixels). It checks the file header before decoding, then checks again after.
Try it
- Live demo (nothing is uploaded): https://compresso.izaias.xyz/compresso
npm install compresso.js- Source, MIT: https://github.com/iziuqo/compresso
It also powers the Compresso iPhone app. Feedback and issues are very welcome, and there are a few good first issues if you'd like to contribute.
This post was written with the help of an AI assistant.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.