Resizing Images in the Browser with Canvas

Decode the file with createImageBitmap(file, { imageOrientation: "from-image" }), transfer the bitmap into a worker, halve it through OffscreenCanvas until it is within 2× of the target box, draw the final step, and encode with convertToBlob({ type: "image/webp", quality: 0.82 }) — a 4.3 MB phone photo leaves the browser at roughly 200 KB with no main-thread jank.

A 12-megapixel photo straight off a phone is 4 MB of JPEG that your product will display at 800 px wide. Uploading the original burns the user’s data allowance, the upload takes eight seconds on a weak connection, and your server pays to re-encode it anyway. This article sits inside client-side media preprocessing, part of frontend UX, chunking and progress tracking, because shrinking the payload before the first byte goes out is the cheapest upload optimisation there is — and the one most likely to be implemented wrong.

When to use this approach

  • User-generated photos that will be displayed, not archived. Avatars, listing photos, receipts, chat attachments. If the product ever needs the original pixels — print, forensics, RAW workflows, legal evidence — resize for the preview and upload the original too.
  • You want the CPU cost on the client, not the server. A stepped canvas resize of a 12 MP image costs 60–110 ms of worker time on a mid-range Android. The same work in a server-side derivative pipeline costs you compute per upload, forever.
  • Not when quality is contractual. Canvas resampling is a box-ish filter, not Lanczos. Sharp on the server produces visibly better 1:8 downscales. Client resizing is the right call for bandwidth and latency, the wrong call when the output is the deliverable.

Prerequisites

  1. Chromium 69+, Firefox 105+ or Safari 16.4+ for OffscreenCanvas with a 2d context in a worker. Safari 16.3 and earlier have the constructor but no 2D context.
  2. A bundler that emits module workers — new Worker(new URL("./resize.worker.ts", import.meta.url), { type: "module" }) works out of the box in Vite 4+, esbuild and webpack 5.
  3. tsconfig.json with "lib": ["DOM", "DOM.Iterable", "ES2022", "WebWorker"].
  4. A File from an <input type="file">, a drop, or a clipboard paste. Any of the three gives you a Blob, which is all createImageBitmap needs.

Implementation

Two files. The worker owns every pixel; the main thread only decodes and posts.

// resize.worker.ts — module worker
export interface ResizeJob {
  id: number;
  bitmap: ImageBitmap;
  longestEdge: number;                 // target box, e.g. 1600
  type: "image/webp" | "image/jpeg";
  quality: number;                     // 0.82 webp / 0.85 jpeg
}
export interface ResizeResult {
  id: number;
  blob: Blob;
  width: number;
  height: number;
}

/** Fit into a square box without upscaling; scale never exceeds 1. */
function fitBox(w: number, h: number, longest: number): [number, number] {
  const scale = Math.min(1, longest / Math.max(w, h));
  return [Math.max(1, Math.round(w * scale)), Math.max(1, Math.round(h * scale))];
}

function render(bitmap: ImageBitmap, tw: number, th: number, opaque: boolean): OffscreenCanvas {
  let src: ImageBitmap | OffscreenCanvas = bitmap;
  let cw = bitmap.width;
  let ch = bitmap.height;

  // Halve until one more halving would undershoot the target: keeps every
  // drawImage at a ratio of 2 or less, where the resampler still averages.
  while (cw > tw * 2 && ch > th * 2) {
    cw = Math.max(tw, cw >> 1);
    ch = Math.max(th, ch >> 1);
    const step = new OffscreenCanvas(cw, ch);
    const sctx = step.getContext("2d", { alpha: true })!;
    sctx.imageSmoothingEnabled = true;
    sctx.imageSmoothingQuality = "high";
    sctx.drawImage(src, 0, 0, cw, ch);
    if (src !== bitmap) (src as OffscreenCanvas).width = 0; // release the old backing store
    src = step;
  }

  const out = new OffscreenCanvas(tw, th);
  const ctx = out.getContext("2d", { alpha: !opaque })!;
  ctx.imageSmoothingEnabled = true;
  ctx.imageSmoothingQuality = "high";
  if (opaque) {
    // JPEG has no alpha: composite onto white or transparent pixels turn black.
    ctx.fillStyle = "#ffffff";
    ctx.fillRect(0, 0, tw, th);
  }
  ctx.drawImage(src, 0, 0, tw, th);
  if (src !== bitmap) (src as OffscreenCanvas).width = 0;
  return out;
}

self.onmessage = async (event: MessageEvent<ResizeJob>) => {
  const { id, bitmap, longestEdge, type, quality } = event.data;
  try {
    const [tw, th] = fitBox(bitmap.width, bitmap.height, longestEdge);
    const canvas = render(bitmap, tw, th, type === "image/jpeg");
    const blob = await canvas.convertToBlob({ type, quality });
    // convertToBlob silently falls back to image/png for an unsupported type.
    if (blob.type !== type) throw new Error(`encoder refused ${type}, got ${blob.type}`);
    const result: ResizeResult = { id, blob, width: tw, height: th };
    (self as unknown as Worker).postMessage(result);
  } catch (err) {
    (self as unknown as Worker).postMessage({ id, error: (err as Error).message });
  } finally {
    bitmap.close();                    // frees width*height*4 bytes immediately
  }
};
// resize-client.ts — main thread
import type { ResizeJob, ResizeResult } from "./resize.worker";

const worker = new Worker(new URL("./resize.worker.ts", import.meta.url), { type: "module" });
const pending = new Map<number, (r: ResizeResult) => void>();
const failed = new Map<number, (e: Error) => void>();
let nextId = 0;

worker.onmessage = (event: MessageEvent<ResizeResult & { error?: string }>) => {
  const { id, error } = event.data;
  if (error) failed.get(id)?.(new Error(error));
  else pending.get(id)?.(event.data);
  pending.delete(id);
  failed.delete(id);
};

let webpOk: Promise<boolean> | null = null;
async function supportsWebP(): Promise<boolean> {
  webpOk ??= (async () => {
    const probe = new OffscreenCanvas(1, 1);
    probe.getContext("2d");
    const blob = await probe.convertToBlob({ type: "image/webp" });
    return blob.type === "image/webp";   // false => the engine gave us PNG
  })();
  return webpOk;
}

export async function resizeImage(file: File, longestEdge = 1600): Promise<ResizeResult> {
  // from-image applies the EXIF orientation tag; without it, portrait phone
  // photos come out on their side because the pixels really are landscape.
  const bitmap = await createImageBitmap(file, {
    imageOrientation: "from-image",
    premultiplyAlpha: "default",
    colorSpaceConversion: "default",
  });

  const webp = await supportsWebP();
  const job: ResizeJob = {
    id: nextId++,
    bitmap,
    longestEdge,
    type: webp ? "image/webp" : "image/jpeg",
    quality: webp ? 0.82 : 0.85,
  };
  const done = new Promise<ResizeResult>((resolve, reject) => {
    pending.set(job.id, resolve);
    failed.set(job.id, reject);
  });
  worker.postMessage(job, [bitmap]);    // transfer, don't clone
  return done;
}

Line-by-line on the critical parameters

  • imageOrientation: "from-image". Pass it explicitly. The spec default flipped from none to from-image in 2021 and engines adopted it at different times, so an unqualified createImageBitmap(file) gives you a sideways bitmap on some browsers and an upright one on others. "from-image" is also the only way to bake rotation into pixels — the resized output has no EXIF block at all, which is a privacy win covered on its own page in this topic.
  • worker.postMessage(job, [bitmap]). The second argument is the transfer list. Without it the structured clone algorithm copies 48 MB of pixels between heaps; with it the ImageBitmap is detached on the sender and re-homed in the worker for free. After the call, bitmap.width on the main thread is 0.
  • cw >> 1, not cw * 0.5. Integer halving avoids fractional canvas sizes, which the browser rounds anyway. Math.max(tw, cw >> 1) stops the ladder from undershooting when the target is not a power-of-two fraction.
  • imageSmoothingQuality = "high". Chromium switches from bilinear to a better kernel; Firefox and WebKit accept it and mostly ignore it. It costs nothing and helps on the one engine that honours it.
  • (src as OffscreenCanvas).width = 0. Setting any dimension resets the canvas and drops its backing store. Without this, three intermediate canvases stay alive until the worker’s next GC, which is exactly when you are about to allocate the next photo’s bitmap.
  • alpha: !opaque. An opaque context skips the alpha channel in compositing. For JPEG output you also need the explicit white fillRect, or every transparent PNG pixel encodes as black.
  • blob.type !== type. convertToBlob does not throw for an unsupported MIME type — it returns a PNG. A PNG of a photograph is typically 4× larger than the JPEG you asked for, so this check is the difference between a 200 KB and an 800 KB upload.
Resize pipeline split across the main thread and a worker A file is decoded to an ImageBitmap on the main thread, transferred to a worker where two OffscreenCanvas steps and convertToBlob run, and a small Blob is posted back. Where each stage runs main thread worker File 4032×3024 4.30 MB JPEG createImageBitmap from-image · 48.8 MB Blob 198 KB image/webp q0.82 step 1 · 2016×1512 OffscreenCanvas step 2 · 1600×1200 final draw convertToBlob encode off-thread postMessage(job, [bitmap]) Blob returned Transferring the bitmap moves 48.8 MB by reference; cloning it would copy every byte.
Only the decode happens on the main thread, and even that yields — the worker holds the pixel buffers for the whole ladder.

Why the two-step downscale matters

drawImage with a destination smaller than half the source is where canvas resizing gets its bad reputation. The GPU-backed path in every major engine samples a small fixed neighbourhood per output pixel — effectively a bilinear tap. Ask it for a 4032 → 1600 reduction in one call and each output pixel is built from roughly four of the 6.4 source pixels it should represent; the rest never contribute. High-frequency detail — text in a screenshot, roof tiles, fabric weave — turns into aliasing and moiré.

Halving first fixes it because a 2:1 reduction is exactly the ratio a bilinear tap covers. Two calls at 2× and 1.26× read every source pixel at least once. The cost is one extra allocation and 3–6 ms; the benefit is output that survives being looked at.

Single-call downscale versus a halving ladder A single drawImage from 4032 to 1600 pixels discards most source rows, while a ladder through 2016 pixels keeps every drawImage at a ratio of two or less. One call above 2× throws pixels away single call halving ladder 4032 × 3024 1600 × 1200 aliased edges, moiré on text ratio 2.52 — beyond the sampling tap ratio 2.52× 4032 × 3024 2016 × 1512 1600 × 1200 clean edges +4 ms, +12 MB 2.00× 1.26× Keep every drawImage at 2× or less and each source pixel contributes to the output at least once.
The ladder costs one intermediate canvas; the single call costs detail you cannot get back after the upload.

Choosing the target box and the format

Resize into a box on the longest edge, never a fixed width and height. fitBox scales by longest / Math.max(w, h), so a portrait 3024×4032 and a landscape 4032×3024 both come out with a 1600 px long side and their aspect ratio intact. Passing explicit width and height to drawImage is what produces squashed avatars.

Pick the box from the largest rendered size times the highest device pixel ratio you support, rounded up to something tidy:

Use Rendered at Target box Format Typical output
Avatar 96 px @3x 320 WebP q0.80 12–20 KB
Chat attachment 400 px @3x 1280 WebP q0.80 90–140 KB
Listing photo 800 px @2x 1600 WebP q0.82 160–240 KB
Full-bleed hero 1440 px @2x 2560 WebP q0.85 380–600 KB

Math.min(1, …) in fitBox is load-bearing: without it, a 600 px source uploaded against a 1600 box gets upscaled to 1600, producing a blurrier image that is three times larger than the original. Never grow an image on the client.

For format, WebP at q0.82 lands 25–35% under JPEG q0.85 at matched visual quality, and every engine that supports OffscreenCanvas also encodes WebP — but feature-detect anyway, because convertToBlob fails by substitution rather than by throwing. AVIF encoding is not available from canvas in any shipping browser; if you need it, encode server-side. Record the chosen format in the object key or a metadata column so the delivery layer knows what it has, the same way you would store image dimensions alongside the upload.

Output size for one photo across four resize settings Horizontal bars showing 486 KB at 2048px JPEG, 312 KB at 1600px JPEG, 198 KB at 1600px WebP and 121 KB at 1200px WebP, from a 4.30 MB source. Bytes leaving the browser source: 4032 × 3024 phone JPEG, 4.30 MB 2048px JPEG q0.85 486 KB · 8.9× smaller 1600px JPEG q0.85 312 KB · 13.8× smaller 1600px WebP q0.82 198 KB · 21.7× smaller 1200px WebP q0.82 121 KB · 35.6× smaller Dropping the box from 1600 to 1200 saves more bytes than any quality tweak at a fixed size.
Resolution dominates: the box you choose moves the payload far more than the quality parameter does.

Memory: what a decoded image actually costs

An ImageBitmap is uncompressed RGBA. The arithmetic is width × height × 4 bytes, with no relationship to the file size:

  • 4032 × 3024 (12 MP) → 48.8 MB
  • 8000 × 6000 (48 MP, recent flagship phones) → 192 MB
  • 3440 × 1440 screenshot → 19.8 MB

Add the ladder: the 12 MP case peaks at 48.8 MB for the bitmap plus 12.2 MB for step 1 plus 7.7 MB for the final canvas ≈ 69 MB. That is survivable once. Run five in parallel and a mid-range Android tab is dead. Serialise the queue — one worker, one job at a time — and call bitmap.close() in a finally block as the worker above does. Setting canvas.width = 0 on the intermediates is the equivalent for canvas backing stores.

If you also hash the file for deduplication or resumability, hash the original bytes with Web Crypto before you resize, and stream it in slices rather than reading it whole — the File API and Blob objects page covers why arrayBuffer() on a 200 MB file is a bad idea when you are already holding 70 MB of pixels.

Configuration gotchas

SecurityError: Failed to execute 'convertToBlob' on 'OffscreenCanvas': Tainted canvases may not be exported. You drew an image fetched from another origin without CORS. A File, a Blob and a blob: object URL never taint a canvas — only cross-origin network images do. Fix: set img.crossOrigin = "anonymous" before img.src, and make sure the origin returns Access-Control-Allow-Origin. If it does not, you cannot resize that image in the browser at all.

InvalidStateError: Failed to execute 'createImageBitmap' on 'Window': The source image could not be decoded. The bytes are not a format this engine decodes. In practice this is HEIC from an iPhone reaching a desktop Chromium build, or a .jpg that is actually a PDF. Fix: sniff the container before you decode — see why browser MIME types are unreliable — and fall back to uploading the original untouched rather than failing the whole upload.

DataCloneError: Failed to execute 'postMessage' on 'Worker': ImageBitmap at index 0 is detached. You transferred the same bitmap twice, usually because a retry re-posts the original job object. Fix: decode again from the File on retry; a detached bitmap has no pixels left to send.

ReferenceError: Can't find variable: OffscreenCanvas on Safari 16.3 and earlier. Fix: feature-detect with typeof OffscreenCanvas !== "undefined" and fall back to a <canvas> on the main thread with canvas.toBlob(cb, type, quality). The API shape differs — toBlob is callback-based and can hand you null.

iOS Safari silently produces a blank image above ~16.7 million canvas pixels. A single canvas may not exceed roughly 4096 × 4096; beyond that drawImage no-ops and convertToBlob cheerfully encodes a transparent or white rectangle with no error anywhere. The ladder in render() never allocates a canvas at source size — the first step is already halved — which is precisely why you must not “optimise” it into a single full-size canvas. If your target box itself exceeds 4096, clamp it: Math.min(longestEdge, 4096).

Encoding is the slow part, not the drawing. convertToBlob at 1600 px costs 25–60 ms; the whole ladder costs 10–20 ms. Doing that on the main thread drops four frames and shows up as input delay, which is the entire reason the worker exists.

Verification

Run this in the page console after resizeImage is in scope. It proves orientation, aspect ratio, format and the actual saving:

const file = (document.querySelector("input[type=file]") as HTMLInputElement).files![0];
const before = await createImageBitmap(file, { imageOrientation: "from-image" });
const t0 = performance.now();
const out = await resizeImage(file, 1600);
const ms = performance.now() - t0;

const check = await createImageBitmap(out.blob);
const srcRatio = before.width / before.height;
const outRatio = check.width / check.height;

console.assert(Math.max(check.width, check.height) === 1600, `box wrong: ${check.width}×${check.height}`);
console.assert(Math.abs(srcRatio - outRatio) < 0.01, `aspect drifted: ${srcRatio} vs ${outRatio}`);
console.assert(out.blob.size < file.size, "output is larger than the input");
console.log(
  `${(file.size / 1024).toFixed(0)} KB -> ${(out.blob.size / 1024).toFixed(0)} KB ` +
  `(${(file.size / out.blob.size).toFixed(1)}× smaller, ${out.blob.type}, ${ms.toFixed(0)} ms)`,
);
before.close();
check.close();

Expected output for a 12 MP portrait photo on a 2023 laptop:

4404 KB -> 203 KB (21.7× smaller, image/webp, 96 ms)

Record ms per resize in your telemetry alongside upload duration. A p95 above 400 ms means users are queueing more images than one worker can chew through, and the fix is a small pool of two workers, not a bigger box. Then send the resulting Blob exactly as you would the original — through fetch with FormData, or straight to storage — and keep the same error recovery around it, since a 200 KB PUT still fails on a train.

Frequently Asked Questions

Do I still need server-side validation if I resized on the client?

Yes, and more than before. Anything the browser produced is attacker-controllable — a scripted client can post whatever bytes it likes to the same endpoint. Keep the magic-byte check and the pixel-dimension check with libmagic on the server exactly as they were.

Why does my portrait photo come out rotated 90 degrees?

The JPEG stores landscape pixels plus an EXIF Orientation tag of 6, and your decode ignored the tag. Pass { imageOrientation: "from-image" } to createImageBitmap, or draw with a matching ctx.rotate(). The resized output carries no EXIF, so the tag must be applied at decode time or it is lost.

Should I resize before or after chunking a large upload?

Before, always. Resizing changes the byte length, so any part boundaries, checksums or resume offsets computed beforehand become invalid. Produce the final Blob, then slice it — the reduced size often takes the payload under the threshold where chunking was needed at all, which the payload size guidance for mobile uploads quantifies.

Is OffscreenCanvas in a worker actually faster than a canvas on the main thread?

The pixel work takes about the same wall-clock time; what changes is who waits. On the main thread a 96 ms resize blocks scrolling, input and animation for six frames. In a worker the page stays at 60 fps and the user never sees it, which is the metric that matters.

Can I keep the original as well as the resized version?

Yes, and it is a common pattern: upload the resized derivative first so the UI can show it immediately, then upload the original in the background at lower priority. Store both keys against the same record and treat the original as cold data with a lifecycle rule.