PNG-to-WebP in JS splits cleanly by environment: browser uses Canvas, Node uses sharp, edge runtimes use the API. All three produce comparable output quality, so the choice is mostly about deployment constraints.

Method 1: Canvas API (browser, zero dependencies)

The browser's built-in Canvas API encodes WebP natively in every modern browser (since 2020).

async function pngToWebp(file, quality = 0.85) {
  // Decode PNG into a bitmap
  const bitmap = await createImageBitmap(file);

  // Draw onto a canvas
  const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
  const ctx = canvas.getContext("2d");
  ctx.drawImage(bitmap, 0, 0);

  // Encode as WebP
  return await canvas.convertToBlob({ type: "image/webp", quality });
}

// Wire up a file input:
document.querySelector("input[type=file]").addEventListener("change", async (e) => {
  const blob = await pngToWebp(e.target.files[0], 0.85);
  const url = URL.createObjectURL(blob);
  const a = Object.assign(document.createElement("a"), { href: url, download: "out.webp" });
  a.click();
  URL.revokeObjectURL(url);
});

Three notes:

  • quality is 0-1 in canvas (not 0-100 like Pillow). 0.85 is a good default for photos.
  • OffscreenCanvas is supported everywhere except old Safari. For broad compatibility, fall back to a regular HTMLCanvasElement and canvas.toBlob().
  • Lossless WebP from canvas requires quality=1.0 — the browser still uses lossy encoding, just at maximum quality. For true lossless, use sharp in Node or the API.

Method 2: sharp (Node, fastest option)

sharp wraps libvips — the same underlying engine ImageMagick uses, but with a much faster Node interface. It's the standard for image work in Node.

npm install sharp
import sharp from "sharp";
import fs from "node:fs/promises";

async function pngToWebp(inPath, outPath, quality = 85, lossless = false) {
  await sharp(inPath)
    .webp({ quality, lossless, effort: 6 })
    .toFile(outPath);
}

await pngToWebp("hero.png", "hero.webp");
await pngToWebp("diagram.png", "diagram.webp", 85, true);  // lossless

sharp's three knobs:

  • quality (1-100) — same scale as Pillow / cwebp.
  • lossless — true for diagrams and screenshots, false for photos.
  • effort (0-6) — encoding effort; 6 is the slowest and produces the smallest files.

For batch processing, sharp pipelines are async — fire them off concurrently:

import { glob } from "glob";
import path from "node:path";

const pngs = await glob("./png_in/*.png");
await Promise.all(
  pngs.map((p) => {
    const out = `./webp_out/${path.basename(p, ".png")}.webp`;
    return sharp(p).webp({ quality: 85, effort: 6 }).toFile(out);
  })
);

Method 3: ChangeThisFile API (edge runtimes, no bundle hit)

If you're running on Cloudflare Workers, Vercel Edge, or another runtime where you can't load native bindings (sharp uses N-API), the API is a single fetch call. Free tier gives 100 conversions/month.

const API_KEY = "ctf_sk_your_key_here";

async function pngToWebp(pngBuffer, filename = "image.png") {
  const form = new FormData();
  form.append("file", new Blob([pngBuffer], { type: "image/png" }), filename);
  form.append("source", "png");
  form.append("target", "webp");

  const response = await fetch("https://changethisfile.com/v1/convert", {
    method: "POST",
    headers: { Authorization: `Bearer ${API_KEY}` },
    body: form,
  });

  if (!response.ok) throw new Error(`HTTP ${response.status}: ${await response.text()}`);
  return await response.arrayBuffer();
}

// Cloudflare Worker example:
export default {
  async fetch(request) {
    const png = await request.arrayBuffer();
    const webp = await pngToWebp(new Uint8Array(png));
    return new Response(webp, { headers: { "Content-Type": "image/webp" } });
  },
};

The API runs sharp on the server, so quality is identical to method 2 — without you needing to ship a 30MB native dependency.

When to use each

ApproachBest forTradeoff
Canvas APIPure-browser apps, zero server costLossless mode is approximate; main thread blocking on big files
sharp (Node)Server-side batch jobs, max performanceNative dep — 30MB install, manylinux/Alpine quirks
ChangeThisFile APIEdge runtimes, small bundles, multi-language teamsNetwork call, per-call cost above free tier

CLI alternative: cwebp

For one-off conversions or shell pipelines, cwebp (the upstream Google binary) is the fastest path with no JS dependency.

npm install -g cwebp-bin
npx cwebp -q 85 -m 6 hero.png -o hero.webp

# In a Node script:
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const run = promisify(execFile);
await run("npx", ["cwebp", "-q", "85", "-m", "6", "hero.png", "-o", "hero.webp"]);

Use cwebp when you want maximum speed and don't mind shelling out. For most JS apps, sharp is more ergonomic.

Production tips

  • Use Web Workers for browser conversions of big files. A 4K PNG can take 200-300ms to encode — long enough to drop frames. Move the canvas work into a worker so the main thread stays responsive.
  • For sharp in Lambda, use the layer. Don't bundle sharp into your Lambda zip — it's huge and architecture-specific. Use the public sharp Lambda layer or AWS's prebuilt one.
  • quality=0.85 is the right default everywhere. Below 0.7 you'll see banding. Above 0.95 the file size jumps with no perceptible quality gain.
  • Always test with your actual images. Photos and screenshots compress very differently. A diagram at quality=0.85 might be larger than the source PNG; switch to lossless WebP for those.

Pick the method that matches your runtime: Canvas in the browser, sharp in Node, API on the edge. All three produce visually identical WebPs at quality=0.85. Free tier on the API is 100 conversions/month, no card.