Bytes vs. Caracteres: por que o tamanho da sua string UTF-8 te trai

Atualizado em 2026 · Leitura de 5 min

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:

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 UnicodeBytes em UTF-8ExemplosQuando você encontra
U+0000 – U+007F1 byteA, 7, !, espaçoTexto em inglês, código, URL
U+0080 – U+07FF2 bytesñ, á, ç, µPortuguês, espanhol, símbolos latinos estendidos
U+0800 – U+FFFF3 bytes中, π, €, あ, ΩChinês, japonês, grego, cirílico, o símbolo do euro
U+10000 – U+10FFFF4 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 caractereQuantidadeBytes cadaBytes totais
ASCII (letras, dígitos, sinais básicos, espaços)81181
Latinos acentuados (á, é, í, ñ, ç, ¡…)13226
Emoji (😀)144
Total95 caracteres111 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

Conte caracteres e grafemas em tempo real enquanto digita — normalização NFC, quebras de linha unificadas e segmentação por grafemas.

Abrir contador de caracteres

Conclusã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.