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
| Approach | Best for | Tradeoff |
|---|---|---|
| Canvas API | Pure-browser apps, zero server cost | Lossless mode is approximate; main thread blocking on big files |
| sharp (Node) | Server-side batch jobs, max performance | Native dep — 30MB install, manylinux/Alpine quirks |
| ChangeThisFile API | Edge runtimes, small bundles, multi-language teams | Network 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.