string.length is wrong for X and Bluesky. My counter failed 8 of 34 tests against X's own library
I'm Alex. I build Fuxux, a social media scheduler: you write a post once and it goes out to X, Bluesky, Instagram, LinkedIn, Threads and a few more. A scheduler has to answer one boring question before anything is sent:
I'm Alex. I build Fuxux, a social media scheduler: you write a post once and it goes out to X, Bluesky, Instagram, LinkedIn, Threads and a few more.
A scheduler has to answer one boring question before anything is sent: does this text fit? I assumed that was text.length <= limit. It isn't, and the two networks with the smallest limits are the two where it goes wrong.
I also thought I had already solved it. Then I sat down to write this post, checked my own code against X's library to get the examples right, and it was wrong on 8 of 34 strings. So this is the corrected version, with the mistake left in.
Three ways to count one string
Take ok ππ½. That's "ok", a space, and a thumbs up with a skin tone.
const text = "ok ππ½";
text.length; // 7 UTF-16 code units
[...text].length; // 5 code points
[...new Intl.Segmenter(undefined, { granularity: "grapheme" })
.segment(text)].length; // 4 what a person sees
Three honest answers. Which one a network uses decides whether your post is accepted.
| String | length |
X counts | Bluesky counts |
|---|---|---|---|
ok ππ½ |
7 | 5 | 4 |
π¨βπ©βπ§βπ¦ (family) |
11 | 2 | 1 |
πͺπΈ (flag) |
4 | 2 | 1 |
γγγ«γ‘γ― |
5 | 10 | 5 |
π―πΌπΉπ± (bold "font") |
8 | 8 | 4 |
read fuxux.com now |
18 | 32 | 18 |
| a 140-character URL | 140 | 23 | 140 |
The X column is from X's own library. The Bluesky column is the grapheme count.
X: weights, not characters
X publishes how it counts in an open source library, twitter-text. The limit is 280, but 280 of a weighted unit:
- Latin letters, digits and common punctuation weigh 1. In code point terms:
U+0000toU+10FF,U+2000toU+200D,U+2010toU+201FandU+2032toU+2037. - Everything else weighs 2. That's why five Japanese characters cost 10, and why those bold "fonts" people paste into bios cost double: they are mathematical letters far outside the Latin range.
- An emoji weighs 2, as a whole. A family of four is seven code points glued with joiners and it is still 2.
- Any link weighs 23, whether it is 9 characters or 140.
- The text is normalised to NFC first, so
eplus a combining accent counts as oneΓ©.
The link rule bites in two directions. A long URL is cheaper than it looks. A short one is more expensive: fuxux.com is 9 characters and costs 23.
And it catches text you didn't mean as a link. It works.So does this is 21 characters. X counts 36, because a missing space after the full stop makes works.So look like a domain (.so is a real ending).
Where I got it wrong
My first version walked the string one code point at a time and added 1 or 2:
function weigh(text) {
let total = 0;
for (const char of text) total += xWeight(char.codePointAt(0));
return total;
}
That handles a plain π (one code point, weight 2). It falls apart on anything built from several:
| String | X | My first version |
|---|---|---|
ok ππ½ |
5 | 7 |
π¨βπ©βπ§βπ¦ |
2 | 11 |
πͺπΈ |
2 | 4 |
1οΈβ£ |
2 | 5 |
β€οΈ |
2 | 4 |
It always over-counted, so nobody could post something too long because of it. It just told people their post didn't fit when it did. On a 280 limit, a few emoji with skin tones is enough to matter.
The fix is to split into graphemes first, and count a grapheme that is an emoji as 2 no matter what it is made of:
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
// Drawn as an emoji by default, or a symbol followed by the emoji
// selector, or a flag letter, or a keycap like 1οΈβ£.
const EMOJI =
/\p{Emoji_Presentation}|\p{Extended_Pictographic}οΈ|\p{Regional_Indicator}|[0-9#*]οΈ?β£/u;
function xWeight(cp) {
return (cp <= 0x10ff) ||
(cp >= 0x2000 && cp <= 0x200d) ||
(cp >= 0x2010 && cp <= 0x201f) ||
(cp >= 0x2032 && cp <= 0x2037) ? 1 : 2;
}
function weigh(text) {
let total = 0;
for (const { segment } of segmenter.segment(text)) {
if (EMOJI.test(segment)) { total += 2; continue; }
for (const char of segment) total += xWeight(char.codePointAt(0));
}
return total;
}
function countForX(text, urlPattern) {
const s = text.normalize("NFC");
let total = 0, last = 0;
for (const m of s.matchAll(urlPattern)) {
total += weigh(s.slice(last, m.index)) + 23;
last = m.index + m[0].length;
}
return total + weigh(s.slice(last));
}
My second attempt was also wrong, in a smaller way. I used \p{Extended_Pictographic} on its own, and Β© 2026 came out as 7 instead of 6. A bare Β© is in that Unicode class, but X only treats it as an emoji when the emoji selector (U+FE0F) follows it. Same for a text heart β€ versus β€οΈ. Hence the longer pattern above.
It now matches twitter-text on 43 of my 44 test strings. The one it misses is a link ending I don't carry: X knows every domain ending there is, and I keep a short list of the common ones, so a.community is a link to X and plain text to me. If you need that last case, use their list.
Which is the real advice here: if you can ship the dependency, call twitter.parseTweet(text).weightedLength and be done. I wanted something small that runs in the browser as you type, so I wrote my own and test it against theirs.
Bluesky: graphemes, and a second limit behind it
Bluesky's limit is 300, and it counts graphemes. The post schema says so directly: maxGraphemes: 300. So the family emoji is 1, the flag is 1, and ok ππ½ is 4. Intl.Segmenter gives you that number in every current browser and in Node.
The same schema also has maxLength: 3000, and that one is in UTF-8 bytes. Normal text never gets near it. Three hundred family emoji would, at 25 bytes each.
The rest
For Instagram, LinkedIn, Threads, Facebook and the others I count text.length. I have not checked each of them emoji by emoji the way I just did for X, so I won't claim more than that. Their limits are large enough (500 on Threads, 2,200 on Instagram, 3,000 on LinkedIn) that a few units either way rarely decides anything. On 280 and 300 it does.
Try it
The counter is free and needs no account: fuxux.com/tools/character-counter. Paste a post and it shows the count for each network side by side, using the rules above. Paste ok ππ½ and you'll see X and Bluesky disagree.
The limits in it are the same numbers Fuxux uses when it publishes. If you'd rather not think about any of this, that's what the scheduler is for, and there's a free plan (3 social accounts, 5 posts a month, no card).
If you know a string where X's count surprised you, put it in the comments. I'll add it to the tests.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.