Dev.to WebDev πŸ›  Dev πŸ‘ 0 πŸ“– 5 min read

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+0000 to U+10FF, U+2000 to U+200D, U+2010 to U+201F and U+2032 to U+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 e plus 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.

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.