Table of Contents
Bluesky posts are capped at 300 graphemes of text, 4 images (1 MB each), or 1 video (100 MB, 3 minutes). Rate limits allow up to 1,666 post creations per hour per account. Here's every limit you need to know, pulled from the official AT Protocol lexicons and Bluesky documentation.

Text limits
| Limit | Value | Source |
|---|---|---|
| Max graphemes | 300 | app.bsky.feed.post lexicon |
| Max bytes (UTF-8) | 3,000 | app.bsky.feed.post lexicon |
| Text required? | No — empty string allowed if embed is present | app.bsky.feed.post lexicon |
| Rich text | Mentions, links, hashtags via facets | app.bsky.richtext.facet |
The 300-grapheme limit is what most people think of as "300 characters," but it's more nuanced. A Latin character is 1 grapheme. An emoji like 👨👩👧👧 is also 1 grapheme, but it's 25 bytes in UTF-8. You'll hit the byte limit before the grapheme limit if your post is emoji-heavy.
The @atproto/api SDK's RichText class handles grapheme counting and facet detection. If you're building a client, use it instead of String.length (which counts UTF-16 code units, not graphemes).
Image limits
| Limit | Value | Source |
|---|---|---|
| Max images per post | 4 | app.bsky.embed.images lexicon |
| Max file size | 1 MB (1,000,000 bytes) | app.bsky.embed.images lexicon |
| Accepted formats | JPEG, PNG, WebP | app.bsky.embed.images lexicon |
| Alt text | Optional, max 1,000 graphemes | app.bsky.embed.images lexicon |
| Aspect ratio | Optional but recommended | app.bsky.embed.defs#aspectRatio |
Images are uploaded as blobs via com.atproto.repo.uploadBlob, which returns a blob reference you embed in the post record. The 1 MB limit is enforced by the PDS.
If you don't know the aspect ratio, leave the aspectRatio field undefined rather than guessing. Clients will handle it, but wrong ratios cause layout jank.
Video limits
| Limit | Value | Source |
|---|---|---|
| Max videos per post | 1 | app.bsky.embed.video lexicon |
| Max file size | 100 MB | app.bsky.embed.video lexicon |
| Max duration | 3 minutes | Server-side enforcement |
| Accepted format | MP4 only | app.bsky.embed.video lexicon |
| Alt text | Optional, max 1,000 graphemes | app.bsky.embed.video lexicon |
| Captions | Up to 20 VTT files | app.bsky.embed.video lexicon |
| Daily limit | 25 videos or 10 GB total | Bluesky blog (Sep 2024) |
| Email verification | Required before uploading | Bluesky docs |
These limits have changed over time:
| Date | Max size | Max duration |
|---|---|---|
| September 2024 (launch) | 50 MB | 60 seconds |
| March 2025 (v1.99) | 100 MB | 3 minutes |
How video upload works
There are two ways to upload video via the API:
Simple method — upload the file as a blob to your PDS using uploadBlob, then reference it in the post. The downside: the video only starts processing after the post is published, so followers see a broken video for several seconds.
Recommended method — upload directly to video.bsky.app with a service auth token, poll getJobStatus until processing completes, then use the returned blob reference in your post. The video is ready to play the moment the post appears.
// 1. Get a service auth token
const { data: serviceAuth } = await agent.com.atproto.server.getServiceAuth({
aud: `did:web:${agent.dispatchUrl.host}`,
lxm: 'com.atproto.repo.uploadBlob',
exp: Math.floor(Date.now() / 1000) + 60 * 30,
});
// 2. Upload to the video service
const uploadUrl = new URL(
'https://video.bsky.app/xrpc/app.bsky.video.uploadVideo'
);
uploadUrl.searchParams.append('did', agent.session.did);
uploadUrl.searchParams.append('name', 'video.mp4');
const uploadResponse = await fetch(uploadUrl, {
method: 'POST',
headers: {
Authorization: `Bearer ${serviceAuth.token}`,
'Content-Type': 'video/mp4',
},
body: videoBytes,
});
const jobStatus = await uploadResponse.json();
// 3. Poll until processing is done
let blob = jobStatus.blob;
const videoAgent = new AtpAgent({ service: 'https://video.bsky.app' });
while (!blob) {
await new Promise((r) => setTimeout(r, 1000));
const { data } = await videoAgent.app.bsky.video.getJobStatus({
jobId: jobStatus.jobId,
});
blob = data.jobStatus.blob;
}
// 4. Create the post with the processed video
await agent.post({
text: 'Check this out',
embed: {
$type: 'app.bsky.embed.video',
video: blob,
},
});
Rate limits
Bluesky rate limits are per account, not per app or API key. If you're a tool that posts on behalf of multiple users, each user has their own limits.
| Limit | Value | Scope |
|---|---|---|
| Write operations | 5,000 points/hour, 35,000 points/day | Per account (DID) |
| Post creation cost | 3 points | Per record |
| Max posts/hour | ~1,666 | Derived from points |
| Max posts/day | ~11,666 | Derived from points |
| HTTP API requests | 3,000 per 5 minutes | Per IP |
| Session creation | 30 per 5 min, 300 per day | Per account |
The points system covers all record operations, not just posts. Likes, follows, and reposts also cost points (3 for create, 2 for update, 1 for delete).
For most apps, you'll hit the 3,000 requests/5 min IP limit before the per-account write limits. If you're posting for hundreds of users from a single server, space out your API calls.
What you can't do
A few things the Bluesky API doesn't support or handles differently than you'd expect:
- No GIF animation. GIF files upload fine but display as static images. Use MP4 with
presentation: "gif"for looping short clips. - No mixing images and video. A post embed is either
app.bsky.embed.imagesorapp.bsky.embed.video, not both. - No server-side link previews. Unlike Twitter or Facebook, Bluesky doesn't auto-generate link cards. Your client must fetch OG metadata and upload the thumbnail as a blob using
app.bsky.embed.external. - No edit. Posts are immutable records. Delete and re-create.
- No scheduled posts. The API creates posts immediately. Scheduling is your app's responsibility.
Posting to Bluesky the easy way
If you're building a social media tool or just want to post to Bluesky alongside other platforms without dealing with facet parsing, blob uploads, and video processing polling — PublishQ handles all of it.
Write your post once, attach media, and publish to Bluesky, Twitter, Instagram, Facebook, LinkedIn, Threads, TikTok, and YouTube from one API call. PublishQ takes care of rich text detection, image dimension extraction, video preprocessing, and per-platform formatting.
You can also do it from the dashboard UI without writing any code.
For the full list of what's supported on Bluesky, see the Bluesky platform docs.
curl -X POST https://publishq.com/api/v1/posts \
-H "Authorization: Bearer pq_live_..." \
-H "Content-Type: application/json" \
-d '{
"content": "Hello Bluesky! 🦋",
"mediaIds": ["media-abc123"],
"accounts": [
{ "accountId": "bluesky-account-id" },
{ "accountId": "twitter-account-id" },
{ "accountId": "threads-account-id" }
],
"publishNow": true
}'Bluesky's 300-character limit is tighter than most platforms. If your main content is longer, use postOverrides to send a shorter version just to Bluesky while keeping the full text for everything else:
curl -X POST https://publishq.com/api/v1/posts \
-H "Authorization: Bearer pq_live_..." \
-H "Content-Type: application/json" \
-d '{
"content": "We just shipped a huge update to our analytics dashboard. Here is everything that changed and why we made these decisions...",
"accounts": [
{ "accountId": "linkedin-account-id" },
{ "accountId": "facebook-account-id" },
{
"accountId": "bluesky-account-id",
"postOverrides": {
"content": "Shipped a huge analytics update today 📊🦋"
}
}
],
"publishNow": true
}'One request. Three platforms. No blob management. No character-limit headaches.
Start posting to Bluesky with PublishQ →

