Blank text message: the bubble stays empty for a space, and a character that draws no ink sends anyway
Short answer: a blank text message works because the send button
never asks “is there a message”. It asks “is there anything in the box that a
trim would not remove”. Measured in Chromium 153, an empty chat bubble is
16 px tall, a bubble holding one letter is 38.39 px, and a
bubble holding one ordinary space is 16 px again. All 23 invisible characters
tested take the bubble to 38.39 px, but only 16 of 23 get past the emptiness
check, and the seven that do not are exactly the seven trim() deletes. The character
most often recommended for this job, U+3164, is also the one that reserves a
full em of width.
One character each. The first three draw no ink and advance the cursor by nothing,
so the bubble contains a message and shows no width for it. The fourth also survives the
emptiness check and also draws no ink, but it advances a full em — 64 px at a 64 px
font — so ten of them make a bubble 184 px wide. U+00A0 is the control: it advances
19 px, draws nothing, and trim() removes it, so it looks like a blank message
and is refused like a space.
Two gates, and an ordinary space fails both
Two separate things stand between you and a blank message. The first is layout: whether the renderer has anything to draw. The second is the string check behind the send button. A space fails the first because a whitespace-only text node produces no line box, and it fails the second because a trim deletes it.
| In the bubble | Height | What the renderer did |
|---|---|---|
| nothing at all | 16.00 px | padding only |
| one ordinary letter | 38.39 px | one line box |
| one ordinary space | 16.00 px | padding only — nothing to lay out |
| any of the 23 invisible characters | 38.39 px | one line box, same as a letter |
So a space does not disappear; it is stored and skipped at layout time. The check behind the button is the obvious one: refuse the message if the field value is empty after trimming.
| Value in the field | Passes the trim() check | Which characters |
|---|---|---|
| an ordinary space | no | — |
| 23 invisible characters | 16 of 23 | everything except the seven below |
| the seven that fail | 0 of 7 | U+00A0, U+2028, U+2029, U+202F, U+205F, U+3000, U+FEFF |
The seven that fail are exactly the code points JavaScript's trim() removes, so the
split is not “invisible” against “visible” but “whitespace to the
language” against “not” — and seven of the characters handed out for this
job are on the wrong side of it. U+00A0 is the one to watch: the old advice for a blank message
was a no-break space, and it advances 19 px, so it looks like it worked while the button refuses
it. People meet this without knowing why. The clearest account of the behaviour is someone whose
handset would not show a send button until they typed characters and not just a space, while blank
messages kept arriving from a contact who had not sent any.
The placeholder is the one signal that lies to you
The only feedback a message box gives you is the placeholder text disappearing, and that signal is
not the one you want. A placeholder hides whenever the value is not the empty string, and one
ordinary space is not the empty string. Measured in Chromium 153 with
:placeholder-shown, the placeholder is gone for a space, and the trim-based check
still refuses it.
| Value in the box | Placeholder still shown | Send button would refuse |
|---|---|---|
| empty string | yes | yes |
| one ordinary space | no | yes |
| U+00A0, U+202F, U+205F, U+3000, U+FEFF | no | yes |
| U+200B, U+2060, U+00AD, U+3164 | no | no |
So the placeholder vanishing says the DOM has a text node, and nothing about whether the app will
send. Five of the characters behave exactly like a space on both rows: the box looks occupied, the
button stays dead, and there is no visible reason why. A better test is to type a second character
and delete it — if the button lights up and stays lit, something is in there. One length rule
points the other way: measured on a required field, all 23 characters count as filled
in, including the seven whose trim() length is zero, while the empty string counts as
missing.
Empty message copy paste: the characters worth copying, and the one to skip
An invisible character does two jobs at once and they come apart: it can draw nothing, and it can advance the cursor by nothing. The characters people hand out are usually invisible in the first sense and not in the second, which is why a blank message so often arrives with a gap in it. Drawing each character to a canvas at 64 px and counting the pixels that are not the background separates the two.
| Character | Ink drawn | Advance | What it does in a bubble |
|---|---|---|---|
| an ordinary letter | 1066 px | 63 px | a visible character |
| an ordinary space | 0 px | 19 px | a gap, and the button refuses it |
| U+200B zero width space | 0 px | 0 px | nothing, and it sends |
| U+2060 word joiner | 0 px | 0 px | nothing, and it sends |
| U+00AD soft hyphen | 0 px | 0 px | nothing, and it sends |
| U+200C, U+200D, U+200E, U+200F | 0 px | 0 px | nothing, and they send |
| U+034F, U+061C, U+180E, U+E0001, U+E0020 | 0 px | 0 px | nothing, and they send |
| U+202F narrow no-break space | 0 px | 10 px | a thin gap, and it is refused |
| U+205F medium math space | 0 px | 14 px | a gap, and it is refused |
| U+00A0 no-break space | 0 px | 19 px | a gap the size of a space, refused |
| U+3000 ideographic space | 0 px | 64 px | a full em of gap, refused |
| U+3164 hangul filler | 0 px | 64 px | a full em of gap, and it sends |
| U+FFA0 halfwidth hangul filler | 0 px | 32 px | half an em of gap, and it sends |
The last three rows are the ones that get recommended and the ones that leave a mark. U+3164 Hangul Filler is the most common answer to “how do I send a blank message”, and measured it reserves a full em: 64 px at a 64 px font, and the same 16 px cell a chat bubble uses at 16 px. Ten copies of it occupy 184 px of bubble. It draws no ink, and it is not blank in the sense that a reader can see how much of it there is.
None of the 23 drew any ink in the font stack measured here, which is worth stating because the usual description of these characters on a phone is a box. A box is what you get when the font has no glyph for the code point at all. The advance is a property of the character, the box is a property of the device, and only the first one travels with your message.
Invisible message copy paste: what the other person actually sees
Nothing is drawn, and the bubble is still not the size of an empty one. Measured in Chromium 153, ten copies of a zero-width character leave the bubble at 24 px wide — the padding and no more — while ten copies of U+3164 take it to 184 px. The recipient gets a message with no text in it and a bubble whose width counts how many characters you sent.
| Ten copies of | Bubble width | Counter in the field |
|---|---|---|
| U+200B | 24 px | 10 |
| U+2060 | 24 px | 10 |
| U+FEFF | 24 px | 10 |
| U+3164 | 184 px | 10 |
| U+E0020 | 24 px | 20 |
The last row is a second way the numbers on screen stop matching the message. U+E0020 is a tag character, and one of them is two UTF-16 units, so a field that counts units reports twenty where you typed ten — and neither number is the length of the message the recipient sees, which is zero either way. The width also shows up in the notification: a chat list row is drawn from the same string, so a blank message of U+3164 produces a gap after the sender's name where an ordinary message would have a word, and a message of U+200B produces a row that looks exactly like a message with no text.
Send blank message: what the app says when it refuses
The wording of the refusal names the check. One messaging API returns Cannot send an empty message to developers who assemble the payload wrong. A chat app returns Can't send empty message when an Android intent arrives with an empty body. A bot platform answers with the same phrase again, and it appears across three separate questions from three different people who were each right that their own code was fine — because the payload the app built was genuinely empty even though the field they typed into was not.
That is the useful part: the check runs on the payload, and it is a string check, so it accepts anything a trim leaves behind. It also means the failure can come from a step between your field and the payload — a share link that collapses its line breaks, a text extraction that returns nothing, a reply window that opens with no body. It is also why “try a space or an HTML entity” is a dead end, and you can watch someone walk into it: on a bot platform where the send method requires text, the asker reports trying a space and trying an entity and getting nowhere.
The field type changes none of this. Measured across an <input>, a
<textarea> and a contenteditable, all three give the same trim
verdict for all 23 characters — the same 16 pass and the same seven fail. A comment box that
is not an input is not testing something different; it is testing the same thing on a string it
has to go and collect.
Blank message for Instagram: the same rule with one extra hop
The rule does not change on a social app, but the box might, and that adds a step that can lose
the character. A comment or direct-message box on the web is usually a
contenteditable element rather than an input, and a contenteditable is
not submitted by a form at all. Measured with FormData, a form holding an input, a
textarea and a contenteditable submits two keys, and the contenteditable
contributes nothing. Its text has to be read out and written into a hidden field first.
That hop is where a blank message fails for a reason that has nothing to do with the character.
The value has to survive being read as innerText or textContent, written
into a hidden input, and then encoded for the request. Measured on all 23, innerText
and textContent both keep the character and report a length of one, and the trim
verdict at the end of it is the same 16 of 23. What is not safe is any step that normalises or
strips the string on the way, and there is no way to see from the outside whether one is in the
path.
One CSS detail catches people building this themselves. The usual way to fake a placeholder in a
contenteditable is a rule on :empty, and :empty tests for
text nodes rather than whitespace. Measured in Chromium 153, a div holding the empty string
matches :empty, one holding a single ordinary space does not, and
one holding U+200B does not either. The fake placeholder vanishes on a box that will refuse to
send the thing that made it vanish — the same false signal as the real placeholder. The
limit here is the app, not the rule: nothing in this section was measured against Instagram, and a
platform can run the trim check and then four more on top of it.
Where the advice splits
Everyone agrees on the first move: a space will not do it, and an invisible character will. The disagreement starts with which character. The common advice is a Hangul filler, and the reasoning is sound as far as it goes — it draws nothing, it survives a trim, and it is a letter, so a field that accepts letters accepts it. What the advice leaves out is the width. Measured, U+3164 advances a full em, and that is the one property of the character a reader can see. The position that follows is the opposite of the common advice: for a blank message you want no advance at all, and the fillers are the wrong end of the list.
The second position is that the exercise is about the wrong layer, and that the interesting question is what the receiving end does with the string. On that reading a blank message is not a trick that works or fails but a probe that identifies which check a platform runs. Refusing it means the payload is trimmed. Accepting it means the payload is checked for emptiness alone. Accepting it and then rendering a gap means nothing is normalised and advance widths are being measured.
The third position is the minority one, and it cuts against both of the others: the character is a
liability in your own data, not a harmless trick in someone else's chat. The evidence is not about
messages. A configuration value with a leading zero-width space produced an error that named the
code point and nothing else, and the person who found it reported being surprised that their
language's own strip() did not remove it — the same seven-of-23 split measured
above, arriving as a production bug instead of a curiosity. Elsewhere the same character got past
an input sanitiser and threw an error naming U+200B. A character that is whitespace to nobody is
not stripped by anything that strips whitespace, and that cuts both ways: it is why the message
sends, and it is why the same character survives every cleanup step you have.
What this page does not tell you
Whether any particular app accepts a blank message today. What is measured here is the box, not the service: the renderer's treatment of a whitespace-only string, the trim-based emptiness check most send buttons implement, the ink and advance width of 23 characters, and three kinds of form field in one browser. Every service layers its own rules on top and changes them without notice.
The set of 23 is the set measured, not a canonical list of every invisible character, and a different set moves the counts without changing the direction. The ink and advance figures are specific to one font stack on one platform, and a device with different font coverage can substitute a glyph — which is where the box comes from, and nothing measured here predicts which devices do that. Chromium 153.0.8010.12 is a single build, and the trim list is defined by the Unicode version that build carries.
Measured 2026-10-04 with Chromium 153.0.8010.12 via Playwright and Node 22.22.2. Related: the full list of invisible characters, what a browser does with text you cannot see, the zero-width space remover, why there is no empty character, tag characters, the ones that cost two units each, the four that are letters and print nothing, what the same characters do in a username field, how to find hidden characters in a file, and comparing two texts that look identical.