DEV3 Technologies home

Canvas image compression to a target size

Downscale and re-encode an image in the browser, lowering JPEG or WebP quality until it fits a byte budget.

React / Next.jsUtilitiescanvasimagecompressiontypescript

Code

export type CompressOptions = {
  maxSide?: number; // longest edge in px
  maxBytes?: number; // optional size budget
  type?: 'image/jpeg' | 'image/webp';
  quality?: number; // starting quality 0..1
  minQuality?: number;
};

const encode = (canvas: HTMLCanvasElement, type: string, quality: number) =>
  new Promise<Blob>((resolve, reject) =>
    canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('Encoding failed'))), type, quality),
  );

export async function compressImage(file: Blob, opts: CompressOptions = {}): Promise<Blob> {
  const { maxSide = 2000, maxBytes, type = 'image/webp', quality = 0.85, minQuality = 0.3 } = opts;

  const bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' });
  const scale = Math.min(1, maxSide / Math.max(bitmap.width, bitmap.height));
  const canvas = document.createElement('canvas');
  canvas.width = Math.max(1, Math.round(bitmap.width * scale));
  canvas.height = Math.max(1, Math.round(bitmap.height * scale));

  const ctx = canvas.getContext('2d');
  if (!ctx) throw new Error('Canvas not supported');
  ctx.fillStyle = '#fff'; // avoid black backgrounds when transparency meets JPEG
  ctx.fillRect(0, 0, canvas.width, canvas.height);
  ctx.imageSmoothingQuality = 'high';
  ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
  bitmap.close();

  let best = await encode(canvas, type, quality);
  if (!maxBytes || best.size <= maxBytes) return best;

  // Binary search for the highest quality that still fits the budget.
  let lo = minQuality;
  let hi = quality;
  for (let i = 0; i < 6; i++) {
    const mid = (lo + hi) / 2;
    const blob = await encode(canvas, type, mid);
    if (blob.size <= maxBytes) {
      best = blob;
      lo = mid;
    } else {
      hi = mid;
      if (i === 0) best = blob.size < best.size ? blob : best; // keep the smallest seen
    }
  }
  return best;
}

How it works

What it solves

Uploading a 6 MB phone photo to produce a 200 KB avatar wastes bandwidth and server time. Doing the resize and re-encode in the browser means only the small result travels, and no image library is needed because the canvas can already encode JPEG and WebP.

How it works

createImageBitmap decodes the file off the main thread and applies EXIF orientation. The function computes a scale factor so neither side exceeds maxSide, draws the bitmap to a canvas of that size, then calls toBlob. If a maxBytes budget is given, it runs a binary search over quality between a floor and the starting value: each probe encodes once, and the search keeps the highest quality that still fits. Binary search needs about six encodes instead of dozens from a linear loop.

PNG ignores the quality argument, so the code switches lossy output to WebP unless the caller asks for JPEG. A white fill is painted first so transparent pixels do not turn black when converting to JPEG.

Adapting it

  • Use OffscreenCanvas and convertToBlob inside a worker for large batches.
  • Return the compression ratio by dividing blob.size by file.size in the caller.
  • Lower minQuality for aggressive thumbnails.

Pitfalls

  • If even the minimum quality exceeds the budget, the smallest result is returned; check blob.size afterwards.
  • Safari older than 17 cannot encode WebP; fall back to image/jpeg there.
  • Always call bitmap.close() to release memory.

How to use it

  1. Save as lib/compressImage.ts; it only uses browser APIs.
  2. Call it from a client component: const out = await compressImage(file, { maxSide: 1600, maxBytes: 300_000 }).
  3. Create a preview with URL.createObjectURL(out) and revoke it when done.
  4. Upload out using FormData.

Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.

Related snippets