Client-Side Image Compression: What Happens in the Browser
The real pipeline behind Canvas, OffscreenCanvas and WebCodecs, which formats a browser will and will not encode, and when you still need a server.
Drop a photo into a browser-based compressor and, in the common case, nothing leaves your machine. The page reads the file, decodes it, draws it, encodes it again, and hands back a Blob. That is the entire trick.
The structural part is worth more than any privacy policy. For a scan of a passport or a product shot still under embargo, "the bytes never left this laptop" is something you can check in DevTools in about ten seconds. "We delete uploads after 24 hours" is something you can only believe.
What follows is what the browser is actually doing, which formats it will refuse to produce, and the point at which you should stop and put a server in the loop.
Decode, draw, re-encode
Canvas or WebCodecs, the shape is the same:
- Read the file. The File API gives JavaScript a byte stream with no network involved.
- Decode. The compressed image becomes raw pixels, RGBA, four bytes each.
- Resize, if you are going to. This is the only stage where it can happen.
- Re-encode. Write the pixels back out at a target quality.
- Hand back a Blob. The page can offer it for download or show it in a preview.
Step two is the one that hurts. A 5472 by 3648 photo out of a mid-range camera is around 20 megapixels, so decoded it occupies roughly 80 MB of memory, no matter that the JPEG on disk was 5 MB. The compression ratio you were admiring exists only on disk.
Canvas, the workhorse
The canvas path works in every browser released in the last decade and is about eight lines:
async function compressJpeg(file, quality = 0.8) {
const bitmap = await createImageBitmap(file);
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
canvas.getContext('2d').drawImage(bitmap, 0, 0);
const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality });
bitmap.close();
return blob;
}
quality runs from 0 to 1 and drives the encoder's quantization tables for JPEG and WebP. For anything lossless it is simply ignored1, which is the first thing people get wrong about PNG.
OffscreenCanvas lets all of this run in a Web Worker, so a 40 MB image does not freeze the interface. Safari was the last engine to ship it, in 16.42, and 2D is the part that landed.
The formats it will not encode, and how quietly it refuses
Here is the behaviour that costs people an afternoon.
If you pass a MIME type the browser cannot encode, toBlob does not throw and does not return null. The specified fallback is to encode PNG instead1. You get a Blob, your success handler runs, your download works, and the file is a PNG wearing the name you gave it.
I measured that in headless Chromium 147 and Firefox 148 on 19 August 2026, driving both through Playwright 1.59.1. Two lines will reproduce it in whatever you are running right now:
const blob = await new Promise((r) => canvas.toBlob(r, 'image/avif', 0.8));
console.log(blob.type); // "image/png" in Chromium 147 and Firefox 148
For a photograph this is worse than an error, because a PNG of a decoded photo is routinely larger than the JPEG you fed in. The compressor reports success and the file grows.
| Format | Canvas can encode it | Notes |
|---|---|---|
| PNG | Yes | Lossless, so quality does nothing |
| JPEG | Yes | Native codec, hardware-accelerated on most devices |
| WebP | Yes | 25 to 34 percent smaller than JPEG at the same SSIM4 |
| AVIF | No, returns PNG | Needs WebCodecs plus your own muxing, or a WASM encoder |
| JPEG XL | No, returns PNG | Same |
So the pragmatic default for a browser compressor in 2026 is WebP at quality: 0.85. It is the best format the platform will actually hand you.
WebCodecs has no image encoder
This is the part most write-ups get backwards, including the earlier version of this article. WebCodecs is not a faster route to AVIF stills. It is a video API.
ImageDecoder exists, and it is genuinely useful for pulling frames out of an animated image. There is no ImageEncoder. I checked for one in both engines above and it is undefined in each.
What you can do is encode a single AV1 keyframe with VideoEncoder, which both Chromium and Firefox report as supported for av01.0.04M.08, and then assemble an AVIF container around the chunk yourself. That is a real technique, and it is also a container-format project rather than a compression setting. VideoEncoder is restricted to secure contexts3, so it will be undefined on a page served over plain HTTP, which is a confusing way to discover the requirement. Safari has had the video half of WebCodecs since 16.42.
If you want AVIF in a browser without writing a muxer, ship a WebAssembly encoder. Squoosh took exactly this route, bundling codec builds to get encoders the platform does not expose6. It works. The bundles are megabytes, and your users pay that on first load.
PNG is a different problem entirely
There is no quality knob because nothing is being thrown away. Size comes from the deflate level and, far more, from the content: flat colour compresses beautifully, photographic grain barely compresses at all.
Three things actually shrink a PNG.
Reduce the colour depth. An image using 256 colours belongs in an 8-bit palette, not 24-bit truecolour. Doing this in a browser means a WebAssembly build of something like pngquant, because canvas will not do it for you.
Change format. A photograph that arrived as a PNG is in the wrong container, and WebP at 0.85 will be dramatically smaller at similar visual quality. Our Image Format Converter covers that case.
Resize. Blunt, and more effective than either of the above.
Memory, at the point where it breaks
One image at a time is fine on any machine made this decade. Batches are where tabs die.
Thirty holiday photos at 20 megapixels each is 2.4 GB of decoded pixels if you kick off all thirty decodes at once, and mobile Safari will kill the tab well before that. The fix is unglamorous: process serially, and call close() on each ImageBitmap before starting the next, which disposes of the graphical resources behind it5. Skipping that call is the single most common cause of out-of-memory crashes in browser-side image tools.
Two in flight is a reasonable ceiling if you want to keep more than one core busy.
Where a server still wins
Production batch pipelines. Running a marketing team's asset pipeline through a browser tab works right up until someone closes the tab. libvips on a server is faster, more configurable, and does not depend on a human being present.
AVIF and JPEG XL at any volume. Covered above: the platform will not encode either, and the WASM route costs both bundle size and encode time.
Encoder-specific tuning. Chroma subsampling, mozjpeg quantization tables, palette quantisation. All of it needs a real encoder binary.
Colour-managed work. A canvas has its own colour space, so a wide-gamut ICC profile embedded by the camera does not survive the round trip. For print or medical imaging that shift is not acceptable, and the compression belongs somewhere that preserves the profile.
Checking the privacy claim rather than believing it
When compression happens on a canvas, the pixel data has no route to a server. That is a property of the code path, not a promise, and it holds for anything sensitive you might drop into one of these tools: a photographed document, a screenshot of a private thread, mockups under NDA.
You can verify it in under a minute. Open DevTools, go to the Network tab, compress an image, and watch for a request carrying the file. With an upload-based tool you will see it immediately. With a canvas-based one there is nothing to see. That test generalises well past image tools, which is most of what the online privacy guide is about.
Which API to reach for
- Resize and output JPEG or WebP:
createImageBitmapplusOffscreenCanvas.convertToBlob, in a worker. Fast, universal, boring in the good way. - AVIF or JPEG XL: a WASM encoder, or a server. Not canvas.
- Palette-reducing a PNG: a WASM build of pngquant. Canvas cannot.
- Keeping EXIF or ICC data: read it out with a library such as
piexifjsbefore encoding and re-inject afterwards, because canvas keeps none of it.
To see the quality tradeoff without writing any of this, our Image Compressor shows before and after sizes as you move the quality slider, and the Image Resizer handles the dimension half. Both run entirely in the tab.
The browser is a capable image runtime with two sharp edges: it stays quiet about formats it cannot encode, and it holds decoded pixels uncompressed. Design around those two and the rest is straightforward.
Sources
Every number in this article traces to a source below. Where a claim could not be sourced, it was cut rather than softened.
- Primary sourceMDN Web Docs
That an unsupported MIME type falls back to image/png rather than failing, and that the quality argument is ignored for formats without lossy compression.
- Primary sourceApple
That Safari 16.4 added OffscreenCanvas for 2D operations and the video portion of the WebCodecs API.
- Primary sourceMDN Web Docs
That VideoEncoder is available only in secure contexts and is exposed to dedicated workers.
- Primary sourceGoogle
The finding that WebP files are 25 to 34 percent smaller than JPEG at the same SSIM index, measured across JPEG quality levels 50, 75 and 95.
- Primary sourceMDN Web Docs
That close() disposes of all graphical resources associated with an ImageBitmap, which is what releases the decoded pixels.
- Primary sourceGitHub
That a browser-based compressor can ship WebAssembly builds of image codecs to get encoders the platform does not provide.
Topics
- Image Compression
- Privacy
- Browser Apis
- Performance
Tools mentioned in this article
- Image Compressor - Compress images by adjusting quality to reduce file size without losing visual clarity.
- Image Format Converter - Convert images between PNG, JPEG, and WebP formats in one click.
- Image Resizer - Resize images by pixels or percentage with aspect ratio lock.
Get new tools by email
New tools and the occasional deep-dive, about once a month. No spam, no sharing your address, unsubscribe in one click.