Last week a teammate asked me why our little browser tool, QuickShrink, could take a 4.2 MB phone photo and hand back a 380 KB file that looked identical — all without uploading a single byte to a server. He assumed there was some clever backend doing the heavy lifting. There isn’t. It’s about 40 lines of JavaScript and a browser API that has shipped in every major engine since roughly 2013. I want to walk through exactly what happens between the file picker and the download link, because once you understand it, you stop trusting upload-based compressors that ship your private photos to someone else’s box.
The whole pipeline is three steps
Browser image compression with the Canvas API comes down to: decode the image into pixels, paint those pixels onto a canvas, then re-encode the canvas at a chosen quality. That’s it. Here’s the core of what QuickShrink runs, stripped to the essentials:
async function compress(file, quality = 0.8) {
// 1. Decode: turn the file bytes into a bitmap
const bitmap = await createImageBitmap(file);
// 2. Paint: draw the bitmap onto a canvas
const canvas = document.createElement('canvas');
canvas.width = bitmap.width;
canvas.height = bitmap.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(bitmap, 0, 0);
// 3. Re-encode: read pixels back out as a compressed blob
return new Promise((resolve) => {
canvas.toBlob(resolve, 'image/jpeg', quality);
});
}
The magic is in step three. When you call canvas.toBlob(callback, 'image/jpeg', 0.8), the browser runs its native JPEG encoder over the raw RGBA pixels sitting in the canvas buffer. That 0.8 is the quality factor, a number between 0 and 1, and it maps to the same quantization-table scaling that libjpeg uses under the hood. Lower the number, the encoder throws away more high-frequency detail, and the file shrinks.
Why the file gets smaller without looking worse
JPEG compression is lossy and it exploits a fact about human vision: we’re bad at noticing small changes in color and fine detail, but good at noticing changes in brightness and edges. The encoder splits the image into 8×8 pixel blocks, runs a discrete cosine transform on each, and then quantizes the result — rounding off the coefficients that represent detail your eye won’t miss.
The quality factor controls how aggressive that rounding is. At 0.92 you’re barely touching anything. At 0.8 you’ve cut the file roughly in half and almost nobody can tell in a blind test. Drop to 0.6 and you’ll start seeing ringing around hard edges — text on a screenshot is where it shows up first. I settled on 0.8 as the default after eyeballing a few hundred photos. It’s the knee of the curve where you get most of the size savings before quality visibly drops.
Real numbers from a real photo set
I ran a batch of 20 photos straight off a Pixel 8 — landscapes, indoor shots, a couple of screenshots — through the canvas pipeline at different quality settings. Average original size was 3.8 MB per file. Here’s what came out:
- quality 0.92 — avg 1.9 MB, about 50% reduction, visually lossless
- quality 0.80 — avg 720 KB, about 81% reduction, no visible loss on normal viewing
- quality 0.60 — avg 410 KB, about 89% reduction, slight softening on text edges
- quality 0.40 — avg 280 KB, about 93% reduction, obvious artifacts
The reason phone photos compress this well is that they start out barely compressed. Camera apps save at quality 0.95 or higher to avoid complaints, and they bake in fat EXIF blocks with GPS coordinates, lens data, and a full-size thumbnail. Re-encoding at 0.8 and dropping the metadata is where most of the savings come from. (If the metadata part interests you, I wrote a separate piece on how EXIF leaks your home address and how to strip it.)
The gotcha nobody warns you about: createImageBitmap vs Image
The old way to decode an image was to create an <img> element, set its src to an object URL, and wait for the onload event. It works, but it decodes on the main thread and blocks your UI while it runs. On a big panorama, that’s a visible freeze.
// Old way - blocks the main thread
const img = new Image();
img.onload = () => ctx.drawImage(img, 0, 0);
img.src = URL.createObjectURL(file);
createImageBitmap() is the better path. It decodes off the main thread, returns a promise, and gives you an ImageBitmap that draws to canvas faster because it’s already in a GPU-friendly format. On the 20-photo batch above, switching from the Image approach to createImageBitmap cut total processing time from 4.1 seconds to 1.6 seconds. If you build anything that compresses more than one file, use it.
One real gotcha: createImageBitmap ignores EXIF orientation by default. Photos shot in portrait can come out sideways. You fix it by passing { imageOrientation: 'from-image' } as the second argument, which most engines now honor:
const bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' });
WebP is where the real wins are
JPEG is the safe default, but if you don’t need to email the file to someone on a 2014 device, WebP beats it badly. Same canvas, same code, you just change the MIME type:
canvas.toBlob(resolve, 'image/webp', 0.8);
On my test set, WebP at quality 0.8 came out to an average of 480 KB versus JPEG’s 720 KB at the same setting — another third smaller for the same perceived quality. Every browser shipped in the last six years decodes WebP, so the compatibility argument is mostly dead unless you’re targeting ancient hardware. The one place I still reach for JPEG is when the recipient is going to drag the file into some old desktop app that chokes on WebP.
Why “browser-only” is the part that matters
Here’s the bit I care about most. Because every step — decode, paint, re-encode — runs inside createImageBitmap and canvas.toBlob, the image never leaves the tab. There’s no fetch, no upload, no server log with your file sitting in it. You can literally open the network tab in DevTools, compress a photo, and watch zero requests fire. Pull your ethernet cable and it still works.
That’s not true of most “free online image compressor” sites. They POST your file to a backend, compress it there, and hand back a URL. Which means a copy of your photo — with its original GPS metadata if they don’t strip it — lives on a machine you don’t control, for however long their retention policy says, or doesn’t say. For a meme, who cares. For a photo of a document, a whiteboard with company internals, or a picture taken inside your house, that’s a real leak. I’ve gotten paranoid enough about this that I treat every upload-based dev tool as a potential logging endpoint, which is the same reason I wrote about why you should stop pasting sensitive data into online dev tools.
Try it, or build your own
If you just want the result, QuickShrink is the tool — drag a photo in, pick a quality, download. No account, no upload, no tracking. If you want to build your own, the code above is the whole idea; wrap it in a drag-drop handler and a quality slider and you’re done in an afternoon.
The hardware angle matters too. Canvas re-encoding is CPU-bound and single-image-fast, but if you batch-process hundreds of RAW or high-res files, a machine with more cores and fast storage makes the difference between seconds and minutes. I do my bulk photo work on an SSD-backed box, and a good portable drive like the Samsung T7 Shield portable SSD is what I use to shuttle large photo libraries between machines without waiting on a slow USB stick. Full disclosure: that’s an Amazon affiliate link — it’s the drive I actually use.
The takeaway: browser image compression isn’t magic and it isn’t a backend. It’s a 13-year-old canvas API, a quality number between 0 and 1, and the choice to keep your pixels on your own machine. Once you know how the pipeline works, the upload-based tools start looking like a strictly worse deal.
Join https://t.me/alphasignal822 for free market intelligence.
๐ง Get weekly insights on security, trading, and tech. No spam, unsubscribe anytime.
Leave a Reply