DEV Community

Nina Hart
Nina Hart

Posted on

Client-side image compression: what actually gets smaller

"Compress this image" is four different requests wearing the same coat:

  • Make it fit an upload limit.
  • Make it load faster.
  • Make it look less like it was shot on a phone from 2009.
  • Make it stop being 12 MB.

Those need different answers, and most "compressor" tools apply all of them at once, then hand you back something that looks like it went through a washing machine.

Here's what each lever actually does, in a browser, with no server involved.

Lever one: dimensions (the big one)

Pixels cost more than bytes-per-pixel. Halve the width and height and you have a quarter of the pixels — and roughly a quarter of the file size, at identical quality settings.

For listing images this is usually not a trade-off at all. A marketplace that asks for 2000 × 2000 does not care that you uploaded 4000 × 4000, and neither does anyone browsing on a phone. Downscaling to the size that will actually be displayed is free quality.

The catch is that resizing has to be done well. drawImage() at a large reduction ratio skips pixels and produces aliasing; doing it in two steps, or letting the browser use a better filter, preserves edges.

async function downscale(blob, maxEdge) {
  const bmp = await createImageBitmap(blob, { imageOrientation: 'from-image' });
  const scale = Math.min(1, maxEdge / Math.max(bmp.width, bmp.height));

  const w = Math.round(bmp.width * scale);
  const h = Math.round(bmp.height * scale);

  const canvas = new OffscreenCanvas(w, h);
  const ctx = canvas.getContext('2d');
  ctx.imageSmoothingQuality = 'high';
  ctx.drawImage(bmp, 0, 0, w, h);

  return canvas.convertToBlob({ type: 'image/jpeg', quality: 0.9 });
}
Enter fullscreen mode Exit fullscreen mode

If the scale factor is smaller than about 0.5, do it in passes — halve, then halve again — rather than asking the browser to throw away three quarters of the pixels in one call.

Lever two: re-encoding quality

Every time you save a JPEG, the encoder quantises the DCT coefficients and throws information away. Lower quality means coarser quantisation: smaller file, more visible blocking in flat areas and more ringing around edges.

For photos that will be viewed at normal size, 0.85–0.92 is usually indistinguishable from the original. Below 0.7, gradients start to band and text in the image falls apart.

The important part: this is lossy on top of lossy. If the input is already a JPEG, re-encoding is a second generation of damage. Which is why you should keep the untouched original and only ever re-encode from it — never from a file you already compressed.

Canvas gives you the encoder directly:

const out = await canvas.convertToBlob({ type: 'image/jpeg', quality: 0.85 });
Enter fullscreen mode Exit fullscreen mode

Lever three: the format

Same pixels, different encoder:

// Chrome, Edge, Firefox: usually 25–35% smaller than JPEG at equal quality.
const webp = await canvas.convertToBlob({ type: 'image/webp', quality: 0.85 });
Enter fullscreen mode Exit fullscreen mode

WebP is the pragmatic default for the web in 2026 and support is effectively universal. AVIF compresses better still, but canvas can't encode it — you'd need a WASM encoder, and the CPU cost stops being worth it for a single photo.

Note that format choice and compatibility pull in opposite directions. If the destination is a marketplace upload form that only accepts JPEG or PNG, WebP is not an option no matter how small it is. That's a common reason these tools keep a format switch rather than always choosing for you.

Hitting a target file size

"Under 100 KB" is a constraint, not a quality setting. The straightforward approach is a binary search over quality, re-encoding until you're under the limit:

async function encodeUnder(canvas, targetBytes) {
  let lo = 0.3, hi = 0.95, best = null;

  for (let i = 0; i < 7; i++) {
    const q = (lo + hi) / 2;
    const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality: q });

    if (blob.size <= targetBytes) {
      best = blob;
      lo = q;          // try to keep more quality
    } else {
      hi = q;          // too big, encode harder
    }
  }
  return best ?? await canvas.convertToBlob({ type: 'image/jpeg', quality: 0.3 });
}
Enter fullscreen mode Exit fullscreen mode

Seven iterations resolves quality to about 0.005, which is finer than anyone can see. But note what this does not do: if the image is 4000 × 4000, no quality setting will get it under 100 KB without turning it to mush. The search should run after downscaling, not instead of it.

A good order of operations:

  1. Downscale to the size you actually need.
  2. If still too big, search over quality.
  3. If still too big, the target is unrealistic for this image — tell the user instead of silently destroying it.

That last step is the one most tools skip, and it's why you sometimes get back a 100 KB file that looks like a watercolour.

What you lose, and what you gain

Lost: a generation of quality, and all the metadata. Canvas re-encoding strips EXIF, which takes the GPS coordinates with it. For a product photo that's a feature. For an archive, it's data loss.

Gained: orientation baked in correctly, colour profile normalised, and a file that fits where you're uploading it.

Real numbers from a run I did while building this: a 1440 × 960 JPEG at 194 KB, resized to 2000 × 2000 with padding, came out at 286 KB — bigger, because the frame got larger, but correctly sized for the marketplace. Compressing the same file down instead, at reduced quality, got it under 100 KB with no visible change at screen size.

Why do it in the tab

The alternative is uploading a photo to a stranger's server so it can do arithmetic your laptop is already capable of. For one image that's a mild inconvenience. For a seller working through a folder of unreleased products, it's a data-handling decision made by accident.

Canvas, createImageBitmap, and convertToBlob are all you need, and they're already there.


This is the approach behind ToolCaravan — small browser tools for product photos that run entirely on your device. No account, no upload, no watermark.

Top comments (0)