Image format conversion is usually a server-side job (sharp, ImageMagick, ffmpeg). But
canvas.toBlobplus the fact that browsers ship PNG / JPEG / WebP / AVIF encoders means you can do it all client-side, with no server. I built a 500-line vanilla JS converter that does exactly that — and discovered along the way that the four formats aren't just different MIME strings: JPEG has no alpha channel (transparent backgrounds get flattened), WebP'squality=1.0quietly switches into lossless mode, AVIF encoding is Chromium-only despite decode being everywhere, and PNG'squalityargument is ignored. The implementation hinges on a catalog-driven design that turns those structural differences into testable data.
🌐 Demo: https://sen.ltd/portfolio/image-convert/
📦 GitHub: https://github.com/sen-ltd/image-convert
The browser-side approach
Every modern browser ships built-in encoders for the major image formats. They're exposed through one API:
canvas.toBlob(
(blob) => { /* blob is the encoded image */ },
"image/webp", // target MIME
0.8, // quality (lossy formats only)
);
One line. No sharp, no ImageMagick, no upload. Until you realize that the four target formats are structurally very different.
Four formats, four behaviors
1. PNG: quality is ignored
PNG is lossless, so quality is meaningless. canvas.toBlob(cb, "image/png", 0.5) ignores the 0.5 entirely. The output is deterministic — re-encode the same canvas a hundred times and you get bit-identical PNGs (predictor filter + DEFLATE).
2. JPEG: no alpha channel
This is the trap. JPEG has no alpha channel — it was designed in 1992 for photographs. So when you encode a Canvas with transparent pixels to JPEG:
const canvas = drawTransparentImage();
canvas.toBlob((blob) => {
// blob is JPEG, but transparent pixels became...
// Chrome: black. Safari: white.
}, "image/jpeg", 0.8);
Browsers disagree on the fill color. The fix is to pre-fill the canvas with an explicit background before drawing:
const canvasOpaque = document.createElement("canvas");
canvasOpaque.width = w; canvasOpaque.height = h;
const ctx = canvasOpaque.getContext("2d");
ctx.fillStyle = userPickedBgColor;
ctx.fillRect(0, 0, w, h);
ctx.drawImage(bitmap, 0, 0, w, h);
// For other formats: a separate canvas with no fill
const canvasTransparent = document.createElement("canvas");
// (no fillRect)
Decode the file once with createImageBitmap, then draw it onto two canvases (one opaque-background for JPEG, one transparent for PNG/WebP/AVIF). Bitmap reuse saves the decode cost.
3. WebP: quality=1.0 switches to lossless mode
WebP supports both lossy and lossless under the same image/webp MIME. The switch is the quality value: pass 1.0 and you get lossless WebP (color cache + LZ77 + Huffman); anything less and you get lossy (VP8 intra-frame).
There's no standard API to say "give me lossless WebP" explicitly. You have to know about the 1.0 trick.
In the catalog I treated "WebP" and "WebP (lossless)" as two separate formats and forced quality=1.0 for the second:
export const FORMATS = [
// ...
{ id: "webp", mime: "image/webp", lossy: true, forceQuality: undefined },
{ id: "webp-lossless", mime: "image/webp", lossy: false, forceQuality: 1.0 },
];
Lossless WebP is typically 20-30% smaller than PNG.
4. AVIF: encode is Chromium-only
AVIF (AV1's still-frame format) is widely supported for decode (Safari 16+, Firefox 93+, Chrome 85+) but the encode side is shipped only in Chromium browsers. Firefox refuses outright; Safari sometimes silently falls back to PNG.
canvas.toBlob((blob) => {
if (!blob) return null;
if (blob.type !== "image/avif") return null; // detect PNG fallback
return blob;
}, "image/avif", 0.5);
Always check blob.type against the requested MIME. Browsers will lie if you don't.
Catalog-driven design
The five formats become rows in a table:
export const FORMATS = [
{ id: "png", mime: "image/png", lossy: false, alpha: true, notes: "..." },
{ id: "jpeg", mime: "image/jpeg", lossy: true, alpha: false, notes: "..." },
{ id: "webp", mime: "image/webp", lossy: true, alpha: true, notes: "..." },
{ id: "webp-lossless", mime: "image/webp", lossy: false, alpha: true, forceQuality: 1.0 },
{ id: "avif", mime: "image/avif", lossy: true, alpha: true, notes: "..." },
];
The UI iterates over this list and encodes once per format:
export async function encodeAll(canvas, quality) {
const out = {};
for (const fmt of FORMATS) {
const q = fmt.forceQuality ?? quality;
const blob = await canvasToBlob(canvas, fmt.id, q);
out[fmt.id] = blob
? { blob, size: blob.size, supported: true }
: { blob: null, size: 0, supported: false };
}
return out;
}
Unsupported formats show as "N/A". This teaches the user which formats their browser refuses — useful information, not an error to hide.
Size comparison UX
For each format, the card shows the size delta vs. the original:
export function sizeDelta(after, before) {
if (before === 0) return { pct: 0, label: "—" };
const pct = ((after - before) / before) * 100;
const sign = pct > 0 ? "+" : "";
return { pct, label: `${sign}${pct.toFixed(1)}%` };
}
Green for smaller, red for larger. The result: a tile grid showing "WebP -45%, AVIF -67%" at a glance.
Example with an 800×600 generated PNG:
| Format | size | delta vs original |
|---|---|---|
| Original PNG | 32.3 KB | (baseline) |
| PNG (re-encoded) | 9.2 KB | -71.5% |
| JPEG (q=0.82) | 18.4 KB | -43.0% |
| WebP (q=0.82) | 6.5 KB | -79.9% |
| WebP (lossless) | 11.2 KB | -65.3% |
| AVIF (q=0.55) | N/A | (headless Chrome here) |
A side-by-side that makes "WebP wins" obvious.
Downscaling
export function computeFitSize(srcW, srcH, maxDim) {
if (!maxDim || (srcW <= maxDim && srcH <= maxDim)) return null;
const ratio = srcW > srcH ? maxDim / srcW : maxDim / srcH;
return {
width: Math.round(srcW * ratio),
height: Math.round(srcH * ratio),
};
}
Returning null when no downscale is needed lets the upstream renderer skip the resize branch cleanly.
Tests: 40
core.js is DOM-free, so all 40 tests run under node:test:
test("PNG is lossless with alpha", () => {
const png = getFormatById("png");
assert.equal(png.lossy, false);
assert.equal(png.alpha, true);
});
test("JPEG is lossy without alpha", () => {
assert.equal(getFormatById("jpeg").alpha, false);
});
test("WebP-lossless has forceQuality 1.0", () => {
assert.equal(getFormatById("webp-lossless").forceQuality, 1.0);
});
test("AVIF preset is the lowest (more efficient codec)", () => {
assert.ok(QUALITY_PRESETS.avif < QUALITY_PRESETS.jpeg);
});
Catalog-driven design makes the tests look like documentation: the structural facts about each format are declared as data, and the tests assert against the declarations.
Architecture
core.js ← format catalog, byte helpers (DOM-free, 40 tests)
convert.js ← Canvas pipeline (createImageBitmap, toBlob)
app.js ← UI glue + drag-and-drop
core.js knows nothing about Canvas or the DOM. convert.js is the only file that touches browser APIs. Separating the two means the testable logic stays testable.
Try it
Drop a transparent PNG (an icon, say) and convert it to JPEG — you'll see how the alpha is flattened. Then drop a photo and watch WebP and AVIF beat JPEG by 30-50% at the same quality.
Takeaways
-
canvas.toBlob(cb, mime, quality)is all you need for browser-side conversion across PNG / JPEG / WebP / AVIF. -
PNG ignores
quality. It's lossless. - JPEG has no alpha. Pre-fill the canvas with a background, or browsers will fill it inconsistently (Chrome black, Safari white).
-
WebP
quality=1.0is the secret lossless switch — there's no explicit API. -
AVIF encode is Chromium-only. Always validate
blob.typeagainst the requested MIME — Safari may quietly fall back to PNG. - Catalog-driven format definitions turn structural differences into data the UI iterates and the tests assert against.
- DOM-free core + browser-only convert keep 40 tests possible in Node.
This is OSS portfolio #258 from SEN LLC (Tokyo). https://sen.ltd/portfolio/

Top comments (0)