PlanVortex
  • Product
  • Developers
  • Pricing
  • Log in
  • Get started
Product
PublishMulti-network schedulingAccount connection
IntelligenceAI assistantAnalytics
ExploreSee the whole productUse casesComparisonResources
Developers
BuildDeveloper APIAPI documentationOpenAPI (openapi.json)
LibrariesNode libraryPython library
AgentsMCP serverFor AI agents
PricingLog inGet started
Language
Home/Resources/Character limits across social APIs: .length lies, and it lies both ways
Engineering

Character limits across social APIs: .length lies, and it lies both ways

8 min read·Published on Sep 16, 2026·Reviewed on Oct 1, 2026
Also inEspañol
The same short text measured by three different rulers, each one giving a different number

"👨‍👩‍👧‍👦".length is 11. That emoji is one character on screen, seven Unicode code points, and 25 bytes on the wire. Which of those numbers a social network measures your post against depends on the network. If you pick the wrong one, you either reject posts that would publish fine or accept posts the API will refuse hours later, once they are already scheduled.

We learned what follows building a publishing API one network at a time, and the numbers were checked against the real APIs, not copied from their documentation.

PlanVortex is free for two accounts, with the API and the MCP server included.

Start free

How many ways are there to measure a string?

Four: UTF-16 code units, code points, graphemes and bytes. Three of them matter for social network limits; code points matter for none.

const seg = new Intl.Segmenter(undefined, { granularity: "grapheme" });

const text = "👨‍👩‍👧‍👦";
text.length                                  // 11 (UTF-16 code units)
[...text].length                             //  7 (Unicode code points)
[...seg.segment(text)].length                //  1 (graphemes)
new TextEncoder().encode(text).length        // 25 (UTF-8 bytes)
Text .length code points graphemes bytes
👨‍👩‍👧‍👦 11 7 1 25
🙂 2 1 1 4
é 1 1 1 2
漢 1 1 1 3

The family emoji is four people joined by three zero-width joiners, which is why spreading the string (usually the first trick people try) gives 7 and not 1.

Which unit does each network count in?

Network Limit Unit
Facebook 63,206 UTF-16
YouTube (description) 5,000 UTF-16
WhatsApp 4,096 UTF-16
Telegram 4,096, or 1,024 with media UTF-16
Slack 4,000 UTF-16
LinkedIn 3,000 UTF-16
Instagram 2,200 UTF-16
TikTok 2,200 UTF-16
Discord 2,000 UTF-16
Threads 500 graphemes
X 280 UTF-16
Bluesky 300 and 3,000 graphemes and bytes

Most networks count UTF-16 code units, which is exactly what .length returns. Because it is right so often, it is hard to suspect it on the two networks where it is wrong.

TikTok has a trap of its own: its app lets you type 4,000 characters, and its Content Posting API rejects past 2,200. The number that matters is the one for the endpoint you call, not the one in the network's own UI.

How does Bluesky measure text?

With two limits at once, 300 graphemes and 3,000 bytes, and a post has to pass both. It is the only network with two text limits alive at the same time.

.length gets both wrong, in opposite directions:

  • It over-counts emoji. 121 family emoji are 121 graphemes, comfortably inside 300, so they should publish. .length says 1,331 and rejects them.
  • It under-counts everything non-Latin. Those same 121 emoji weigh 3,025 bytes, over the byte limit: counted as characters they fit, and the network rejects them. A long text in Japanese does the same thing, at three bytes per character, and a post that looks short blows the byte ceiling.

So the check has to be two checks:

const graphemes = countGraphemes(text);           // Intl.Segmenter
const bytes = countBytes(text);                   // Buffer.byteLength / TextEncoder

if (graphemes > 300 || bytes > 3000) {
    // reject, and tell the user WHICH of the two it was
}

The message has to say which of the two limits was hit: "Too long" in front of a counter that says 180 characters is an error the user has no way to fix.

Which unit does Threads count in?

Graphemes, and we know because we measured. The documentation says "500 characters" and adds that emoji count as UTF-8 bytes: two plausible readings that disagree. So we sent two probes at a real account:

  • 400 × 🙂: 400 graphemes, 800 UTF-16 units, 1,600 bytes → publishes.
  • 400 × é: 400 graphemes, 400 UTF-16 units, 800 bytes → publishes.

Both are over 500 in at least one of the other two units, so neither UTF-16 nor bytes can be the ruler.

Before that measurement our validation used .length, so a post of 300 emoji was rejected as too long while the network would have accepted it. Of the two possible mistakes, that is the expensive one. A post the API refuses leaves an error you can read; a post your own product refuses leaves a user staring at a counter that says 300 of 500 and a message saying it is too long.

Why does Telegram change its limit when you attach a photo?

Because the text moves to a different call. The same composer field has two numbers:

  • 4,096 with no attachment, because the text goes out as sendMessage.
  • 1,024 the moment there is a photo or a video, because then the text is the caption of sendPhoto, sendVideo or sendMediaGroup.

In a publishing tool this is the normal flow: someone writes 2,000 characters, likes it, drags in an image, and the post that was valid a second ago now fails with a 400 at publish time. The counter in the composer has to change the instant the file is attached, and the error, if it still happens, has to say that the limit dropped because of the file.

What happens if you go over Slack's limit?

No error comes back. The limit is 4,000 characters for chat.postMessage, and if you go over, Slack truncates the message or splits it into several. Your dashboard shows one published post and the channel holds two.

Slack text also has to be escaped (&, < and > are the start of Slack's own entities, and an unescaped < swallows the rest of the sentence), and escaping changes the length: & becomes &amp;, five characters where there was one. So the 4,000 check counts the user's text, because that is what the composer counts, while any safety trim measures the escaped string, and has to cut without leaving half an entity behind. Cutting at character 4,000 flat once published a visible &am.

How do you count each unit?

Graphemes with Intl.Segmenter, bytes with Buffer.byteLength:

/** Graphemes: what a person calls "a character". */
export const countGraphemes = (text: string): number =>
    [...new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(text)].length;

/** UTF-8 bytes: what the wire carries. */
export const countBytes = (text: string): number => Buffer.byteLength(text, "utf8");

Intl.Segmenter needs your TypeScript lib at ES2022 or later (ours is pinned there while target stays lower, purely for this). And segmenting has a cost: it allocates a segment per grapheme, so count once and pass the number around rather than calling it in a loop over a thousand posts.

Where should per-network limits live?

In one place, owned by whoever validates them, and published over the API. Every bug above is cheap to fix once. The expensive one is the screen that counts and the server that validates keeping separate copies of the numbers.

That is how ours drifted: the composer counted LinkedIn to 3,000 while the server rejected at 1,300 (a number left over from an endpoint we no longer called), and the user found out at publish time. We fixed it by keeping the numbers in one place, the server, which publishes them:

curl https://api.planvortex.com/v1.0.0/social_limits \
  -H "Authorization: Bearer $TOKEN"

That endpoint returns the character limit, the byte limit, the title limit, image counts, video duration and file size for every network, so a composer never has to hardcode any of them.

If you are building the same thing, that is the piece most worth copying. If you would rather not build it, PlanVortex's API does the counting for you, in each network's unit, before anything reaches the network.

And if you publish from the dashboard, the composer counts in those same units, network by network, as you type.

Try it with your own accounts

Connect your accounts and publish from the dashboard, through the API or from your AI assistant. The free plan covers two accounts and asks for no card.

Start freeSee the plans
Frequently asked questions
Why is JavaScript's .length wrong for social media character limits?

Because .length counts UTF-16 code units, and not every network measures text that way. A family emoji is 11 UTF-16 units, 7 code points, one grapheme and 25 UTF-8 bytes. On Bluesky, which counts graphemes, .length rejects posts the network would happily accept; on that same network's second limit, which is in bytes, .length lets through posts the network rejects. Of the two mistakes, the first is the expensive one: the user is forbidden something publishable with no way to understand why.

Which social networks count graphemes instead of characters?

Bluesky and Threads. Bluesky's 300-post limit is 300 graphemes, and Threads' 500 is 500 graphemes (measured against the real API, not read in the docs). Every other network we publish to measures in UTF-16 code units, which is exactly what .length returns, so using Intl.Segmenter there would break them in the opposite direction.

Does Telegram have one character limit or two?

Two, for the same field, and which one applies depends on whether the post carries a file. Plain text goes out as sendMessage and gets 4,096 characters; the moment there is a photo or a video, that text becomes the caption of sendPhoto, sendVideo or sendMediaGroup and the limit drops to 1,024. A composer has to change its counter when the file is attached, not when publish is pressed.

What happens if you go over Slack's 4,000 characters?

No error comes back. Slack does not reject a longer text: it truncates it or splits it into several messages. Your dashboard would show one published post and the channel would hold two, with no error anywhere to explain it.

Where should per-network limits live in an integration?

In one place, owned by whoever validates them, and published over the API. If the composer keeps its own copy of the numbers they drift: ours counted LinkedIn to 3,000 while the server rejected at 1,300, and the user only found out at publish time. PlanVortex exposes them at GET /social_limits so the screen counting and the server validating can never disagree.

FJ
Francisco José Fernández-Medina López

Software developer and entrepreneur. Builds PlanVortex, the API software companies use to put social publishing inside their own product, and the dashboard brands and agencies use to run their social media without writing code. A parent and a video game enthusiast.

LinkedIn profile
All resources
PlanVortex
FacebookTwitterInstagramLinkedin

Product

  • Overview
  • Use cases
  • Scheduling
  • AI and planner
  • Analytics
  • Account connection
  • Pricing

Developers

  • For developers
  • API documentation
  • For AI agents
  • Node library
  • Python library
  • MCP server
  • OpenAPI (openapi.json)

Comparisons

  • All comparisons
  • Metricool alternatives
  • Buffer alternatives
  • Hootsuite alternatives
  • Ayrshare alternatives

Company

  • About PlanVortex
  • Contact
  • Resources and blog
  • Affiliate programme
  • Trust and security
  • Data processing agreement (DPA)
  • Privacy
  • Terms

Networks and integrations

  • Facebook
  • Instagram
  • X
  • LinkedIn
  • TikTok
  • WhatsApp
  • YouTube
  • Bluesky
  • Discord
  • Telegram
  • Threads
  • Slack
  • Pinterest
  • Google Drive
  • RSS and WordPress
  • WooCommerce
Developed by Talia Softworks S.L.
Phone: 640 29 96 58