PlanVortex
  • Product
  • Developers
  • Pricing
  • Login
  • 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
PricingLoginGet 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
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 — and if you pick the wrong one, you either reject posts that would publish fine or accept posts the API will refuse hours later, when the thing is already scheduled.

This is what we learned building a publishing API against thirteen networks, with the numbers taken from the real APIs rather than from their documentation.

Three units, one string

Every discussion about character limits is really a discussion about units. There are three that matter, and a fourth that looks useful and is not:

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 — the trick everyone reaches for first — gives 7 and not 1. Code points are never the answer to anything here.

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. That is the trap: .length is right often enough that you stop questioning it, and then you meet the two networks where it is wrong.

TikTok's row hides a second lesson. Its app lets you type 4,000 characters; its Content Posting API rejects past 2,200. The number that matters is the one belonging to the endpoint you actually call, never the one in the network's own UI.

Bluesky counts two limits at once, in different units

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

.length fails both, 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, which is over the byte limit. Counting characters alone says they fit; the network says no. A long text in Japanese does the same thing: 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
}

Telling the user which limit they hit is not a nicety. "Too long" in front of a counter that says 180 characters is an unanswerable error message.

Threads counts graphemes, and we only know because we measured

The documentation says "500 characters" and adds, unhelpfully, that emoji count as UTF-8 bytes. Two readings, both plausible, and they 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. It counts graphemes.

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

Telegram changes its limit when you attach a photo

Same text field, 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.

This is not an edge case in a publishing tool, it is the normal case: 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.

Slack does not fail, which is worse

Slack's limit is 4,000 characters for chat.postMessage, and going over does not produce an error. It truncates the message, or splits it into several. Your dashboard shows one published post; the channel holds two; nothing failed anywhere.

Slack has a second trap in the same area. Its text 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 to count each unit properly

/** 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");

Two practical notes. Intl.Segmenter needs your TypeScript lib at ES2022 or later — ours is pinned there while target stays lower, purely for this. And segmenting is not free: 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.

The rule that prevents all of this

Every bug above is cheap to fix once. The expensive one is structural: the screen that counts and the server that validates keeping separate copies of the numbers.

Ours drifted exactly that way. 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, which is the worst moment available. The fix was not a better number. It was one source of truth, owned by whoever validates, and published:

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 a single one of them. Whoever enforces a limit is who gets to announce it.

If you are building the same thing, that is the piece worth copying even if you take nothing else from this article. And if you would rather not build it at all, PlanVortex's API does the counting for you — in the right unit, per network — before anything reaches the network.

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

Because .length counts UTF-16 code units, and only some networks measure 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. Both directions are wrong, and the first one 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?

Nothing fails, which is the problem. 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. He builds PlanVortex, the API other software companies use to integrate publishing to Facebook, Instagram, X, LinkedIn, TikTok, YouTube and WhatsApp inside their own product. A parent and a video game enthusiast.

LinkedIn profile
All resources
PlanVortex
FacebookTwitterInstagram

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
  • PlanVortex vs Zernio
  • PlanVortex vs Metricool
  • PlanVortex vs Postiz
  • PlanVortex vs Ayrshare

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
  • Google Business
  • Bluesky
  • Discord
  • Telegram
  • Threads
  • Slack
  • Google Drive
  • RSS and WordPress
Developed by Talia Softworks S.L.
Phone: 640 29 96 58