What Bluesky actually does to your pixel art
Nobody scrolling past your post sees the PNG you uploaded. We downloaded our own sprites back off the CDN and measured what they see instead.
Measured 2026-09-18 against three of our own live posts. Method and limits at the bottom. No signup, no email, nothing to buy on this page.
the short version
- Your PNG is re-encoded to lossy WebP. Not resized, not optimised — re-encoded. A 640×640 sprite we uploaded as a 14,217-byte PNG is served as a 3,388-byte WebP.
- Flat colour stops being flat. That sprite had 22 unique colours going in. It comes back with 3,040. Every flat fill is now a faint gradient.
- The in-feed thumbnail caps the long edge at 1000px. Our 1200×630 card is served to the feed at 1000×525 — a ×0.8333 resample, which is the one scale factor pixel art cannot survive.
what we measured
Bluesky stores the blob you upload and serves two derived renditions: feed_thumbnail (what the feed shows) and feed_fullsize (what you get when you tap the image). We pulled both for three of our own posts and compared them, pixel by pixel, against the files on disk.
| what we uploaded | what the feed serves (feed_thumbnail) |
what a tap serves (feed_fullsize) |
|---|---|---|
| 640×640 PNG, 14,217 b 22 unique colours |
640×640 WebP, 3,388 b 3,040 unique colours |
identical to the thumbnail |
| 1200×630 PNG, 154,163 b | 1000×525 WebP, 15,622 b resampled ×0.8333 |
1200×630 WebP, 18,576 b |
| 800×800 PNG, 20,045 b | 800×800 WebP, 6,942 b 6,972 unique colours |
identical to the thumbnail |
On the two images served at their original size, we could diff the decoded pixels directly against the source:
- 640×640 sprite: 98.3% of pixels changed value, worst single channel off by 69/255.
- 1200×630 card: 99.9% of pixels changed, worst channel off by 89/255, mean change 1.80.
Read that honestly: most of those changes are tiny, one or two values. A photo would survive it without anyone noticing. Pixel art does not, and the reason is the colour count, not the average error.
why 22 colours becoming 3,040 is the whole problem
A pixel-art sprite is built out of flat areas of a small palette. That flatness is the style. Lossy compression works by throwing away exactly the thing pixel art is made of: it assumes neighbouring pixels are related, spends its bit budget on hard edges, and lets flat regions drift by a value or two.
So a 22-colour sprite arrives on someone's screen with 3,040 colours in it. Each flat fill is now a very faint gradient, and every hard edge has a halo of intermediate values ringing around it. At 1× you will not consciously see it. What you see is a sprite that looks slightly cheap and you cannot say why.
Nothing here is Bluesky being careless. Serving 3 KB instead of 14 KB to every scroller is the right call for a feed. It just happens to be the worst possible transform for our medium.
the 1000px cap, and why ×0.8333 is the bad one
Anything wider than 1000px gets resampled down for the feed view. Our 1200px card became 1000px: every 6 source pixels became 5. A sprite drawn at 32px and scaled ×4 to 128px does not divide into that. Individual art pixels end up spread across screen-pixel boundaries, so some of your 4×4 blocks come out 3 wide and some 4, with soft columns between them.
Images at 800px and 640px passed through at their original size. So the cap is somewhere at 1000: under it, your dimensions survive.
what to actually do
-
measured
Keep the long edge at 1000px or under.
Then the feed shows your image at the size you drew, and the only damage is the re-encode. Above 1000 you get the re-encode and a fractional resample. -
measured
Pick a canvas that is an integer multiple of your sprite.
32px sprite, ×8 = 256. ×16 = 512. ×24 = 768. All under the cap, all exact. A 1200px-wide card is the one shape that guarantees a fractional resample. -
reasoning, not measured
Scale up before uploading, not after.
Compression artifacts are sized in screen pixels. If one art pixel is a 1×1 screen pixel, ringing eats the pixel. If it is an 8×8 block, the same ringing lives inside the block and the edge stays where you put it. We have not isolated this one with a controlled upload, so treat it as the mechanism suggesting it rather than a number we have. -
reasoning, not measured
Expect dithering and 1px outlines to take the worst of it.
They are the highest-frequency content in the image, which is precisely what a lossy encoder spends its budget failing at. -
measured
Do not bother hunting for a lossless upload path.
There is not one. Every rendition we pulled, on every post, was WebP. The blob in your repo is still your PNG; the thing humans load is not.
check your own post, two commands
Nothing here needs an account or an API key — the read endpoints are public. Swap in your handle:
curl -s "https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor=YOUR.HANDLE&limit=20" \
| grep -o 'https://cdn.bsky.app/img/feed_thumbnail[^"]*'
That gives you the exact URL the feed loads for each of your images. Then:
curl -s "<that url>" -o served.webp
file served.webp # says WebP, not PNG
sips -g pixelWidth -g pixelHeight served.webp
The size of the file you actually uploaded is public too, in the post record itself — com.atproto.repo.listRecords on your repo returns embed.images[].image.size in bytes. That is where the upload column in the table above comes from, so none of it depends on trusting our copy of the file.
Compare the dimensions against the file you uploaded. If they differ, you are above the cap. If you want the colour count, one line of sharp will decode both and put each pixel in a Set — that is exactly how the numbers on this page were produced.
method, and what this is not
Three images, one account, one day (2026-09-18), pulled from the public CDN and decoded locally. That is a small sample and a single point in time: these renditions are an implementation detail Bluesky can change without telling anyone, and the numbers above will rot. The date is on the page so you know how stale they are, and the commands are here so you can re-run them in thirty seconds instead of trusting us.
We also tried to test the scale question properly, by uploading the same sprite at ×1, ×2, ×4, ×8 and ×16 and diffing the results. It does not work: a blob that is not referenced by a post is not served to the CDN (it returns 504), so there is no way to A/B this without posting five times. Hence "reasoning, not measured" on the two rules above. If you have run that experiment, we would genuinely like to know.