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

  1. Browsers with OffscreenCanvas and createImageBitmap (all current evergreen browsers; Safari 16.4+ for OffscreenCanvas).
  2. 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.
  3. A Web Worker bundle (new Worker(new URL("./encode.worker.ts", import.meta.url), { type: "module" })).

The pipeline

Browser image conversion pipeline The main thread passes the File to a worker. The worker decodes it with createImageBitmap applying EXIF orientation, computes target dimensions, draws onto an OffscreenCanvas with high-quality smoothing, and encodes to WebP. If the output type is not WebP it re-encodes as JPEG. The smaller of the converted and original files is returned for upload. Decode, resize, encode — off the main thread File 4.2 MB JPEG worker decode + orientation resize ≤ 2560 px encode WebP q 0.82 upload ~520 KB If the browser returns PNG instead of WebP, re-encode as JPEG; if the result is not smaller, upload the original.
A typical phone photo drops from megabytes to hundreds of kilobytes.

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. convertToBlob does 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

Typical output sizes for a 12 megapixel photo The original JPEG is about 4.2 MB. Resized to 2560 pixels and encoded as JPEG at quality 0.85 it is about 900 KB. As WebP at quality 0.82 it is about 520 KB. At quality 0.7 it is about 340 KB with visible softening in fine detail. 12 MP phone photo, long edge 2560 px original JPEG 4.2 MB JPEG q 0.85 ~900 KB WebP q 0.82 ~520 KB — recommended WebP q 0.70 ~340 KB — softer detail Illustrative sizes; measure on your own users' photos before fixing the numbers.
Resizing does most of the saving; WebP takes it the rest of the way.

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.

What client-side conversion keeps and drops Conversion keeps the pixels, correctly oriented, at the chosen size. It drops EXIF including GPS, capture date and camera model, drops ICC colour profiles, and drops extra frames from animated images. If capture date is needed, read it before converting and send it as a form field. Pixels survive; metadata does not kept pixels, upright, resized transparency (PNG → WebP) dropped EXIF: GPS, date, camera ICC profile, extra frames Read the capture date before converting and send it separately if your product sorts by it.
Decide deliberately which metadata you want, and send it as data rather than hoping it survives.

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 (exiftool shows 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.