Most "compress your image" sites still work the same way: you upload the file, a server does the work, you download the result. For a 4 MB photo that's two network trips and a queue, plus your file sitting on someone else's disk.
You don't need any of it. The browser has shipped everything required for this since about 2012.
The basic version
async function compress(file, quality = 0.8) {
const bitmap = await createImageBitmap(file)
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height)
canvas.getContext('2d').drawImage(bitmap, 0, 0)
return canvas.convertToBlob({ type: 'image/webp', quality })
}
That's the whole thing. createImageBitmap decodes off the main thread, OffscreenCanvas means you can run it in a Worker, and convertToBlob re-encodes at whatever quality you ask for. Ten lines, no dependencies, no server.
Where it gets annoying
Quality is not linear. Going from 0.9 to 0.8 on a JPEG typically saves 30 to 40% of the bytes with almost no visible difference. Going from 0.6 to 0.5 saves maybe 8% and starts putting rings around high contrast edges. If your UI exposes a quality slider, users will drag it to 0.3 and then complain about artifacts.
A target-size loop usually beats a quality slider:
async function compressToTarget(file, maxBytes) {
let lo = 0.4, hi = 0
return best
}
Six binary search steps gets you close enough and runs in well under a second for typical photos.
Canvas strips EXIF. Including orientation. Photos from phones come out rotated and users assume your tool broke them. You have to read the orientation tag before you draw and apply the transform yourself.
Canvas also strips the color profile. Wide gamut images from newer phones get re-tagged as sRGB and come out visibly duller. There's no clean fix in the Canvas path. If this matters you need a WASM encoder like mozjpeg or libwebp compiled with profile preservation.
Alpha channels. Converting a transparent PNG to JPEG gives you a black background unless you fill the canvas white first. Obvious in hindsight, easy to ship as a bug.
Big files kill the tab. A 50 MP image is roughly 200 MB as raw RGBA in memory. Do this on the main thread with three files at once and the tab dies. Worker plus a queue, not Promise.all.
The real argument for client-side
It's not performance, though it is faster. It's that the file never leaves the machine.
If someone is compressing a scanned passport to fit an upload limit, "processed in your browser" is a genuinely different product from "uploaded to our servers and deleted within 24 hours." One of those is a promise. The other is architecture.
A working example if you want to see the pattern in production rather than build it: compresspower.com does image compression, format conversion between JPG, PNG and WebP, and PDF merge and split, all in-browser with no upload step.
Worth opening devtools on the network tab while you use one of these. If you see your file going out over the wire, the privacy copy on the landing page is decorative.
Top comments (0)