TL;DR, Quick Answer
7 min readBluesky caps a post image at 2,000,000 bytes, a number written as maxSize in the app.bsky.embed.images lexicon. That is two million bytes, not 2 MiB, so an image your file manager calls 1.95 MB is already over. Avatars, banners and link card thumbnails are held to half that, 1,000,000 bytes. The Bluesky app never sends your original file: it re-encodes every post image to JPEG at 4,000 pixels or less and searches for a quality setting that fits under the cap.
What is the Bluesky image size limit?
Every image attached to a Bluesky post is capped at 2,000,000 bytes, so the Bluesky image size limit is two million bytes rather than the 2 MiB that most file managers mean when they print "2 MB". The number is not buried in a help article. It sits in the protocol schema, in app.bsky.embed.images, as a field called maxSize on the image blob:
"image": {
"type": "blob",
"description": "The raw image file. May be up to 2 MB, formerly limited to 1 MB.",
"accept": ["image/*"],
"maxSize": 2000000
}Two million bytes is 1.907 MiB. An exported JPEG that macOS Finder reports as "1.9 MB" is 1,992,294 bytes or thereabouts and squeaks through; one reported as "2 MB" is 2,097,152 bytes and does not. That gap of 97,152 bytes is where most of the confusion about this limit lives.
The lexicon's own wording preserves the history. It says the blob "May be up to 2 MB, formerly limited to 1 MB", which is why so many third-party size guides still print 1 MB. They were right two years ago and nobody went back to edit them.
Which Bluesky upload gets which limit?
Bluesky does not have one image limit. It has six blob caps declared across six lexicons, and they disagree with each other on purpose.
| Lexicon | What it holds | maxSize | accept |
|---|---|---|---|
app.bsky.embed.images | Post images, up to 4 | 2000000 | image/* |
app.bsky.embed.gallery | Gallery items | 2000000 | image/* |
app.bsky.embed.external | Link card thumbnail | 1000000 | image/* |
app.bsky.actor.profile | Avatar | 1000000 | image/png, image/jpeg |
app.bsky.actor.profile | Banner | 1000000 | image/png, image/jpeg |
app.bsky.embed.video | Video file | 300000000 | video/mp4 |
Two things in that table catch people out. Avatars and banners are capped at half a post image, and they refuse anything that is not PNG or JPEG, so a WebP avatar that uploads fine as a post image is rejected on your profile. And the video blob is 300,000,000 bytes, with its own description noting it was "formerly limited to 100mb", which is the same pattern as the image cap.
The newer app.bsky.embed.gallery lexicon allows a maxLength of 20 items, but its own schema comment tells clients to hold back: "The schema-level maxLength of 20 is a future-proof ceiling. Clients should currently enforce a soft limit of 10 items in authoring UIs." The older app.bsky.embed.images embed stays at 4.

What does the Bluesky app do to your image before uploading?
The official client never sends your original file. Before it calls uploadBlob, the composer runs compressImage against a config in src/lib/constants.ts:
export const IMAGE_SIZE_CONFIG_POSTS = {
maxDimension: 4000,
maxSize: 2000000,
};That function does not check whether your file is already small enough. It re-encodes unconditionally, always to JPEG, and binary searches for a quality level that lands under 2,000,000 bytes. It starts at quality 51, moves up if the result fits and down if it does not, and stops once the search window closes.
When an image resists compression, the code refuses to keep dropping quality. The comment in the source spells out the rule: "binary search will check 51, 26, 13(rounded). We don't want to go below 25, so if we've halved to 13, reset the loop and reduce the image dimensions instead." Each reset multiplies the working dimension by 0.8, which walks 4000 down to 3200, then 2560, then 2048, then roughly 1638 pixels. Four resets is the ceiling, and past that the upload fails with Unable to compress image.
Three consequences follow from the fact that every post image is re-encoded to JPEG. Transparency is gone, because JPEG has no alpha channel, so a PNG logo on a transparent background arrives with a solid one. Sharp text and flat color pick up ringing artifacts that the same file would not show on a platform that passes PNG through. And your careful export settings are discarded, since the client picks its own quality number regardless of what you chose. The same re-encode also fills in the aspectRatio field from the compressed output rather than from your original, and that field is two integers with a minimum of 1, not a float.
Why does an oversized image sometimes upload anyway?
Because the blob upload and the record write are two different requests with two different limits, and only the second one reads the lexicon.
The com.atproto.repo.uploadBlob endpoint enforces the server's own ceiling, which in the reference PDS defaults to 5 * 1024 * 1024, or 5,242,880 bytes, configurable through the PDS_BLOB_UPLOAD_LIMIT environment variable. Exceed that and the stream aborts with Max size of 5242880 bytes exceeded. Stay under it and the blob is accepted even at 4 MB, well past the lexicon's 2,000,000.
The lexicon spells out where the real check happens. Its description reads: "The blob will be deleted if it is not referenced within a time window (eg, minutes). Blob restrictions (mimetype, size, etc) are enforced when the reference is created." So a 4 MB image uploads successfully, sits in temporary storage, fails validation when you try to attach it to a post, and is garbage collected minutes later. Anyone posting through a custom client rather than the official app hits this ordering and reads the successful upload as a green light.
That same endpoint carries a rate limit of 1,000 points per day, which puts a hard ceiling on how many images one account can push in 24 hours. It is one of several caps worth reading alongside the rate limits the AT Protocol applies to writes if you publish on a schedule.
AdaptlyPost
Start 7-Day FREE Trial
All-platform analytics
Social Inbox
AI-powered assistant

What dimensions should you export for Bluesky?
Nothing in the lexicon constrains pixel dimensions. There is no minimum width, no maximum height, and no required aspect ratio anywhere in app.bsky.embed.images. The only dimension number that exists is the client's own maxDimension: 4000, and that is a resize trigger rather than a rejection.
The practical advice that follows is short. Export at 2,000 pixels on the long edge or less, so the client has no reason to resize and no reason to drop quality hard. Use JPEG yourself, since the app will convert to JPEG regardless and your encoder is better tuned than a quality binary search. Keep the file under about 1.8 MB to leave room for the re-encode. If you need transparency or crisp text, flatten the image onto the background color you want before uploading, because the alternative is letting the JPEG encoder pick for you.
Alt text has no declared cap in the lexicon at all. The official app enforces its own MAX_ALT_TEXT = 2000, which is a client rule rather than a protocol one, so other clients set their own. That mirrors the way Bluesky handles links and mentions, where the byte offsets in a post's facets are the protocol's concern and the rendering is the client's.
If you are comparing specs across networks before building an export preset, the arithmetic on Instagram's image dimensions works differently, since Meta resizes server side rather than making the client do it. And if you are queueing images ahead of time, the compression step is part of scheduling posts to Bluesky whether you run it yourself or let a client run it for you.
Frequently asked questions
Is the Bluesky image limit 1 MB or 2 MB?
It is 2,000,000 bytes. The 1 MB figure was correct before the cap was raised, and the lexicon still records the change in its own description: "May be up to 2 MB, formerly limited to 1 MB." Guides that print 1 MB have not been updated since the change.
How many images can one Bluesky post hold?
Four, set by "maxLength": 4 on the images array in app.bsky.embed.images. The newer app.bsky.embed.gallery lexicon raises the schema ceiling to 20 while instructing clients to enforce a soft limit of 10.
Does Bluesky accept PNG and WebP?
The post image blob declares "accept": ["image/*"], so any image MIME type passes protocol validation. The official app converts everything to JPEG before upload, so a PNG you send arrives as a JPEG. Avatars and banners are stricter and accept only image/png and image/jpeg.
What is the avatar and banner size limit on Bluesky?
Both are 1000000 bytes in app.bsky.actor.profile, half the post image cap. Feed generator avatars and list avatars in app.bsky.feed.generator and app.bsky.graph.list use the same 1,000,000 figure.
Why did my image upload succeed but my post fail?
The upload endpoint checks the server's blob limit, which defaults to 5,242,880 bytes, and the lexicon's 2,000,000 cap is checked later when the blob is referenced by a record. An image between those two numbers uploads and then fails at post time.
Does Bluesky compress images after upload?
The app compresses before upload, not after. The App View then serves resized derivatives from its CDN for thumbnails and full-size views, and the lexicon notes the served file "May or may not be the exact original blob". The blob stored in your repository is whatever the client sent.
Does Bluesky compress an image I already sized under 2,000,000 bytes?
The composer runs compressImage on every post image regardless of the starting size, and re-encodes it to JPEG before the quality search ever begins. The function never checks the file size first, so even an export that already fits gets a fresh, app-chosen JPEG quality.
Why does my transparent PNG lose its background after posting on Bluesky?
JPEG has no alpha channel, and the Bluesky app converts every post image to JPEG before upload, so a PNG's transparent background gets filled with a solid color. This happens even when your original export looked correct, because compressImage re-encodes every image without exception.
Does Bluesky store the aspect ratio from my original image file?
The app fills in the aspectRatio field from the compressed JPEG output, not from your original file, since the ratio is calculated after the resize and quality search finish. That field stores two integers with a minimum of 1 each, not a decimal ratio.
Is there a character limit on Bluesky alt text?
The app.bsky.embed.images lexicon sets no cap on alt text length at all. The official app enforces its own client-side rule, MAX_ALT_TEXT = 2000, which is an app choice rather than a protocol limit, so other clients can set their own ceiling.
AdaptlyPost
Start 7-Day FREE Trial
All-platform analytics
Social Inbox
AI-powered assistant
Put this into practice with AdaptlyPost
Was This Article Helpful?
Let us know what you think!
See us more often in Google
One click marks AdaptlyPost as a preferred source, so our articles sit higher in your Top Stories, AI Mode, and AI Overviews.
Before you go...
AdaptlyPost
Schedule your content across all platforms
Manage all your social media accounts in one place with AdaptlyPost.
All-platform analytics
Social Inbox
AI-powered assistant
Related Glossary Terms


The Two Records Behind a Bluesky Custom Domain Handle
Your Bluesky custom domain handle needs one of two records: a TXT on _atproto, or plain text at /.well-known/atproto-did. Both values, plus the reserved TLDs.


Why the Threads Character Limit Counts Emoji as UTF-8 Bytes
The Threads character limit is 500, but Meta counts each emoji as its UTF-8 byte length, so one family emoji costs 25. Here is how to count a post correctly.


Why the TikTok Caption Character Limit Is Measured in UTF-16 Runes
The TikTok caption character limit is 2200 UTF-16 runes for video and 90 for a photo title. Runes are not characters, and one emoji can cost eleven.
Related Articles


How a Bluesky Feed Generator Works, From Lexicon to Live Feed
A Bluesky feed generator is one HTTPS service answering one XRPC query. The lexicons, the DID document entry, the JWT, and the gaps in the docs.


Why the Accounts Engaged Instagram Metric Is Not the Same as Interactions
The accounts engaged Instagram metric counts unique accounts, not actions, and the API field names no longer match the labels in the Instagram app.


Why Bluesky Facets byteStart byteEnd Count Bytes, Not Characters
Bluesky facets byteStart byteEnd offsets count UTF-8 bytes, not JavaScript UTF-16 indexes. The lexicon warning, a worked example, and code that gets it right.

