Social media publishing limits, and what the API docs get wrong
We publish to nine networks, so we had to write a validator for 41 separate limits. Most of them are the documented numbers. Two are not, and the four findings below are the reasons posts fail after they look fine in the editor.
These are the 41 limits our own validator checks a post against before it leaves the composer, which is why a post that would be rejected fails here, with the reason, rather than at a network's API an hour after you scheduled it. They run from 280 characters on X (Twitter) to 63,206 on Facebook, a spread of 226x, which is the whole reason one draft cannot go to nine networks unedited.
Pre-publish limits Content Drifter enforces on each network
Network
Characters
Hashtags
Images
Video
Media required
Instagram
2,200
30
10
15 min
Yes
TikTok
2,200
5
video only
10 min
Yes
X (Twitter)
280
5
4
2m 20s
No
Facebook
63,206
10
10
20 min
No
LinkedIn
3,000
5
20
30 min
No
Threads
500
5
20
5 min
No
YouTube
5,000
15
video only
12h
Yes
Pinterest
800
5
1
15 min
Yes
Bluesky
300 graphemes
5
4
1 min
No
A post with no image or video is refused outright on Instagram, TikTok, YouTube, Pinterest. Threads (5) is the one network where we also cap how many distinct URLs the body can carry. Bluesky counts graphemes rather than characters, so an emoji costs one, not four.
What this table is not: a mirror of every rule each network applies. It is the set we check first, and a network can tighten a limit without announcing it. When one of ours turns out to be wrong, the publish attempt still fails and the error comes back to the post, rather than the post being marked sent.
Four things the documentation does not tell you
Bluesky’s 300 is graphemes, not characters
A JavaScript string length is not what the AT Protocol counts. An emoji assembled from several code points, or a letter with a combining mark, is one grapheme to Bluesky and four or more to String.length. A post measures 298 in your editor, passes your validator, and is rejected at the protocol boundary, which is the worst place to find out because the user has already scheduled it.
We count with Intl.Segmenter before anything is sent, and fall back to code-point length where it is unavailable, which is slightly conservative for emoji that join with zero-width joiners. Conservative is the correct direction to be wrong in here.
Threads counts unique URLs, not links
Threads has capped links at five since December 2025, and the cap is on DISTINCT urls. Meta's THREADS_API__LINK_LIMIT_EXCEEDED counts unique URLs rather than link occurrences, so the same link repeated six times is one URL and passes, while six different links fail. A validator that counts matches rejects valid posts; one that counts links without normalising case lets invalid ones through.
We dedupe case-insensitively, match bare www. URLs as well as full ones, and stop at punctuation that is usually a sentence boundary while keeping trailing slashes, query strings and fragments inside the URL.
Truncating to a limit produces valid posts nobody meant to write
Fitting text to a character cap is where most tools stop thinking, and it is where the worst output comes from. Trim a 400-character draft that opens "Hi." down to X's limit by cutting at the last sentence end and you get a post that reads "Hi.". It is a complete sentence. It passes every check. It is not the post the user wrote.
So our fitter returns how much of the original survived alongside the text, and a caller can refuse to ship a stub rather than publishing something technically valid. Completeness is not the useful signal; the ratio is.
The one we missed for 52 days
Every rule above is one we enforce. Here is one we did not. Our publish pipeline marks a row publishing, calls the platform, and settles it to success or failed. If the process dies between those two steps, or the provider drops the request, nothing settles it, because there was no timeout anywhere.
On 1 August 2026 we found two rows in that state on production. One had been there for 52 days. The row was not the cost. The cost was that the calendar rendered "Publishing…" with a spinner indefinitely, so the user believed work was in progress that had stopped seven weeks earlier; that the retry control only picks up failed rows, so the one recovery available could not see it; and that every count of how many publishes failed was quietly wrong.
A wedged row is failed, not lost. A reaper now flips it with an explicit reason, which puts it back within reach of the retry path that already existed rather than inventing a new one. If you are building this, put a timeout on the transient state before you need one.
How to use this, and how not to
Every number in the table is generated from the object our publish path reads at runtime, so it cannot describe rules we do not run, and it changes in the same commit our enforcement changes. The dated incidents are literals, because what happened on a date does not move.
What it is not: a mirror of every rule each network applies. These are the checks we run first, chosen because they are the ones we watched fail. A network can tighten a limit without telling anyone, and when one of ours turns out to be wrong the publish still fails and the error comes back to the post rather than the post being marked sent. Cite it as one team's enforced ruleset with a verification date, which is what it is.
What is the character limit on each social network?
▾
X is the tightest at 280 characters and Facebook the loosest at 63,206, with Bluesky at 300 and LinkedIn at 3,000 in between. The full table is below and it is generated from the rules our publisher runs, not typed by hand, so it moves when our enforcement moves. Verified 26 August 2026.
Why does my Bluesky post fail when it is under 300 characters?
▾
Because Bluesky counts graphemes, not characters, and a JavaScript string length is neither. An emoji built from several code points is one grapheme to Bluesky and four or more to String.length, so a post can measure 298 in your editor and be rejected at the AT Protocol boundary. We hit this, and we now count with Intl.Segmenter before anything is sent.
How many links can a Threads post contain?
▾
Five unique URLs, a cap Threads introduced in December 2025. The word unique is the part that catches people out: Meta’s THREADS_API__LINK_LIMIT_EXCEEDED counts distinct URLs rather than link occurrences, so the same link six times is one URL and passes, while six different links fail. We dedupe case-insensitively before counting.
Which networks refuse a post with no image or video?
▾
Instagram, TikTok, YouTube, Pinterest reject a text-only post outright, so we refuse it in the composer rather than letting it fail at the API. Two of those four take no still image at all and want video: a text post and a photo post both fail there for the same reason.
Is this table the complete set of rules each network applies?
▾
No, and treating it as one will cost you. These are the checks OUR publisher runs before a post leaves the composer, chosen because they are the ones we saw fail in production. A network can tighten a limit without announcing it, which is how we ended up writing most of this page.