Converting Images to WebP in the Browser
Decode the image with createImageBitmap(file, { imageOrientation: "from-image" }), draw it onto an OffscreenCanvas (resized to your maximum dimensions) inside a Web Worker, and encode with canvas.convertToBlob({ type: "image/webp", quality: 0.82 }). Check the result’s type — browsers that cannot encode WebP silently return PNG — and fall back to JPEG or to the original file. Keep the original when the converted file is not meaningfully smaller, skip conversion for GIFs, SVGs and already-small files, and remember that canvas encoding strips EXIF metadata, including orientation and location.
A 12-megapixel phone photo is typically 3–5 MB as JPEG and often more as HEIC or PNG screenshots. Resized to 2560 pixels on the long edge and encoded as WebP at quality 0.8, it usually lands between 300 and 700 KB with no visible difference at normal viewing sizes. Converting on the device means an order of magnitude less to upload — faster on mobile networks, cheaper to store and quicker to process. This page belongs to client-side media preprocessing in frontend UX, chunking and progress tracking; resizing in detail is in resizing images in the browser with canvas.
When to use this approach
- Users upload photos or screenshots that you will display at web sizes, not print.
- Many users are on mobile networks, where upload bandwidth is scarce.
- You do not need the untouched original — or you upload it separately and lazily.
Prerequisites
- Browsers with
OffscreenCanvasandcreateImageBitmap(all current evergreen browsers; Safari 16.4+ forOffscreenCanvas). - WebP encoding support: Chromium and Firefox encode WebP; Safari encodes WebP from version 17 in some contexts but not all — always check the output type.
- A Web Worker bundle (
new Worker(new URL("./encode.worker.ts", import.meta.url), { type: "module" })).
The pipeline
Implementation
The worker:
// encode.worker.ts
interface Job { id: string; file: File; maxEdge: number; quality: number }
interface Result { id: string; blob: Blob; width: number; height: number; converted: boolean; reason?: string }
const SKIP_TYPES = new Set(["image/gif", "image/svg+xml"]); // animation / vectors: leave alone
self.onmessage = async (e: MessageEvent<Job>) => {
const { id, file, maxEdge, quality } = e.data;
try {
if (SKIP_TYPES.has(file.type) || file.size < 150 * 1024) {
return post({ id, blob: file, width: 0, height: 0, converted: false, reason: "skipped" });
}
const bitmap = await createImageBitmap(file, { imageOrientation: "from-image" });
const scale = Math.min(1, maxEdge / Math.max(bitmap.width, bitmap.height));
const width = Math.round(bitmap.width * scale), height = Math.round(bitmap.height * scale);
const canvas = new OffscreenCanvas(width, height);
const ctx = canvas.getContext("2d", { alpha: file.type === "image/png" })!;
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high";
ctx.drawImage(bitmap, 0, 0, width, height);
bitmap.close();
let blob = await canvas.convertToBlob({ type: "image/webp", quality });
if (blob.type !== "image/webp") { // encoder unavailable: browser fell back to PNG
blob = await canvas.convertToBlob({ type: "image/jpeg", quality: Math.min(0.9, quality + 0.05) });
}
if (blob.size >= file.size * 0.9) {
return post({ id, blob: file, width, height, converted: false, reason: "not smaller" });
}
post({ id, blob, width, height, converted: true });
} catch (err) {
post({ id, blob: file, width: 0, height: 0, converted: false, reason: String(err) }); // undecodable: upload original, let the server decide
}
};
function post(r: Result) { (self as unknown as Worker).postMessage(r); }
The main thread:
const worker = new Worker(new URL("./encode.worker.ts", import.meta.url), { type: "module" });
const pending = new Map<string, (r: any) => void>();
worker.onmessage = (e) => { pending.get(e.data.id)?.(e.data); pending.delete(e.data.id); };
export function toWebP(file: File, maxEdge = 2560, quality = 0.82): Promise<File> {
const id = crypto.randomUUID();
return new Promise((resolve) => {
pending.set(id, (r) => {
if (!r.converted) return resolve(file);
const ext = r.blob.type === "image/webp" ? ".webp" : ".jpg";
const name = file.name.replace(/\.[^.]+$/, "") + ext;
resolve(new File([r.blob], name, { type: r.blob.type, lastModified: file.lastModified }));
});
worker.postMessage({ id, file, maxEdge, quality });
});
}
Line-by-line on the decisions that matter
imageOrientation: "from-image". Phone photos are stored sideways with an EXIF orientation tag. Decoding with orientation applied bakes the rotation into the pixels, which matters because the encoded output has no EXIF to carry the tag.- Worker plus
OffscreenCanvas. Decoding and encoding a 12-megapixel image takes 100–500 ms. On the main thread, that freezes the interface for every photo in a batch. In a worker, the page stays responsive and several images can be processed in parallel. - Skip small files, GIFs and SVGs. Converting a 60 KB icon saves nothing. Canvas flattens animated GIFs to one frame and rasterises SVGs, destroying what made them useful.
- Check
blob.type.convertToBlobdoes not throw when it cannot produce the requested type; it returns PNG. Without the check, some browsers upload PNGs several times larger than the original JPEG. - Keep the original when not smaller. Already-optimised JPEGs or small screenshots can grow when re-encoded. A 10 % threshold avoids spending quality for no gain.
- Upload the original on decode failure. HEIC decoding, for example, is unavailable in many browsers. The server can still accept and convert it (converting HEIC images to JPEG in the browser covers the client-side option).
Choosing quality and size
Most of the reduction comes from resizing: a 4000 × 3000 photo has 1.8 times the pixels of a 2560 × 1920 one. Pick the maximum edge from your largest display size times the device pixel ratio you care about — 2560 covers full-width display on most screens, 1600 is enough for feeds and cards. Quality 0.8–0.85 is visually lossless for photos at those sizes; below 0.75, fine textures (hair, foliage, fabric) soften. Screenshots with text compress better as lossless WebP or PNG; detect them by type (image/png) and try quality: 1 to request lossless, falling back to the original if it is not smaller.
Metadata, privacy and originals
Canvas encoding produces a file with no EXIF, XMP or ICC data. That removes GPS coordinates and camera serial numbers, which is often desirable (see stripping EXIF metadata before upload), but it also removes capture dates and colour profiles. Wide-gamut (Display P3) photos from recent phones can look slightly duller after conversion because the canvas works in sRGB by default; request colorSpace: "display-p3" in getContext where supported if colour accuracy matters.
If your product needs the capture date (for sorting a timeline), read it from the original before conversion with a small EXIF parser and send it as a separate field. If some users need originals — photographers, archives — offer an explicit “upload original quality” option rather than converting silently, and consider uploading the converted version first for immediate display and the original afterwards in the background.
Fitting conversion into the upload flow
Convert as soon as files are chosen, not when the upload starts, so the work overlaps with the user reviewing their selection. Show each file as “Preparing” while its conversion runs and switch to the normal upload states afterwards; the queue from upload queue concurrency control can treat conversion as a step before the transfer. Limit concurrent conversions to two or three — they compete for CPU and memory with each other and with the page — and let uploads of already-converted files proceed while later files are still being prepared.
Report the saving where it helps. “12 photos, 48 MB → 6 MB” tells users on metered connections why the upload is fast and reassures them that the app is careful with their data. Record the before-and-after sizes in telemetry too; they show whether your quality and size settings are doing what you expect across real devices.
Configuration gotchas
Output is a PNG named .webp. The browser could not encode WebP. Always read blob.type and name the file from it.
Photos come out sideways. The browser ignored orientation. Older Safari versions needed the image drawn through an <img> element to apply EXIF orientation; imageOrientation: "from-image" is the modern fix.
Memory crashes on mobile with large batches. Each decoded 12-megapixel bitmap takes about 48 MB. Process two or three images at a time, and call bitmap.close() as soon as it has been drawn.
Server validation rejects the converted file. Your allowed types list does not include image/webp. Update server-side validation when adding client conversion.
Verification
- Convert a 4 MB phone JPEG: the result is
image/webp, under 1 MB, upright, with no EXIF (exiftoolshows none). - In a browser without WebP encoding, the result is
image/jpeg, not PNG. - Convert a 60 KB PNG icon: the original is returned unchanged.
- Record a Performance profile during a 20-photo batch: no long tasks on the main thread.
Frequently Asked Questions
Should I convert to AVIF instead?
AVIF compresses better but browser-side encoding is not widely available through canvas and is slow where it is. WebP is the practical client-side choice; convert to AVIF on the server if you want it for delivery (serving AVIF and WebP with Accept header negotiation).
Does this replace server-side image processing?
No. The server still validates, generates derivatives and serves responsive sizes. Client conversion reduces upload size and time; it does not make the server trust the client.
Is quality consistent across browsers?
Encoders differ slightly between Chromium and Firefox, so the same quality setting produces slightly different sizes. The visual difference at 0.8 is negligible for photos.