Writing a real PNG compressor in vanilla JavaScript (no WASM, no libraries)
A guide to creating a fully client‑side PNG compressor in vanilla JavaScript. It explains browser limitations, common traps such as silent MIME type mismatches and the inefficiency of re‑encoding PNGs, and shows how to hand‑craft PNG files, use median cut and Floyd‑Steinberg dithering, and handle S…
Every online image converter I tried followed the same pattern: drag a file, it uploads to a server, and you get a download link. That’s fine for a meme, but not for a passport scan or any image that you want to keep private. I wondered how far a browser could go on its own, and the answer turned out to be surprisingly far – but not without a few traps that cost me an evening each.
What Browsers Can Do for You
Modern browsers can decode a wide range of image formats – PNG, JPEG, WebP, GIF, BMP, AVIF, SVG, ICO – and they can encode exactly three of those: PNG, JPEG and WebP. The canvas.toBlob() method is the key to turning a canvas into a file, but it only works for the three encodable formats. Everything else, including BMP, ICO, PDF, and especially a properly compressed PNG, must be written byte‑by‑byte.
Trap 1: Silent MIME Type Mismatches
When you call canvas.toBlob() with a MIME type that the browser can’t encode, the method never throws an error. Instead, it silently returns a PNG file and calls the success callback. The result is a file named .avif that is actually a PNG, and users only discover the mistake through bug reports. The fix is simple: after the blob is returned, compare its type property to the MIME type you requested and reject if they don’t match.
Trap 2: Re‑encoding PNGs Usually Worsens Size
PNG is a lossless format with no quality slider. Drawing a PNG onto a canvas and calling toBlob('image/png') often produces a file larger than the original because the browser re‑compresses the data with its own algorithm, which is rarely as efficient as the tool that created the PNG. Real PNG compression involves creating an optimized palette of up to 256 colors and storing each pixel as a one‑byte index instead of three or four bytes of color. This requires writing the PNG format yourself, including the signature, chunks, and filter bytes for each scanline.
Trap 3: Choosing the Right Deflate Variant
The CompressionStream API accepts deflate, deflate-raw, and gzip. The names are misleading: deflate produces the zlib format (RFC 1950) with a 2‑byte header and Adler‑32 checksum, while deflate-raw produces raw deflate data (RFC 1951) with no header or checksum. PNG’s IDAT chunk requires the zlib wrapper, so you must use deflate. Using deflate-raw results in a file that looks plausible byte‑for‑byte but fails to open in any viewer.
Trap 4: SVGs Default to 300×150 Pixels
When an SVG has no explicit width or height, drawing it onto a canvas yields a 300×150 pixel image, the CSS default for replaced elements. The solution is to parse the viewBox, remove any existing dimensions, and inject real width and height values before rasterising.
Testing and Validation
Unit‑testing binary output is tricky because a file can have the right size and type but still be incorrect. I used headless Chromium with Playwright to drive the conversion pages, then verified the bytes in Python. Libraries like Pillow checked dimensions, color mode, and palette usage; PyPDF verified PDF page counts; zipfile checked archive integrity; and pixel‑wise comparisons caught rotation and flipping errors. This approach uncovered the SVG sizing bug, the toBlob fallback, and a missing filter byte that caused wrong dimensions.
What Browsers Still Can’t Do
Browsers cannot decode HEIC, RAW, PSD, or TIFF without external libraries, and encoding AVIF is only supported where the browser itself supports it. For large files or unsupported formats, a server‑side solution remains the best choice. The upside of a pure client‑side tool is that no data leaves the user’s device, there’s no upload queue, no bandwidth limits, and the page works offline.
Get the Code
The entire project is MIT‑licensed and dependency‑free. The engine is a single JavaScript file, and a Python script generates the site. You can find the source on GitHub and run the live demo at convertpicto.com. If you run into files that fail, feel free to open an issue – I’d love to hear about edge cases.
Why it matters
Building a fully client‑side PNG compressor keeps sensitive images private and removes reliance on third‑party services, which is crucial for privacy‑conscious users and for handling large or regulated images.
Key points
- Browsers can decode many formats but only encode PNG, JPEG and WebP.
- <code>canvas.toBlob()</code> silently returns PNG for unsupported MIME types – check the blob type.
- Re‑encoding PNGs usually increases size; true compression needs a custom palette.
- Use <code>deflate</code> (zlib) for PNG IDAT, not <code>deflate-raw</code>.
- SVGs without width/height default to 300×150 – parse viewBox to set real dimensions.
- Unit tests with headless Chromium and Python libraries catch subtle bugs.
Frequently asked questions
Why does <code>canvas.toBlob()</code> return a PNG when I ask for AVIF?
The browser silently falls back to PNG for unsupported MIME types and does not throw an error, so you must verify the returned blob’s type.
Can I use this code for large photos?
For very large images, client‑side processing may be slow on older devices; a server‑side approach can be faster for such cases.
Does this handle animated PNGs?
No, the current implementation focuses on static PNGs; animated PNG support would require additional logic.




