DEV Community

SEN LLC
SEN LLC

Posted on

An In-Browser Image Format Converter in 500 Lines — and Why PNG, JPEG, WebP, AVIF Are More Different Than You Think

Image format conversion is usually a server-side job (sharp, ImageMagick, ffmpeg). But canvas.toBlob plus 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's quality=1.0 quietly switches into lossless mode, AVIF encoding is Chromium-only despite decode being everywhere, and PNG's quality argument 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

Screenshot

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)
);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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 },
];
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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: "..." },
];
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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)}%` };
}
Enter fullscreen mode Exit fullscreen mode

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),
  };
}
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.0 is the secret lossless switch — there's no explicit API.
  • AVIF encode is Chromium-only. Always validate blob.type against 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)