Back to News & Insights
JavaScript September 17, 2026 · 6 min read

Writing a real PNG compressor in vanilla JavaScript (no WASM, no libraries)

Every online image converter I tried had the same shape: drag your file in, it uploads to a server...

Writing a real PNG compressor in vanilla JavaScript (no WASM, no libraries)

Every online image converter I tried had the same shape: drag your file in, it uploads to a server somewhere, you get a download link back.

That's fine for a meme. It's less fine for a scan of your passport, and it's the most common thing people convert. So I wanted to know how far a browser could get on its own.

Quite far, as it turns out — but not without a few traps that cost me an evening each. Here are the four that were worth writing down.

They encode exactly three: PNG, JPEG, WebP. That's the whole list canvas.toBlob() will give you.

Everything else — BMP, ICO, PDF, and crucially a properly compressed PNG — you write yourself, byte by byte.

canvas.toBlob() takes a MIME type. Pass it one the browser can't encode, and it does not throw, does not return null, and does not warn. It silently gives you a PNG with a success callback.

So you ship a converter that appears to work perfectly, and your users get files named .avif that are actually PNGs. Nothing errors. You find out from a bug report.

Check the type you got against the type you asked for. Never trust the callback firing as proof of anything.

Here's the thing that surprised me most. Draw a PNG to a canvas, call toBlob('image/png'), and you get a file that's often larger than the original.

PNG has no quality slider. It's lossless. So there's no knob for toBlob to turn, and re-encoding just re-does the same lossless compression, usually worse than whatever tool made the original.

Real PNG compression works differently. A standard PNG stores 24-bit colour — about 16 million possible values per pixel. Almost no real image uses that many. A logo might use twelve. A screenshot might use two hundred.

So you build an optimised palette of at most 256 colours and store a one-byte index per pixel instead of three or four bytes of colour. That's where the savings live, and toBlob will never do it for you.

A PNG is an 8-byte signature followed by chunks. Each chunk is: length, 4-byte type, data, CRC32.

Note the CRC covers the type and the data, but not the length field. Get that wrong and every decoder rejects the file with no useful message.

For an indexed PNG you need four chunks: IHDR (dimensions, bit depth, colour type 3 for palette), PLTE (the palette), tRNS (per-palette-entry alpha, only if anything is transparent), and IDAT (the pixel data).

One detail that's easy to miss: every scanline in the raw data is prefixed with a filter byte. Zero means "no filter". Forget it and your image comes out sheared diagonally — which is at least a memorable way to find the bug.

Bit depth is worth packing properly too. Two colours fit in 1 bit per pixel, four in 2 bits, sixteen in 4. A two-colour image at 8 bits per pixel wastes 87% of its bytes.

IDAT data is zlib-compressed, and browsers have that built in now via CompressionStream:

Want to discuss this further?

Book a free strategy call with our team to see how these insights apply to your specific business goals.

Book a consultation