Bytes vs. Caracteres: por que o tamanho da sua string UTF-8 te trai
Seu contador marca 95 caracteres. Seu banco de dados define a coluna como VARCHAR(100). A API devolve um 422 "payload too long". O que acabou de acontecer?
A resposta curta: 95 caracteres não são 95 bytes. E quase todos os limites que você encontra em bancos de dados, APIs e validadores do backend são medidos em bytes, não em caracteres. Se o seu contador de caracteres diz uma coisa e o servidor diz outra, o culpado é a codificação.
A armadilha do .length
Em JavaScript, string.length não conta nem bytes nem grafemas: conta unidades de código UTF-16. Para a maior parte do texto (ASCII e o Plano Básico Multilíngue) coincide com o número de caracteres que você vê, mas desmorona em dois casos:
- Caracteres fora do plano básico (emojis como 😀, hieróglifos egípcios): ocupam duas unidades de código (um par substituto), então
lengthdevolve 2 para um único caractere visual. - Grafemas compostos: um "é" escrito como
e+ acento combinado (forma decomposta NFD) conta como 2, embora visualmente seja uma letra.
E mesmo se length acertasse os caracteres, ainda não diria quantos bytes a string ocupa em UTF-8, que é o que o servidor realmente lê.
Codificação UTF-8 de comprimento variável
O UTF-8 é brilhante e traiçoeiro ao mesmo tempo: codifica cada ponto de código com entre 1 e 4 bytes, conforme sua posição na tabela Unicode. Um caractere ASCII continua pesando um byte, mas todo o resto cresce.
| Faixa Unicode | Bytes em UTF-8 | Exemplos | Quando você encontra |
|---|---|---|---|
| U+0000 – U+007F | 1 byte | A, 7, !, espaço | Texto em inglês, código, URL |
| U+0080 – U+07FF | 2 bytes | ñ, á, ç, µ | Português, espanhol, símbolos latinos estendidos |
| U+0800 – U+FFFF | 3 bytes | 中, π, €, あ, Ω | Chinês, japonês, grego, cirílico, o símbolo do euro |
| U+10000 – U+10FFFF | 4 bytes | 😀, 🇧🇷, 𝕏 | Emojis, bandeiras, símbolos matemáticos alfanuméricos |
Notas úteis: o símbolo € (euro, U+20AC) cai na faixa de 3 bytes, não 2. O ñ e o á pesam 2 bytes cada. Um emoji simples como 😀 pesa 4 bytes. Uma sequência com modificador de tom de pele ou unida por ZWJ (ex. 👨👩👧) pode somar até 18 bytes e continuar sendo um único grafema.
É aí que caracteres, bytes e grafemas deixam de se alinhar.
O cenário real que quebra tudo
Seu formulário permite 100 caracteres. O usuário escreve uma mensagem promocional de 95 caracteres — uma mistura de texto, acentos do português e um emoji no final. Seu contador no front marca 95 caracteres (correto, contando grafemas). O usuário envia. O backend responde 422 payload too long. Por quê?
Porque o limite do backend não é de caracteres: é de bytes. Um VARCHAR2(100 BYTE) no Oracle, uma constraint que valida OCTET_LENGTH, ou um maxBytes: 100 no validador da API medem bytes UTF-8, não grafemas. E seus 95 caracteres pesam mais do que você imagina:
| Tipo de caractere | Quantidade | Bytes cada | Bytes totais |
|---|---|---|---|
| ASCII (letras, dígitos, sinais básicos, espaços) | 81 | 1 | 81 |
Latinos acentuados (á, é, í, ñ, ç, ¡…) | 13 | 2 | 26 |
Emoji (😀) | 1 | 4 | 4 |
| Total | 95 caracteres | 111 bytes |
95 caracteres, 111 bytes. O contador dizia "tudo certo" porque comparava com 95; o servidor comparava com 100 bytes e viu 111. Onze bytes a mais, rejeição limpa.
Regra mnemônica: se o seu texto tem algo que não seja ASCII puro (acentos,
ñ,€, emojis, outros alfabetos), os bytes sempre serão maiores ou iguais aos caracteres. Nunca o contrário.
Como medir as três dimensões em JavaScript
Para não depender de suposições, meça as três dimensões que importam. Aqui vai um snippet pronto para produção:
/**
* Devolve três métricas de comprimento para uma string:
* - graphemes: unidades visuais (o que o usuário vê) -> limites de UX
* - codeUnits: string.length (unidades UTF-16) -> limites de JS/DOM
* - bytes: tamanho do payload em UTF-8 -> limites de DB/API
*/
function stringMetrics(text) {
// 1. Grafemas: o que o olho conta (UAX #29)
let graphemes;
if (typeof Intl !== 'undefined' && Intl.Segmenter) {
const segmenter = new Intl.Segmenter('pt-BR', { granularity: 'grapheme' });
graphemes = Array.from(segmenter.segment(text)).length;
} else {
// Fallback: normaliza para NFC e conta pontos de código
graphemes = Array.from(text.normalize('NFC')).length;
}
// 2. Unidades de código UTF-16 (o que string.length devolve)
const codeUnits = text.length;
// 3. Bytes em UTF-8 (o que o servidor mede)
// Blob sempre serializa em UTF-8; .size é a contagem de bytes.
const bytes = new Blob([text]).size;
return { graphemes, codeUnits, bytes };
}
// Exemplo:
const result = stringMetrics('Olá, João! 😀');
// result -> { graphemes: 12, codeUnits: 13, bytes: 17 }
Quando usar cada métrica
graphemes— para mostrar ao usuário quantos caracteres restam numa interface (campos de texto, barras de progresso). É o número que o seu contador de caracteres mostra.codeUnits— útil quando a limitação vem da própria API do JavaScript (ex. o atributomaxlengthde um<textarea>conta unidades de código, não grafemas).bytes— o valor que você deve enviar ao backend quando o limite é de banco de dados ou tamanho de payload. Se sua API documenta ummaxBytes, valide contrabytes, não contragraphemes.
Conte caracteres e grafemas em tempo real enquanto digita — normalização NFC, quebras de linha unificadas e segmentação por grafemas.
Abrir contador de caracteresConclusão
Na próxima vez que vir uma rejeição de API ou um erro de VARCHAR numa string que "cabe", confira os bytes, não os caracteres. A divergência entre o UTF-8 de comprimento variável e um contador que só vê grafemas é a causa nº 1 dessas falhas silenciosas. Meça as três dimensões no cliente, documente qual cada ponta usa e pare de adivinhar.
Para contar caracteres e grafemas em tempo real enquanto você digita, use o contador de caracteres: ele normaliza para NFC, unifica quebras de linha e segmenta por grafemas, então o que você vê é o que o usuário percebe.