Compress your PNG — local and secure
Every "PNG compressor" on the web asks you to upload your image to a server you know nothing about, and most of them are doing something you could do in the tab you already have open. This is what PNG compression actually is — why the format has no quality dial, what a compressor is really throwing away when you drag that slider, and why dithering can make your file bigger. With the real code from the COMPRESS tab, and real measurements.
PNG has no quality dial, and that changes the question
JPEG has a quality setting because JPEG is lossy by construction: it throws away
high-frequency detail in blocks, and the quality number says how much. PNG has no
such number, because PNG is lossless by definition. Its pixel data
is already DEFLATE-compressed inside the IDAT chunk, and decoding a PNG
returns exactly the pixels that went in.
The browser API is quietly honest about this, in a way that catches people out:
// The second argument is documented as a quality hint for lossy types.
// For image/png it is IGNORED — completely, silently.
canvas.toBlob(cb, 'image/png', 0.2); // same bytes as 0.9, or 1.0, or omitted
So any tool that offers you a PNG quality slider is doing one of exactly two things. Either it is re-deflating harder — trying more filter combinations per row, running Zopfli instead of zlib — which is genuinely lossless and typically buys 5–15%. Or it is throwing colours away, which is lossy, is not reversible, and buys 70–80%.
The COMPRESS tab does the second one, and says so. It is worth knowing which you are getting, because only one of them changes your image.
What an indexed PNG actually is
A normal truecolour PNG stores three bytes per pixel: red, green, blue (plus alpha,
if it has any). An indexed PNG stores a small lookup table and then
one index per pixel. The table is the PLTE chunk: up to 256
RGB triples, 768 bytes at most. The pixel data becomes a grid of numbers pointing
into it.
That is already a 3× saving before DEFLATE sees anything — and it gets better, because an index only needs as many bits as the palette is big:
export function bitDepthFor(paletteLength) {
if (paletteLength <= 2) return 1;
if (paletteLength <= 4) return 2;
if (paletteLength <= 16) return 4;
return 8;
}
Sixteen colours means 4 bits per pixel: two pixels packed into every
byte, so the raw scanline data halves again before compression begins. PNG's
IHDR carries that bit depth, and every decoder in the world handles it —
this is not an exotic corner of the format, it is how PNG was used for most of the
1990s.
One detail that matters for size and is easy to get wrong: every scanline in our output takes filter type 0 (None). PNG's row filters (Sub, Up, Average, Paeth) predict each byte from its neighbours and store the difference, which is a large win on photographic truecolour data. On palette indices it is a loss. An index is a label, not a magnitude — palette entry 7 is not "one more than" entry 6 in any meaningful sense — so subtracting neighbouring indices produces noise where the filter was supposed to produce zeros.
Choosing 96 colours out of sixteen million
That is the actual problem. A photograph might contain 200,000 distinct colours; you have room for 96. Which 96?
The classic answer, and the one used here, is median cut. Start with one box containing every colour in the image. Repeatedly pick a box, split it in two, and stop when you have as many boxes as you want colours; each box's average becomes a palette entry. The whole algorithm lives in which box you pick and where you cut.
Picking is where implementations differ, and where the results visibly differ:
const e = extents(boxes[i]);
const widest = Math.max(e.dr, e.dg, e.db, e.da);
if (widest === 0) continue; // a box of identical colours
const score = widest * Math.cbrt(e.n);
Score by volume alone and the algorithm chases outliers: a dozen stray hot-pink pixels occupy a huge, empty region of colour space and soak up palette entries that thousands of skin-tone pixels needed. Score by population alone and you lose the small vivid subject on the large flat background — the red jacket in the snow field gets one entry, the snow gets ninety.
The cube root of the population is the standard compromise. It lets population matter without letting it dominate: a region has to be both reasonably large in colour space and reasonably common in the image before it earns another split.
Cutting is simpler — sort the box along its widest axis and split at the population median rather than the midpoint, so both halves carry a similar number of pixels:
box.sort((x, y) => x[axis] - y[axis]);
let total = 0;
for (const c of box) total += c.n;
let acc = 0, cut = 1;
for (let i = 0; i < box.length; i++) {
acc += box[i].n;
if (acc >= total / 2) { cut = Math.max(1, Math.min(i + 1, box.length - 1)); break; }
}
One practical note on speed. Sorting and splitting sixteen million colours would be hopeless, so the image is first bucketed into a colour cube — 5 bits per channel, 32 levels each — which collapses it to at most 32,768 populated cells carrying their mean colour and pixel count. That histogram pass is the expensive part of the whole pipeline, and, crucially, it does not depend on the slider. It is computed once when the file opens. Every subsequent drag re-cuts the same small set of cells, which is why the slider feels free.
Transparency is where naive quantizers break
Search for a palette-quantization tutorial and almost all of them handle RGB and stop. Feed one a logo with a soft shadow and you get black fringes, because transparency in an indexed PNG is not stored with the pixels at all.
It lives in tRNS, which for an indexed image is an array of alpha
bytes parallel to the palette — entry n of tRNS is the
opacity of palette entry n. And it is allowed to stop early: any palette
entry past the end of the array is fully opaque. So if you sort the palette to put
the opaque colours last, the array can be short instead of padded with a long run
of 255s:
// medianCut sorted opaque entries last precisely so this can stop early.
let lastNonOpaque = -1;
for (let i = 0; i < palette.length; i++) if (palette[i][3] !== 255) lastNonOpaque = i;
const trns = lastNonOpaque >= 0
? Uint8Array.from(palette.slice(0, lastNonOpaque + 1), (p) => p[3])
: null;
Alpha also has to take part in the distance metric, weighted hard — a pixel that should be invisible and isn't is a far louder error than one that is slightly the wrong green. But alpha error is deliberately not diffused when dithering. Spreading transparency error across neighbouring pixels turns a crisp cut-out edge into a speckled halo, which looks considerably worse than the banding it was meant to fix.
Dithering can make your file bigger
This is the most counter-intuitive thing about compressing a PNG, and the reason the COMPRESS tab ships dithering off by default.
Dithering fixes banding. When you quantize a smooth gradient to 32 colours you get visible stripes; Floyd–Steinberg error diffusion pushes each pixel's rounding error onto its neighbours, trading those stripes for fine noise that the eye blends back into a smooth ramp. It genuinely looks better.
It also destroys your compression ratio. DEFLATE saves space by finding repetition — runs of identical bytes, and sequences it has seen before. A flat region of one palette index compresses to almost nothing. Dithering deliberately replaces that flat region with a high-frequency pattern of alternating indices, which is precisely the input DEFLATE cannot pack.
On a photograph the trade is usually worth it and costs 2–5%. On a screenshot it is a catastrophe, because a screenshot is mostly large flat areas of UI colour with no texture to hide the noise in. Quantizing a 171 KB iOS screenshot to 96 colours with dithering produced a 257 KB file — half again larger than the original it was supposed to compress, while also looking worse.
Without dithering, the same image at the same 96 colours came out at 43.8 KB. That is not a small difference in tuning; it is the difference between the feature working and the feature being actively harmful, decided by one checkbox.
Why this runs in your browser
The usual justification for uploading an image to be compressed is that compression is expensive. For this, it isn't. Measured end to end in a browser on a 3-megapixel image — median cut, remap every pixel, deflate, count the bytes — the whole pipeline runs in 21 to 33 milliseconds. That is faster than the network round trip to any server you could send it to.
So the upload buys you nothing, and costs you three things. A copy of your image on
someone else's disk, under whatever retention policy you did not read. A queue, and a
file size limit. And — the one people forget — everything your image is carrying
besides pixels: a PNG can hold an eXIf block with
the GPS coordinates of where it was
taken, the device that took it, and timestamps. You hand all of that over before
the compressor has done anything.
Here the file never leaves the tab. The decode, the histogram, the quantization and the re-encode all happen in a Web Worker in your own browser, and the only DEFLATE implementation involved is the one already built into the platform:
const deflate = async (raw) => {
const stream = new Blob([raw]).stream().pipeThrough(new CompressionStream('deflate'));
return new Uint8Array(await new Response(stream).arrayBuffer());
};
CompressionStream('deflate') emits exactly the zlib-wrapped stream that
IDAT expects, so there is no compression library to ship, audit, or keep
up to date.
There is one honest consequence of compressing this way, and it deserves stating
rather than burying. Because the output is rebuilt from pixels, it contains only
IHDR, PLTE, tRNS, IDAT and
IEND — every metadata chunk is gone. That is usually
what you want from something you are about to post publicly. If instead you want the
metadata stripped while the pixels stay byte-for-byte identical, that is a different
operation, and it lives one tab over:
CHUNKS → Download clean copy performs
pure byte surgery and never re-encodes the image at all.
The measurements
Both files run through the shipping code in headless Chromium. "Total" is palette selection, remapping every pixel, and deflating — everything between moving the slider and the number on screen.
IMG_1667.PNG — an iOS screenshot, 1170×2532, 171.0 KB
colours dither size vs orig total
16 none 34.6 KB −80% 28ms
32 none 38.9 KB −77% 27ms
64 none 41.6 KB −76% 27ms
96 none 43.8 KB −74% 26ms
256 none 47.2 KB −72% 27ms
16 FS 69.0 KB −60% 74ms
96 FS 257.1 KB +50% 144ms ← dither on a flat image
blue-marble.png — a photograph, 1000×1025, 469.5 KB
colours dither size vs orig total
16 none 193.6 KB −59% 33ms
32 none 245.7 KB −48% 28ms
64 none 300.5 KB −36% 24ms
96 none 338.4 KB −28% 23ms
256 none 456.4 KB −3% 21ms
96 FS 359.2 KB −23% 46ms ← dither on a photo: normal cost
Two things to read out of that. The screenshot compresses far better than the photograph at every setting, because flat UI colour is exactly what a palette represents well. And on the photograph, 256 colours saves almost nothing (−3%) — past about 128 colours you are paying for palette entries that DEFLATE was already handling for you. The useful range is the low end, which is why the quality slider is curved to spend most of its travel there rather than in the dead zone above 128.