A cosmic ray, a dying SSD sector, a truncated upload, a bad cable. Somewhere in your storage, one bit in one image file flips from a 0 to a 1. What happens to the picture?
The honest answer is that it depends entirely on the format, and the differences are much stranger than "some are more robust than others". I encoded the same 512×512 image as PNG, JPEG, GIF, WebP, AVIF and BMP, flipped exactly one bit in each file, and decoded the result. Four hundred times per format.
Two formats fail in ways that are almost opposites, and the one that looks healthier is the one I would trust least.
PNG refuses to show you anything
Start with the result that surprised me most. Out of 400 single-bit flips in a PNG file:
dead: 400 changed: 0 identical: 0
Every single one. Pillow would not decode any of them. I assumed this was a quirk of one library being strict, so I ran the same corrupted files through OpenCV, which uses libpng underneath, and got the same answer: 120 out of 120 refused, with libpng naming the reason.
libpng error: IDAT: CRC error
libpng error: bad adaptive filter value
libpng error: IDAT: invalid bit length repeat
That is the format working exactly as designed. PNG stores a CRC32 for every chunk, so a flipped bit is detected rather than absorbed, and the image data is one DEFLATE stream, so a flip that gets past the checksum desynchronises the decompressor and everything after it is garbage.
Unless a browser is doing the decoding
Then I served those same rejected files to Chromium, and it rendered 10 out of 10.
Not fully, though, and this is the interesting part. I drew each one to a canvas and compared it with the original pixel by pixel. The browser painted a median of 48.6% of the image — and every pixel it painted matched the original. It decodes as far as the damage, stops there, and leaves the rest blank.
So a bit-rotted PNG shows most users a half-drawn picture and hands your backend an exception. Your thumbnailer, your CI, your image pipeline all reject the file that your visitors can partly see.
JPEG hands you a whole picture that is wrong everywhere
Now the same test on the lossy formats, measured the same way in the browser:
| format | browser refused | median % of image painted | median % of pixels matching the original |
|---|---|---|---|
| PNG | 0 of 10 | 48.6% | 48.6% |
| JPEG | 0 of 10 | 100% | 3.6% |
| WebP | 0 of 10 | 100% | 2.3% |
| GIF | 0 of 10 | 100% | 11.8% |
| AVIF | 1 of 10 | 100% | 2.3% |
| BMP | 0 of 10 | 100% | 100% |
Read the last column carefully. JPEG produced a full, complete, confident image in which 96% of the pixels are not the colour they should be.
Look back at the screenshot and you will see why that is easy to miss: the corrupted JPEG looks fine. The damage lands in a DC coefficient, the decoder happily carries the error forward through the rest of the scan, and what comes out is a picture with a slightly wrong cast across the whole frame. Nothing about it says "damaged". It just is.
PNG gives you half a picture and tells you the truth about it. JPEG gives you a whole picture and quietly lies.
The one that barely notices
BMP sat at 100% painted and 100% matching, and that is not an error in the table. It stores raw pixels with no compression, no checksum and no entropy coding, so one flipped bit changes one colour channel of one pixel out of 262,144. In 400 trials the median damage was too small to register.
The format with no error detection at all is the one that degrades most gracefully, because there is no shared state for the error to propagate through. Compression is what turns one bad bit into a ruined file.
Then I deployed it, and the platform had its own opinion
I wrapped the experiment in a small app so the damage is visible rather than described, and put it on DigitalOcean's App Platform straight from a public git repo — no registry, no CI, just a spec and a clone URL. It built and went live in about two minutes.
And immediately returned a 500 for one format:
File "/workspace/main.py", line 18, in encode
im.save(buf, format=pil, ...)
KeyError: 'WEBP'
The Pillow build in that Python buildpack has no WebP encoder. My first fix made it worse, because I guarded the format list with features.check("webp") — which reports decode support and returns True on a build that cannot write the format at all. The correct test is whether the format is in Image.SAVE, the registry Pillow actually consults when saving. With that, the app drops WebP from the list and serves the four it can encode.
That is a useful thing to have learned by accident: the experiment ran perfectly on my own machine, and it took a different Python build, on someone else's infrastructure, to reveal that my capability check had been asking the wrong question the whole time.
What I would actually do with this
If an image matters and you will process it later, store a hash beside it. Not because PNG lacks checksums — it has excellent ones — but because JPEG, WebP and AVIF will not tell you anything is wrong, and by the time a human notices the colours are off, the original is long gone.
If images flow through a pipeline of any kind, it is worth decoding them at the point they arrive and treating a decode failure as a real error rather than something to skip past, because the formats in that table split neatly into two groups and only one of them will ever complain. A PNG will stop your job with an exception, which feels like the annoying case until you notice that JPEG, WebP and AVIF simply handed you a complete image and said nothing at all. A decoder returning successfully tells you the file parsed, and on three of the five formats here it tells you nothing whatsoever about whether the bytes are the ones you stored.
There is one more practical wrinkle I ran into while setting the browser test up. My first pass counted an image as "rendered" whenever naturalWidth came back above zero, which is the usual way to check, and by that measure every corrupted PNG rendered perfectly. Drawing them to a canvas and counting pixels is what showed that half the frame was never painted at all. The flag was telling me the decode had begun, not that it had finished, and I had been about to write down that browsers shrug off PNG corruption entirely.
So if a bug report ever says an image looks fine in the browser but breaks the processing job, both halves can be true at once. Chromium paints what it can and stops; libpng refuses the file outright; neither is malfunctioning. That was worth an hour of my afternoon to work out, and the numbers above are what came of it.
The demo, the scripts and the raw numbers are at github.com/DimitrovK/bitrot-demo. The app that produced the screenshot ran on App Platform's smallest instance and has been taken down.

Top comments (0)