Decodificação Base64 em JavaScript/Browser: um guia completo
Ela chega com uma dúzia de disfarces diferentes: um JWT escondido num cabeçalho Authorization, um blob image/png dentro de uma resposta JSON, um valor de Sec-WebSocket-Accept num log de handshake, um anexo de e-mail embrulhado em MIME, um valor que seu backend educadamente espremeu numa query string. A string em si sempre tem a mesma cara: uma longa sequência de letras e dígitos, de vez em quando um + ou um /, e talvez um = ou dois no final. Se a página inicial deste site te ensinou o que é Base64 - quatro caracteres imprimíveis representando cada três bytes, com padding de = para fechar o último grupo - então este artigo é sobre a parte que você de verdade faz em código: transformar esses caracteres de volta em bytes, e os bytes de volta em significado, usando só o que o navegador já entrega.
Duas regras rápidas antes de começarmos. Primeira: decodificar é a direção de encolhimento: para cada quatro caracteres que você lê, três bytes saem, então a saída sempre ocupa menos memória que a entrada. Segunda: uma string Base64 decodificada não vira texto automaticamente. Ela é bytes, e esses bytes podem se revelar UTF-8, Windows-1252, um cabeçalho PNG ou uma assinatura criptográfica. O bug mais comum em código Base64 é esquecer qual deles você está segurando, então as seções abaixo estão organizadas em torno dessa pergunta.
As três camadas de decodificação
Navegadores modernos te dão três camadas nativas, e a boa notícia é que nenhum pacote é necessário em nenhum momento. Cada uma responde a uma pergunta um pouco diferente, e escolher a certa te poupa de muitos trechos copiado e colado do Stack Overflow:
| Camada | O que ela come | O que ela entrega | Personalidade | Disponibilidade |
|---|---|---|---|---|
atob() |
string Base64 padrão | uma "string binária" (um byte por caractere) | muito tolerante: pula espaços em branco ASCII e aceita padding ausente | todo navegador desde os anos 2000, IE 10+, Node 16+ |
TextDecoder |
bytes (Uint8Array) |
texto JavaScript legível | configurável: rótulo para o charset, flag fatal para o rigor |
Firefox 18, Chrome 38, Safari 10.1 e superiores (nunca no IE) |
Uint8Array.fromBase64() |
string Base64 mais opções | um Uint8Array de verdade |
rigorosa com botões de ajuste: alfabeto e tratamento do último chunk | Baseline 2025: Chrome 140, Firefox 133, Safari 18.2, Node 25 |
A forma de todo o artigo vem dessa tabela. atob() é o cavalo de batalha que você encontra em todo lugar, inclusive em código antigo. TextDecoder é a ponte dos bytes para as palavras. E Uint8Array.fromBase64() é o upgrade de 2025 que pula a etapa do meio por completo quando o que você queria eram só bytes o tempo todo.
atob: rápida, tolerante e muito antiga
O contrato inteiro cabe numa linha: atob(encodedData). Ela recebe uma string codificada em Base64 e devolve uma "string binária": uma string JavaScript comum em que cada caractere guarda exatamente um byte decodificado, um code point de 0 a 255. Esse tipo de retorno importa, porque não é a mesma coisa que texto legível (falaremos disso mais abaixo). A função em si é rápida no limite do possível, e está aí há muito tempo: Chrome 4, Firefox 1, Safari 3 e - essa é a que a maioria lembra - Internet Explorer só a partir da versão 10, o que explica por que código escrito antes de 2012 está cheio de tabelas Base64 feitas à mão.
O que torna atob() gostosa de usar é o quanto ela perdoa antes de desistir. O padrão WHATWG HTML diz para ignorar todos os espaços em branco ASCII - espaço, tabulação, feed de linha, feed de formulário, retorno de carrinho - antes de decodificar, então uma string embrulhada em MIME com quebras a cada 76 caracteres decodifica sem nenhuma limpeza da sua parte. Padding ausente também é perdoado. Mas no momento em que ela vê um caractere fora do alfabeto, ou um comprimento que jamais poderia ser válido, ela lança uma DOMException chamada InvalidCharacterError. Sem lixo em silêncio, sem resultados parciais.
Aqui está o relatório de danos, linha por linha:
| Entrada | Resultado |
|---|---|
"SGVsbG8sIFdvcmxkIQ==" |
"Hello, World!" - o caso de manual |
"aGVsbG8" (sem padding) |
"hello" - um = ausente é perdoado |
"SGVs\nbG8s\nIFdvcmxkIQ==" (linhas quebradas) |
"Hello, World!" - espaços em branco ASCII são pulados primeiro |
"" (string vazia) |
"" - a entrada vazia é válida e faz ida e volta |
"A" (um caractere sobrando) |
lança InvalidCharacterError - um caractere não consegue codificar nada |
"Zm9vYmFy!" (um ! perdido) |
lança InvalidCharacterError - fora do alfabeto |
"ZGFua29nYWk-" (caractere URL-safe misturado) |
lança InvalidCharacterError - os dois alfabetos não podem ser misturados |
"Zm9v====" (padding demais) |
lança InvalidCharacterError - no máximo dois = no final |
Uma nota prática: a mensagem de erro em si é diferente entre engines (o Firefox diz "String contains an invalid character", o Chrome diz que a string "contains characters outside of the Latin1 range" para entrada não-Latin1 ou "is not correctly encoded" para base64 inválido), então pegue pelo nome da exceção, não pelo texto da mensagem.
De bytes brutos a texto de verdade
Esse tipo de retorno "string binária" merece uma parada, porque é a fonte de quase toda confusão na decodificação. Strings JavaScript são UTF-16, então atob() te entrega uma string cujos caracteres são valores de bytes, não glifos legíveis. Se o seu payload era a codificação UTF-8 do texto "hello 你好", imprimir o resultado diretamente te dá mojibake. A correção é uma decodificação em duas etapas: Base64 para bytes, depois bytes para texto.
Primeiro, a etapa de Base64 para bytes. Esse helper pequeno é a receita clássica e vale a pena guardar no bolso, porque é a peça que segura o peso de boa parte dos exemplos deste artigo:
function base64ToBytes (base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
Depois a etapa de bytes para texto, com TextDecoder. Para UTF-8 (o padrão, e a escolha certa para JSON, payloads JWT e a maioria dos dados da web) a chamada é uma linha:
const bytes = base64ToBytes('aGVsbG8g5L2g5aW9');
const text = new TextDecoder('utf-8').decode(bytes);
console.log(text); // "hello 你好"
Por que duas etapas? Porque atob() não tem a menor ideia de em qual charset os bytes foram produzidos. Ela é um conversor puro de bits. TextDecoder é o componente que interpreta bytes como um charset, e ele aceita um rótulo para o trabalho: utf-8, windows-1252, iso-8859-1, utf-16le, mais uns 220 rótulos. Dados que saíram de um aplicativo dos anos 1990 são geralmente Windows-1252, e um argumento no construtor é tudo o que precisa:
const decoder = new TextDecoder('windows-1252');
const text = decoder.decode(bytes); // mesmos bytes, interpretação diferente
O construtor de TextDecoder também aceita uma flag fatal, e vale a pena deixá-la em true sempre que o texto decodificado alimenta algo importante. Por padrão o decoder é tolerante: sequências inválidas de bytes são substituídas em silêncio pelo caractere de substituição do Unicode, U+FFFD, e você nunca é avisado. Com fatal: true, o mesmo dano lança um TypeError em vez de se esconder:
const strict = new TextDecoder('utf-8', { fatal: true });
try {
strict.decode(corruptedBytes);
} catch (error) {
console.log(error.name); // "TypeError"
}
Esse é um daqueles interruptores que parece menor nos docs e parece um incidente de dados em produção. Se a sua entrada vem do usuário ou da rede, decodifique com rigor e trate o erro de propósito.
Entrada URL-safe pede um desvio
Uma variante do Base64 merece a sua própria seção, porque aparece o tempo todo no mundo real e atob() não lê. É o alfabeto seguro para URL e nome de arquivo da seção 5 do RFC 4648, geralmente chamado base64url: os mesmos 64 caracteres, só que + e / são substituídos por - e _, e o padding de = costuma cair, já que o comprimento dos dados é conhecido implicitamente. A troca existe por um motivo concreto: numa URL, + significa espaço e / começa um segmento de caminho, então o alfabeto padrão teria que ser percent-encoded caractere por caractere. Base64url viaja limpo em query strings, segmentos de caminho, fragments e nomes de arquivo.
O senão é que os dois alfabetos não são intercambiáveis, e atob() só fala o padrão. Passe um - ou _ para ela e ganha um InvalidCharacterError. Você tem duas opções limpas.
Opção um, que funciona em todo lugar: converter o alfabeto e restaurar o padding antes de chamar atob():
function fromUrlBase64 (segment) {
let s = segment.replace(/-/g, '+').replace(/_/g, '/');
const missing = (4 - (s.length % 4)) % 4;
return atob(s + '='.repeat(missing));
}
console.log(fromUrlBase64('aGVsbG8')); // "hello"
A expressão (4 - (s.length % 4)) % 4 é o truque inteiro: ela calcula quantos caracteres = uma string bem padded daquele comprimento precisaria, de zero até dois.
Opção dois, em navegadores 2025+: o novo decoder nativo aceita o alfabeto como opção, então não há nenhuma cirurgia na string:
const bytes = Uint8Array.fromBase64('P3-0', { alphabet: 'base64url' });
console.log(Array.from(bytes).join(', ')); // "63, 127, 180"
Duas regras te mantêm longe de encrenca. Nunca misture alfabetos dentro de um único valor - um decoder que vê ao mesmo tempo um + e um - não tem como saber de qual família está lendo, e o comportamento correto pela spec é falhar. E combine com o outro lado da linha se o padding está presente: tirar é legal para base64url, então um receptor tem que estar pronto para as duas formas. atob() já está; as opções nativas abaixo te dão um botão para isso.
O atalho de 2025: Uint8Array.fromBase64
Se você olhar de volta para o helper base64ToBytes, vai notar que ele faz duas coisas: decodificar Base64 e depois copiar os caracteres para um array de bytes um por um em JavaScript. Esse loop de cópia é a parte lenta e evitável, que é exatamente o que o novo método do ECMAScript remove. Uint8Array.fromBase64(string, options) vai direto da string codificada para um array de bytes, e ele chega no Chrome 140, Edge 140, Firefox 133, Safari 18.2, Node 25 e Deno 2.5 - o primeiro recurso de plataforma JavaScript do tipo a chegar, marcado Baseline Newly available no programa Baseline dos navegadores.
O objeto de opções tem dois botões. O primeiro é alphabet: "base64" (o padrão) ou "base64url". O segundo é lastChunkHandling, que controla o que acontece com o último grupo parcial de caracteres:
| Modo | Regra para o último chunk |
|---|---|
"loose" (padrão) |
dois ou três caracteres, ou quatro com padding; bits de overflow sobrando são ignorados |
"strict" |
exatamente quatro caracteres (padding só onde o comprimento exigir), e os bits de overflow devem ser todos zero |
"stop-before-partial" |
só grupos completos de quatro caracteres são decodificados; uma cauda parcial fica sem ler |
Como atob(), o método ignora espaços em branco ASCII na entrada, então linhas quebradas são tranquilas. Diferente de atob(), ele tem opinião sobre tudo o resto: um caractere fora do alfabeto escolhido, ou um último chunk que viola o modo escolhido, lança um SyntaxError; passar algo que não é string lança um TypeError. Aqui está o modo strict em ação, rejeitando um chunk cujo padding está faltando:
const ok = Uint8Array.fromBase64('SGVsbG8=', { lastChunkHandling: 'strict' });
try {
Uint8Array.fromBase64('VR', { lastChunkHandling: 'strict' });
} catch (error) {
console.log(error.name); // "SyntaxError"
}
Performance é o outro motivo para preferi-la. Num Firefox recente, na máquina do autor, decodificar um payload de 10 megabytes leva menos de dez milissegundos com fromBase64, enquanto o clássico atob mais o mapeamento caractere por caractere leva cerca de vinte vezes mais, porque a parte lenta é o loop em JavaScript, não a matemática do Base64. Se os seus dados são bytes, pule a string por completo.
Para navegadores mais antigos, a situação é simples: mantenha o helper base64ToBytes acima, ou inclua um polyfill pequeno (core-js e o pacote es-arraybuffer-base64 do projeto es-shims ambos trazem um para fromBase64) se você quiser escrever código novo em todo lugar. A API é estável - ela está na especificação do ECMAScript agora - então o que você escrever contra ela não vai ser deprecated.
Lendo um JWT
A "string misteriosa" mais comum em logs de aplicação é um JSON Web Token: três segmentos separados por ponto, header.payload.signature, em que os dois primeiros são objetos JSON codificados em base64url. Decodificar um é uma questão de cinco linhas, e é um aquecimento perfeito para tudo o que vimos até aqui:
function jwtSegmentToBytes (segment) {
let s = segment.replace(/-/g, '+').replace(/_/g, '/');
s += '='.repeat((4 - (s.length % 4)) % 4);
return base64ToBytes(s);
}
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';
const [header64, payload64] = token.split('.');
const payload = JSON.parse(new TextDecoder().decode(jwtSegmentToBytes(payload64)));
console.log(payload.name); // "John Doe"
Agora a parte que iniciantes pulam e sistemas de produção aprendem da forma difícil: o payload não é verificado por ser decodificável. Qualquer um pode escrever um JWT com o payload que quiser; o segmento de assinatura é o que o amarra a um segredo. Verificar um token HS256 no navegador usa a Web Crypto API, que precisa da assinatura em bytes - mais um motivo para o helper de segmento para bytes pagar o seu salário:
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
encoder.encode('shared-secret'),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
);
const [h, p, sig64] = token.split('.');
const valid = await crypto.subtle.verify(
'HMAC',
key,
jwtSegmentToBytes(sig64),
encoder.encode(h + '.' + p)
);
console.log(valid); // true só se a assinatura combinar com o segredo
Três armadilhas merecem nome. Primeira: cheque o header antes de verificar: um token que afirma alg: "none" te pede para confiar no payload sem assinatura, e código ingênuo já foi enganado a fazer exatamente isso. Segunda: respeite as claims de tempo - exp, nbf, iat - depois da verificação, não antes. Terceira: o clássico ataque de confusão de chaves: um servidor configurado para RS256 mas que também aceita HS256 deixa um atacante assinar tokens com a chave pública (que é pública de propósito) usada como segredo HMAC. Em resumo: decodifique à vontade, não confie em nada, verifique tudo.
Abrindo Data URLs
Uma data URL embute um arquivo inteiro dentro de uma URL: data:, um media type opcional, um flag opcional ;base64, uma vírgula, e depois o payload. Payloads de texto são percent-encoded, payloads binários são Base64, e o navegador renderiza tudo sem nenhum pedido HTTP - sem fetch, sem ida e volta ao servidor, nada para cachear. O navegador trata cada data URL como uma origem única e opaca, o que também explica por que elas são um vetor favorito para conteúdo trapaceiro: um documento data:text/html aberto num iframe roda os scripts dele, e uma Content-Security-Policy restritiva pode bloquear data URLs por completo. Mantenha o seu CSP em mente se começar a entregar essas para markup controlado pelo usuário.
Decodificar uma é quase toda cirurgia em string, e depois o mesmo pipeline de bytes de antes:
const url = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADgQFY/fWoOgAAAABJRU5ErkJggg==';
const comma = url.indexOf(',');
const meta = url.slice(5, comma); // "image/png;base64"
const bytes = base64ToBytes(url.slice(comma + 1));
const blob = new Blob([bytes], { type: 'image/png' });
const objectUrl = URL.createObjectURL(blob);
O pedaço meta te diz o media type (aqui image/png, com o marcador ;base64 confirmando que o payload é Base64). Assim que o payload vira um Blob, tudo o que é normal se aplica: uma object URL para um <img>, um link de download, ou um post para um servidor. O único custo de verdade do caminho de data URL é o tamanho - o payload fica cerca de 33 por cento maior que o arquivo original - e uma imagem grande numa URL pode esticar os limites de string da página, o que é mais um voto a favor das object URLs quando o arquivo nunca precisa sair do navegador.
Decodificando arquivos que chegam como texto
Arquivos chegam ao navegador de dois jeitos. O jeito moderno é bytes brutos: um fetch que você lê como ArrayBuffer, ou um File de um picker que você lê com file.arrayBuffer(). Se você está nesse caminho, parabéns - não há Base64 no quadro de jeito nenhum, e você deve ficar nesse caminho, porque bytes não custam nada para carregar, enquanto Base64 custa um terço a mais de banda e memória pelo privilégio. O outro jeito é quando o canal é só texto: uma API JSON que retorna {"attachment": "data:application/pdf;base64,JVBERi..."}, um anexo de e-mail, uma string de configuração, um valor numa coluna de banco de dados. Aí o Base64 é o protocolo, e o seu trabalho é só tirar os bytes:
async function loadRemoteBytes (fileUrl) {
const response = await fetch(fileUrl);
return new Uint8Array(await response.arrayBuffer());
}
const record = JSON.parse(await (await fetch('/api/record/42')).text());
const pdfBytes = base64ToBytes(record.attachment.split(',')[1]);
Três notas sobre esse snippet. Separar na primeira vírgula é tudo o que precisa para tirar o header de data URL (o media type não pode conter vírgula, então a primeira sempre é o separador). E se o valor é Base64 puro sem o prefixo de data URL, é só pular o split. Por fim, aquele await solto é de nível superior, e navegadores só permitem esses dentro de módulos, então o snippet precisa de uma tag <script type="module"> ou de um wrapper async em volta daquelas duas linhas. Partes MIME de e-mail são a mesma história com passos extras: o corpo do anexo é Base64 quebrado a 76 caracteres por linha, mas como atob() pula espaços em branco, você pode entregar o texto quebrado exatamente como chegou na mensagem bruta - sem precisar desenrolar. Esse comportamento sozinho economiza uma quantidade de regex em silêncio.
Verificando um handshake WebSocket
Um dos usos mais charmosos de decodificação no navegador é checar o próprio handshake do WebSocket. O RFC 6455 exige que o cliente envie um cabeçalho Sec-WebSocket-Key (16 bytes aleatórios, codificados em Base64) e que o servidor responda com Sec-WebSocket-Accept: o hash SHA-1 da chave concatenada com um GUID mágico fixo, codificado em Base64. Se o valor não bater, o handshake falha e a conexão não faz upgrade. O ponto inteiro dessa cerimônia é que um servidor que só fala HTTP não consegue completá-la por acidente - o GUID mágico existe para fazer o cálculo parecer exageradamente complicado de propósito. E como o navegador tem tanto o hash quanto a codificação, você pode calcular a resposta esperada sozinho, o que torna o debug de proxies e gateways uma linha só:
const MAGIC = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
async function expectedAccept (clientKey) {
const digest = await crypto.subtle.digest(
'SHA-1',
new TextEncoder().encode(clientKey + MAGIC)
);
return btoa(String.fromCharCode(...new Uint8Array(digest)));
}
const accept = await expectedAccept('dGhlIHNhbXBsZSBub25jZQ==');
console.log(accept); // "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="
A última linha não é coincidência - é o exemplo exato do RFC, reproduzido byte por byte. Quando o seu gateway responde com qualquer outra coisa, agora você sabe precisamente de qual lado da equação está mentindo.
Cabeçalhos HTTP e query strings
Base64 é um favorito para cabeçalhos HTTP porque cabeçalhos devem ser ASCII, e o caso mais famoso é a autenticação Basic: Authorization: Basic seguido da codificação Base64 de username:password. Ler um cabeçalho assim (digamos, ao exibir o que um pedido carrega) é um split e um decode:
const header = 'Basic YWxpY2U6c2VjcmV0MTIz';
const [user, ...rest] = atob(header.slice(6)).split(':');
const password = rest.join(':');
console.log(user, password); // "alice secret123"
O padrão de spread e rejoin lida com o caso estranho-mas-legal de senha contendo dois pontos, porque o ponto de corte é sempre o primeiro, depois do nome de usuário. O mesmo padrão se aplica em todo lugar onde um cabeçalho contrabandeia um valor estruturado: Proxy-Authorization, alguns cabeçalhos específicos de fornecedor, e o cookie ocasional. Em query strings e deep links, Base64 aparece quando um app quer compartilhar estado sem servidor: um valor OAuth state, um formulário de busca restaurado, um marcador de "retomar de onde parei". Decodifique defensivamente - envolva em try/catch, porque o valor cruzou uma fronteira de rede e qualquer coisa pode ter acontecido com ele - e trate o que você recebe como entrada não confiável, ponto final.
O que nos traz à frase que deveria estar pregada acima de cada terminal: Base64 não é criptografia. Não é nem obfuscação em qualquer sentido real, porque a "descodificação" é uma chamada de função que toda linguagem do mundo implementa. Se um valor precisa continuar secreto, codificá-lo em Base64 antes o deixa menos seguro, não mais - cria a ilusão de privacidade e adiciona exatamente um passo trivial para qualquer um que queira o original.
Estado na URL e no storage
A mesma lógica se estende para qualquer coisa que precisa sobreviver a um reload da página ou a um link compartilhado. Os suspeitos de sempre: valores de localStorage e sessionStorage que carregam dados estruturados ou binários, o hash fragment de uma URL para estado de roteamento de single-page-app, e blobs de configuração embutidos em páginas por ferramentas de build. A história do storage merece um exemplo concreto, porque o lado de leitura faz par com o lado de escrita que você vai querer lembrar:
const raw = localStorage.getItem('profile');
const profile = JSON.parse(new TextDecoder().decode(base64ToBytes(raw)));
Três coisas para ter em mente. Primeira, orçamentos: navegadores dão a cada origem mais ou menos 5 megabytes de localStorage, e a sua string Base64 armazenada come cerca de 33 por cento a mais que os dados originais, então um arquivo de 3.5 megabytes vira 4.6 megabytes de storage em silêncio - e a string vive em memória como UTF-16, o que dobra a pegada de novo enquanto a página está aberta. Segunda, consistência: codifique e decodifique com o mesmo charset nos dois lados, senão você armazena bytes perfeitamente bons e lê mojibake. Terceira, links compartilhados: se o estado viaja na URL, use o alfabeto URL-safe para o valor sobreviver a copy-paste, e mantenha ele curto, porque comprimentos de URL acima de um par de mil caracteres começam a deixar clientes antigos e ferramentas de log nervosas.
Quando os dados chegam em pedaços
Às vezes o Base64 não chega como uma string só: um limite de mensagem do WebSocket corta ele ao meio, um stream de server-sent events pinga aos poucos, um upload chunked entrega alguns kilobytes de cada vez. Você não pode chamar atob() num fragmento, porque os grupos do Base64 são unidades de 3 bytes expressas em blocos de 4 caracteres, e um corte no meio de um grupo deixa um parcial pendurado. A correção de escola antiga era bufferizar caracteres até ter um múltiplo de quatro e decodificar o buffer em fatias. A API de 2025 torna isso limpo: Uint8Array.prototype.setFromBase64(string, options) escreve os bytes decodificados num array existente e devolve um objeto com dois números, read (quantos caracteres ele consumiu) e written (quantos bytes ele produziu). Com lastChunkHandling: "stop-before-partial", ele decodifica só grupos completos e deixa a cauda parcial sem ler, que é exatamente o comportamento que um decoder de stream quer:
const parts = [];
let carry = '';
for (const piece of incomingPieces) {
let pending = carry + piece;
for (;;) {
const room = new Uint8Array(8);
const result = room.setFromBase64(pending, {
lastChunkHandling: 'stop-before-partial'
});
parts.push(room.subarray(0, result.written));
pending = pending.slice(result.read);
if (result.read === 0) {
carry = pending;
break;
}
}
}
const size = parts.reduce((sum, part) => sum + part.length, 0);
const bytes = new Uint8Array(size);
let at = 0;
for (const part of parts) {
bytes.set(part, at);
at += part.length;
}
const text = new TextDecoder().decode(bytes);
Leia o loop interno devagar, porque ele é o padrão inteiro: alimente o resto acumulado mais o novo pedaço, deixe o decoder consumir quantos grupos completos couberem, lembre quanto sobrou cortando result.read caracteres, e quando não sobrar nada completo (result.read === 0) guarde o resto como o novo carry e espere o próximo pedaço. O Uint8Array(8) é só um buffer rascunho - um grupo de quatro caracteres produz no máximo três bytes, então oito é generoso. No final, carry guarda o que o stream nunca terminou, que é ou o seu sinal de erro ou a sua checagem de "a conexão encerrou limpa".
Quando não decodificar Base64
Uma boa referência te ensina quando deixar a ferramenta. Se você controla as duas pontas do canal, vá de bytes brutos: fetch com response.arrayBuffer() para downloads, file.arrayBuffer() para arquivos de picker, payloads ArrayBuffer em WebSockets, e FormData multipart para uploads. Nada disso toca em Base64, e você recebe os dados na velocidade cheia, sem o imposto de tamanho e sem a pegada de string em memória. O Base64 paga o seu salário exatamente quando o canal é só texto: bodies JSON, query strings, e-mail, storage, APIs legadas, e qualquer coisa cujo contrato diz "ASCII ou nada". No momento em que um byte resolve, uma string Base64 está pagando um extra de 33 por cento pelo privilégio de ser imprimível, e o extra é cobrado em banda, memória e CPU - três contas que você pode evitar todas.
Armadilhas comuns de decodificação
Depois de todos os caminhos felizes, aqui está a lista de formas como isso morde, mais ou menos na ordem em que você vai encontrá-las:
- Tratar o resultado de
atob()como texto. Ela é uma string binária. Através deTextDecoderela vira texto; impressa diretamente, vira mojibake. Essa única confusão causa a maioria dos relatórios de "Base64 não funciona". - Esperar que Unicode funcione sozinho. Os bytes de "你好" decodificam perfeitamente bem, mas eles continuam sendo bytes até um decoder dizer que são UTF-8. Codifique e decodifique no mesmo charset nos dois lados.
- Alimentar base64url para
atob(). Um único-ou_lança. Converta o alfabeto antes, ou usefromBase64com a opção certa. - Crer que qualquer string longa é Base64. Uma string Base64 válida com padding tem comprimento múltiplo de quatro (base64url sem padding pode terminar em 2 ou 3) e usa no máximo um alfabeto. Comprimento que dá um no módulo de quatro é falha instantânea - cheque antes de gastar um try/catch nisso.
- Confiar em padding que você não combinou. Alguns sistemas tiram o
=, outros mantêm, e alguns adicionam no meio de uma string quebrada, onde ele não pertence. Combine com o remetente, e depois decida ser tolerante (atob) ou rigoroso (fromBase64). - Corrupção silenciosa de um decoder tolerante. Um
TextDecoderpadrão substitui bytes inválidos por U+FFFD e não diz nada. Deixefatal: truequando os dados importam. - Supor que Base64 protege alguma coisa. Não protege. É um formato de serialização, a uma chamada de função de ser texto puro, e "codificamos em Base64 para o usuário não conseguir ler" é uma postura de segurança, não um controle.
- Esquecer memória. Uma string binária decodificada de um megabyte ocupa dois megabytes como string UTF-16, enquanto um
Uint8Arraydos mesmos dados ocupa um. Para payloads grandes, vá direto parafromBase64. - Decodificar de novo em cada render. Decodificar alguns megabytes é rápido, mas não de graça - e não é algo para fazer uma vez por frame. Decodifique uma vez, cacheie os bytes, renderize do cache.
Notas de performance
A versão curta: os decoders nativos são rápidos, e a parte lenta do código antigo geralmente é o JavaScript ao redor deles, não o Base64 em si. Nos tamanhos que importam, o cenário é o mesmo: um payload de 10 megabytes decodifica em menos de dez milissegundos com Uint8Array.fromBase64; o atob sozinho é algumas vezes mais lento, e o loop clássico que mapeia caracteres para um array de bytes leva cerca de vinte vezes mais que fromBase64 para a mesma entrada, porque ele roda algo como treze milhões de gravações de propriedade na thread principal. Consequências práticas: prefira fromBase64 onde a sua audiência tem; mantenha o helper de atob onde não têm; nunca construa um array de bytes concatenando strings num loop; e se precisar processar um payload gigante, considere entregar o Uint8Array decodificado a um Web Worker - os bytes transferem sem cópia, e a thread principal fica livre para manter a UI a 60 quadros por segundo. E lembre a direção da conta: decodificar encolhe, então um buffer decodificado sempre ocupa menos memória que a string de onde veio. Você nunca estoura memória decodificando; só estoura memória mantendo a string e os bytes por mais tempo do que precisa.
Uma breve história da decodificação em navegadores
O Base64 é mais velho que a maior parte da web moderna, mas os decoders dos navegadores têm uma história que vale a pena conhecer, porque ela explica por que o ecossistema está cheio de relíquias. atob e sua irmã btoa são anteriores à spec que agora as cobre: o padrão WHATWG HTML só as definiu em fevereiro de 2011, quando o comportamento de longa data dos navegadores foi reengenhariado dentro do padrão. As engines já as traziam cedo de qualquer jeito: Firefox desde a versão 1 em 2004, Safari 3, Chrome 4. O Internet Explorer pulou as duas completamente até o IE 10 em 2012, o que explica por que JavaScript pré-2012 é um museu de Base64 feito à mão - tabelas de busca, ginástica com String.fromCharCode, e a famosa invocação unescape(encodeURIComponent()) para Unicode, um par de funções que foram deprecated na linguagem e sobreviveram em navegadores por uma década de pura inércia. Depois veio a camada de charset: TextEncoder e TextDecoder do padrão Encoding chegaram entre 2013 e 2017 (Firefox 18, Chrome 38, Safari 10.1, e nunca em nenhum IE), finalmente dando à plataforma um jeito principiado de transformar bytes em palavras. O Node.js, que nunca teve atob ou btoa como globals até a versão 16 em 2021, viveu a fase inicial com Buffer e um par de shims pequenos de npm. E aí o ciclo fechou: o Firefox 133 (novembro de 2024) e o Safari 18.2 (dezembro de 2024) traziram primeiro Uint8Array.fromBase64, toBase64 e companhia, e a segunda metade de 2025 completou o conjunto quando o Chrome 140 (setembro) e o Node 25 (meados de outubro) chegaram e o programa Baseline os marcou Newly available, a primeira vez que a linguagem em si - não a plataforma web - ganhou Base64 embutido. Um formato de décadas acabou de virar um recurso de biblioteca padrão da linguagem, e a próxima década de código pode parar de copiar helpers de um lugar para o outro.
Fatos divertidos
- O teste mais rápido de "isso é Base64 mesmo?" que existe é
string.length % 4 === 0. Toda string Base64 válida com padding passa; qualquer outra coisa é uma desconhecida. atob('')devolve''. A string vazia é a única entrada sem bytes, e ela faz ida e volta limpa por todo o pipeline - nenhum caso especial necessário, nunca.- O GUID mágico do WebSocket,
258EAFA5-E914-47DA-95CA-C5AB0DC85B11, é um valor fixo gravado no RFC, escolhido para que um servidor HTTP comum nunca completasse o handshake por acidente. É a constante mais famosa da engenharia de protocolos que ninguém jamais gera. - Chrome e Firefox lançam a mesma exceção para a mesma falha, mas com mensagens diferentes. Pegue pelo
error.name, não pela string da mensagem, senão o seu tratamento de erro vai ter sotaque de navegador. - Uma string binária de um megabyte pesa dois megabytes em memória, porque strings JavaScript são UTF-16: cada byte decodificado viaja acompanhado de um byte de folga não usada. O
Uint8Arraynão tem esse imposto. - "Data URI" é um nome aposentado. O WHATWG mudou o nome para "data URL" como parte da grande harmonização de URI para URL, o que explica por que você vai encontrar as duas grafias em specs, posts e nomes de pacotes.
- O RFC 4648 traz uma tabela de vetores de teste - "f", "fo", "foo", "foob", "fooba", "foobar" e companhia, cada um com a sua codificação conhecida - que autores de decoders vêm checando há vinte anos. Se o seu decoder passa nessas linhas, ele quase certamente está correto.
- A string Base64 mais produzida na história da computação é quase com certeza
aGVsbG8=, a codificação de "hello". Todo tutorial de "comece aqui", toda suíte de testes e toda resposta do Stack Overflow do planeta contribuem com o seu voto.
Fechando
Então o ofício inteiro de decodificação no navegador cabe numa página: atob() para o decode rápido, tolerante e universal; TextDecoder para transformar os bytes nas palavras que você de verdade quer, com fatal: true quando os dados importam; e Uint8Array.fromBase64 para o caminho moderno, rigoroso e rápido que pula a string por completo. No meio, as variantes têm nomes e regras: base64url para qualquer coisa que viaja numa URL, padding que pode estar ou não, espaços em branco que o decoder antigo come em silêncio. E embaixo de tudo, duas atitudes: os bytes não são o texto, e o texto não é o segredo. Decodifique de propósito, verifique antes de confiar, e quando o canal permitir, pule o Base64 e leve os bytes.
A outra metade da viagem - pegar os seus bytes e texto e transformá-los na string imprimível de onde tudo isso começou - é coberta em detalhe no guia companheiro de codificação Base64 em JavaScript, linkado abaixo.
Última atualização: 2026-09-08
Artigo relacionado: Codificação Base64 em JavaScript/Browser: um guia completo