The zero-width joiner holds emoji together — and one stray copy of it halves your text-message budget
Short answer: U+200D ZERO WIDTH JOINER paints no ink and takes up
no width, and it has exactly one job: tell the renderer to draw what surrounds it as a
single glyph. It is what turns four separate people —
👩👩👧👦 —
into one family:
👩👩👧👦.
It is also the invisible character that refuses to leave. JavaScript’s
trim() keeps it, Python’s strip() keeps it, and all four
normalization forms hand it back byte-identical. And it is expensive. Measured against the
GSM 03.38 alphabet, a hundred characters of plain text cost one segment to send. The same
hundred characters with a single U+200D buried in the middle cost two, because
one character outside that alphabet pushes the entire message into UCS-2, where the limit falls
from 160 to 70.
The one invisible character that is supposed to be there
Almost every other character on this site is an accident. A zero-width space arrives because somebody copied text out of a document. A byte-order mark arrives because something saved a file with a signature. Nobody wanted them.
U+200D is different. It is load-bearing. Remove it and you do not clean the text up — you break a picture that a person chose. That single fact explains most of the confusion around it, because the usual advice for invisible characters is “strip them all out”, and this is the one case where that advice does real damage.
Its vital statistics, measured on this machine: general category Cf (a format
character, not whitespace), bidi class BN, one UTF-16 code unit, three bytes of
UTF-8. It is not whitespace to any trimmer, and unlike the six exotic spaces it never
normalizes into anything else. NFC, NFD, NFKC and NFKD all return it unchanged.
Seven code points drawn as one, and no ceiling on how long that gets
Here is the four-person family emoji measured four ways at once on Node 22.22.2 (ICU 78.2, Unicode 17.0). Every number in this row is correct. They just answer different questions.
| Text | What you see | Code points | UTF-16 | UTF-8 bytes |
|---|---|---|---|---|
| family of four | 1 | 7 | 11 | 25 |
| woman astronaut | 1 | 3 | 5 | 11 |
| thumbs up, medium skin tone | 1 | 2 | 4 | 8 |
| one lone U+200D | 1 | 1 | 1 | 3 |
| 10,000 U+200D in a row | 1 | 10,000 | 10,000 | 30,000 |
Read the last row again. Ten thousand joiners, thirty thousand bytes, and a grapheme
segmenter calls it one character. There is no rule anywhere in the standard
that caps how many joiners may pile up, so “one character” is a unit with no upper
length at all. Put an a in front of a thousand joiners and the segmenter still
reports one character; put a b after them and it reports two, because the letter
after the run starts a new cluster. A trailing joiner never starts one, which is why a paste
that ends in U+200D can quietly glue itself onto whatever you type next.
Two levels of the same string, two different answers
This is where people talk past each other. Ask whether the family emoji contains a woman and you get two defensible answers, depending on which level of the string you ask at.
At the grapheme level — the level that models “what a person thinks of as one character” — the joiner is not a separate thing you can find. It has been absorbed into the cluster that follows the preceding emoji, so a search for it comes back empty and a search for one of the people comes back empty too. At the scalar level the joiner is sitting right there at a known position, and a search for it succeeds. Both are correct. Neither is lying.
The uncomfortable part is that the same language can change its answer between versions. One widely used language counted the family emoji as four characters and reported that it did not contain the woman; a later version of the same language counts it as one and reports that it does. Identical bytes, identical question, opposite result — and any code written against the first answer silently changes behaviour.
The same split shows up inside a single standard library. In one of them, a culture-aware substring search steps over the format character as if it were not there, while the ordinal replace on the very same string refuses to, because one consults collation tables and the other compares numbers. A find that succeeds and a replace that fails, on one string, in one runtime. If your cleanup pipeline mixes the two, it will report that it found the problem and then leave it untouched.
Where it quietly costs money
Text messaging is billed per segment, and a segment is 140 bytes. GSM-7 squeezes 160 characters into those bytes. UCS-2 spends two bytes per character and fits 70. Concatenated messages lose a little more to the reassembly header: 153 per segment for GSM-7, 67 for UCS-2. One character outside the GSM-7 alphabet moves the whole message into the second column.
U+200D is not in that alphabet, and neither is any emoji. Measured:
| Message | Encoding | Units | Segments |
|---|---|---|---|
| 100 characters of plain Latin text | GSM-7 | 100 of 160 | 1 |
| the same 100 characters, one U+200D in the middle | UCS-2 | 101 of 70 | 2 |
| a 93-character delivery notice | GSM-7 | 93 of 160 | 1 |
| that notice with one U+200D after a colon | UCS-2 | 93 of 70 | 2 |
| six family emoji | UCS-2 | 66 of 70 | 1 |
| seven family emoji | UCS-2 | 77 of 67 | 2 |
Six family emoji fill a text message. Six pictures, sixty-six of the seventy units, one segment — and the seventh one costs you a second message. A marketing tool that counts “characters” by looking at the rendered string will tell you that a campaign with seven family emoji is seven characters long, which is true on screen and wrong on the invoice.
Nothing about this is a bug. The billing follows the encoding, the encoding follows the alphabet, and the alphabet predates emoji by about two decades. The joiner just happens to be the character most likely to arrive without anyone typing it, because it comes along invisibly whenever somebody pastes an emoji out of a document or a chat window.
Not every joiner is allowed to join
You cannot put U+200D between any two characters and expect a combined glyph. A well-formed sequence may only be built from characters that carry the Emoji property, from emoji presentation sequences, and from emoji modifier sequences. Everything else is malformed.
Tested here against Unicode 17.0: U+2605 BLACK STAR has Emoji=false,
so a flag-star-square sequence that uses it is not well-formed and no renderer is obliged to
draw anything but the pieces. U+2B50 WHITE MEDIUM STAR has
Emoji=true and is fine. U+200D itself has Emoji=false, which is worth
knowing before you write a validation rule that assumes otherwise. Some characters also need a
variation selector to be drawn in colour at all, some must never be followed by one, and the
two lists are not the same list.
And even a well-formed sequence is only a request. The standard’s own fallback rule is that a renderer which does not have the combination ignores the joiners and draws the components separately. That is why these sequences are supposed to be chosen so that the fallback still reads sensibly — four people in a row is a reasonable failure mode for a family, whereas half a flag is not.
Same bytes, three screens
Whether you get the joined glyph is decided by the font and the renderer, not by the encoding. The reports are genuinely contradictory. Someone who went looking for a terminal that handles these sequences correctly came back convinced that not one of them does. Someone else who spent the same year adding wide-character support to a shell found support surprisingly good across the emulators they tried on two operating systems, and a third person named one that passes a published test.
Practical versions of the same story are easy to reproduce. A script that prints a joined emoji renders it correctly in two well-known terminal applications and as two separate emoji in the terminal pane embedded in an IDE. Both programs received the same bytes. One had the ligature data and one did not. If you are debugging this, the file is fine; the font is the variable.
The cleanup that breaks your emoji
The common sanitising snippet is a character class covering the whole zero-width block, and it catches U+200D along with the rest. Run it over the family emoji and the single picture becomes four separate people: seven code points down to four, one grapheme cluster up to four. The text is shorter, it is genuinely cleaner by any byte-level measure, and the person who wrote it now sees something else on screen.
Joiners also do real work outside emoji, shaping letter forms in scripts such as Sinhala, Devanagari and Arabic, so a blanket strip is not merely ugly, it is wrong in several languages at once.
The safe version removes only the joiners that are not holding anything together — the ones with no emoji on both sides:
const loose = (s) => s.replace(/(?<!\p{Emoji})\u200D+|\u200D+(?!\p{Emoji})/gu, "");
Tested on this machine: the family emoji and the woman astronaut come through untouched, a joiner sitting in the middle of ordinary Latin text is removed, and a joiner trailing after an emoji is removed. That last one matters more than it looks, because a trailing joiner with nothing to attach to is a stray character waiting to merge with the next thing typed.
Measure yours
Paste text in and it is counted here, in the browser. Nothing is uploaded and nothing leaves the page.
Finding one by eye is not realistic. Browser find-in-page will not match it, and it does not move the cursor. Use the zero-width space remover if what you are chasing is the space rather than the joiner, or the full list of invisible Unicode characters if you are not sure which one you have.
What this page does not tell you
Which emoji combinations your recipients’ devices will render. That depends on the font shipped with their operating system and the version of the standard it was built against, and no amount of checking here tells you what a phone you have never seen will draw. If a sequence matters commercially, send it to a real handset.
The SMS numbers are the published GSM 03.38 limits — 160 and 153 for GSM-7, 70 and 67 for UCS-2 — applied to strings measured on this machine. Carriers and messaging platforms add their own rules on top, and some of them rewrite characters for you before billing. Treat the segment counts as what the encoding rules imply, not as what any particular invoice will say.
Measured 2026-09-26 with Node 22.22.2 (ICU 78.2, Unicode 17.0) and Python
3.13.14 (unicodedata 15.1.0). Related:
why there is no empty character,
what an invisible character does to a form field,
the two families of invisible text,
tag characters that hide a whole message,
what invisible characters cost you in tokens, and
the JSON cleaner if it is a payload that will not parse.