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
- Chromium 69+, Firefox 105+ or Safari 16.4+ for
OffscreenCanvaswith a2dcontext in a worker. Safari 16.3 and earlier have the constructor but no 2D context. - 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. tsconfig.jsonwith"lib": ["DOM", "DOM.Iterable", "ES2022", "WebWorker"].- A
Filefrom an<input type="file">, a drop, or a clipboard paste. Any of the three gives you aBlob, which is allcreateImageBitmapneeds.
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 fromnonetofrom-imagein 2021 and engines adopted it at different times, so an unqualifiedcreateImageBitmap(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 theImageBitmapis detached on the sender and re-homed in the worker for free. After the call,bitmap.widthon the main thread is0.cw >> 1, notcw * 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 whitefillRect, or every transparent PNG pixel encodes as black.blob.type !== type.convertToBlobdoes 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.
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.
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.
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.