¿Tiene que ocuparse del formato Base64? Entonces esta página es perfecta para Ud. Utilice nuestra práctica herramienta en línea para codificar o decodificar sus datos.

Decodificación Base64 en JavaScript/Browser: una guía completa

Llega con una docena de disfraces distintos: un JWT escondido en la cabecera Authorization, un blob image/png dentro de una respuesta JSON, un valor Sec-WebSocket-Accept en el registro de un handshake, un adjunto de correo envuelto en MIME, un valor que tu backend metió con amabilidad en una cadena de consulta. La cadena en sí siempre se ve igual: una larga ristra de letras, dígitos, el ocasional + o /, y quizá un = o dos al final. Si la página principal de este sitio te enseñó qué es Base64 - cuatro caracteres imprimibles que sustituyen a cada tres bytes, con = de relleno para rematar el último grupo - entonces este artículo trata de la parte que de verdad haces en código: devolver esos caracteres a bytes, y los bytes a significado, usando solo lo que el navegador ya trae.

Dos reglas de oro rápidas antes de empezar. Primera: decodificar es la dirección que encoge: por cada cuatro caracteres que lees, salen tres bytes, así que la salida siempre ocupa menos memoria que la entrada. Segunda: una cadena Base64 decodificada no es automáticamente texto. Son bytes, y los bytes pueden resultar ser UTF-8, Windows-1252, la cabecera de un PNG o una firma criptográfica. El bug más común en código Base64 es olvidar cuál de esos estás sosteniendo, así que las secciones de abajo están organizadas en torno a esa pregunta.

Las tres capas de la decodificación

Los navegadores modernos te dan tres capas nativas, y la buena noticia es que no hace falta ningún paquete. Cada una responde a una pregunta ligeramente distinta, y elegir la adecuada te ahorra muchos fragmentos copiados y pegados de Stack Overflow:

Capa Lo que come Lo que te entrega Personalidad Disponibilidad
atob() cadena Base64 estándar una "cadena binaria" (un byte por carácter) muy indulgente: omite los espacios ASCII y acepta falta de relleno todos los navegadores desde los 2000, IE 10+, Node 16+
TextDecoder bytes (Uint8Array) texto JavaScript legible configurable: etiqueta para el charset, flag fatal para la estrictez Firefox 18, Chrome 38, Safari 10.1 y posteriores (nunca IE)
Uint8Array.fromBase64() cadena Base64 más opciones un Uint8Array de verdad estricta con mandos: alfabeto y manejo del último trozo Baseline 2025: Chrome 140, Firefox 133, Safari 18.2, Node 25

La forma de todo el artículo sale de esa tabla. atob() es la mula de carga que encontrarás en todas partes, incluido el código viejo. TextDecoder es el puente de los bytes a las palabras. Y Uint8Array.fromBase64() es la actualización de 2025 que se salta el paso intermedio por completo cuando lo que querías desde el principio eran bytes.

atob: rápido, indulgente y muy viejo

Todo el contrato cabe en una línea: atob(encodedData). Toma una cadena codificada en Base64 y devuelve una "cadena binaria": una cadena JavaScript normal en la que cada carácter guarda exactamente un byte decodificado, un punto de código de 0 a 255. Ese tipo de retorno importa, porque no es lo mismo que texto legible (volveremos sobre ello abajo). La función en sí es tan rápida como se puede ser, y lleva ahí muchísimo tiempo: Chrome 4, Firefox 1, Safari 3, y - este es el que más gente recuerda - Internet Explorer solo a partir de la versión 10, por eso el código escrito antes de 2012 está lleno de tablas Base64 hechas a mano.

Lo que hace a atob() agradable es lo mucho que perdona antes de rendirse. El estándar HTML de WHATWG dice ignorar todos los espacios ASCII - espacio, tabulación, salto de línea, salto de página, retorno de carro - antes de decodificar, así que una cadena envuelta en MIME con saltos de línea cada 76 caracteres se decodifica sin que tú tengas que limpiar nada. La falta de relleno también se perdona. Pero en el momento en que ve un carácter fuera del alfabeto, o una longitud que nunca podría ser válida, lanza una DOMException llamada InvalidCharacterError. Sin basura silenciosa, sin resultados a medias.

Aquí va el informe de daños, fila a fila:

Entrada Resultado
"SGVsbG8sIFdvcmxkIQ==" "Hello, World!" - el caso de manual
"aGVsbG8" (sin relleno) "hello" - un = faltante se perdona
"SGVs\nbG8s\nIFdvcmxkIQ==" (líneas envueltas) "Hello, World!" - primero se omiten los espacios ASCII
"" (cadena vacía) "" - la entrada vacía es válida y hace el redondo
"A" (un carácter suelto) lanza InvalidCharacterError - un carácter no puede codificar nada
"Zm9vYmFy!" (un ! fuera de lugar) lanza InvalidCharacterError - fuera del alfabeto
"ZGFua29nYWk-" (un carácter URL-safe mezclado) lanza InvalidCharacterError - no hay que mezclar los dos alfabetos
"Zm9v====" (demasiado relleno) lanza InvalidCharacterError - como mucho dos = al final

Una nota práctica: el mensaje de error en sí difiere entre motores (Firefox dice "String contains an invalid character", Chrome dice que la cadena "contains characters outside of the Latin1 range" para entrada no Latin1 o "is not correctly encoded" para base64 no válido), así que atrapa por el nombre de la excepción, no por el texto del mensaje.

De bytes crudos a texto de verdad

Ese tipo de retorno de "cadena binaria" merece un alto en el camino, porque es la fuente de la mayor parte de la confusión al decodificar. Las cadenas JavaScript son UTF-16, así que atob() te entrega una cadena cuyos caracteres son valores de byte, no glifos legibles. Si tu payload era la codificación UTF-8 del texto "hello 你好", imprimir el resultado directamente te da mojibake. La solución es una decodificación en dos pasos: Base64 a bytes, y luego bytes a texto.

Primero, el paso de Base64 a bytes. Este pequeño helper es la receta clásica y vale la pena guardarlo en el bolsillo, porque es la pieza que sostiene la mayor parte de los ejemplos de este artículo:

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;
}

Luego el paso de bytes a texto, con TextDecoder. Para UTF-8 (el predeterminado, y la elección correcta para JSON, payloads de JWT y la mayor parte de los datos web) la llamada es una línea:

const bytes = base64ToBytes('aGVsbG8g5L2g5aW9');
const text = new TextDecoder('utf-8').decode(bytes);
console.log(text); // "hello 你好"

¿Por qué dos pasos? Porque atob() no tiene ni idea de en qué charset se produjeron los bytes. Es un puro convertidor de bits. TextDecoder es el componente que interpreta los bytes como un charset, y acepta una etiqueta para el trabajo: utf-8, windows-1252, iso-8859-1, utf-16le, más unas 220 etiquetas. Los datos que salieron de una aplicación de los años 90 suelen ser Windows-1252, y basta con un argumento del constructor:

const decoder = new TextDecoder('windows-1252');
const text = decoder.decode(bytes); // los mismos bytes, distinta interpretación

El constructor de TextDecoder también acepta un flag fatal, y vale la pena ponerlo en true cada vez que el texto decodificado alimenta algo importante. Por defecto el decodificador es indulgente: las secuencias de bytes inválidas se sustituyen en silencio por el carácter de reemplazo de Unicode, U+FFFD, y nunca te lo avisan. Con fatal: true, el mismo daño lanza un TypeError en vez de esconderse:

const strict = new TextDecoder('utf-8', { fatal: true });
try {
  strict.decode(corruptedBytes);
} catch (error) {
  console.log(error.name); // "TypeError"
}

Este es uno de esos interruptores que en la documentación parece menor y en producción parece un incidente de datos. Si tu entrada la aporta el usuario o la red, decodifica en estricto y maneja el error a propósito.

La entrada URL-safe necesita un desvío

Una variante de Base64 merece su propia sección, porque aparece constantemente en el mundo salvaje y atob() no la lee. Es el alfabeto seguro para URLs y nombres de archivo de la sección 5 del RFC 4648, normalmente llamado base64url: los mismos 64 caracteres, salvo que + y / se sustituyen por - y _, y el relleno = a menudo se elimina, porque la longitud de los datos se conoce de forma implícita. El cambio existe por una razón concreta: en una URL, + significa un espacio y / empieza un segmento de ruta, así que el alfabeto estándar habría que percent-codificarlo carácter a carácter. Base64url viaja limpio por cadenas de consulta, segmentos de ruta, fragmentos y nombres de archivo.

El problema es que los dos alfabetos no son intercambiables, y atob() solo habla el estándar. Pásale un - o un _ y obtienes InvalidCharacterError. Tienes dos opciones limpias.

Opción uno, que funciona en todas partes: convierte el alfabeto y restaura el relleno antes de llamar a 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"

La expresión (4 - (s.length % 4)) % 4 es todo el truco: calcula cuántos caracteres = necesitaría una cadena bien rellenada de esa longitud, de cero hasta dos.

Opción dos, en navegadores 2025+: el nuevo decodificador nativo toma el alfabeto como opción, así que ninguna cirugía de cadenas:

const bytes = Uint8Array.fromBase64('P3-0', { alphabet: 'base64url' });
console.log(Array.from(bytes).join(', ')); // "63, 127, 180"

Dos reglas te mantienen fuera de problemas. Nunca mezcles alfabetos dentro de un único valor - un decodificador que ve tanto un + como un - no tiene forma de saber a qué familia pertenece, y el comportamiento correcto según la especificación es fallar. Y acuerda con el otro lado del cable si el relleno está presente: eliminarlo es legal para base64url, así que un receptor debe estar preparado para ambas formas. atob() ya lo está; las opciones nativas de abajo te dan un mando para ello.

El atajo de 2025: Uint8Array.fromBase64

Si miras hacia atrás el helper base64ToBytes, notarás que hace dos cosas: decodificar Base64 y luego copiar los caracteres a un array de bytes uno a uno en JavaScript. Ese bucle de copia es la parte lenta y evitable, justo lo que el nuevo método de ECMAScript elimina. Uint8Array.fromBase64(string, options) va directo de la cadena codificada al array de bytes, y llega en Chrome 140, Edge 140, Firefox 133, Safari 18.2, Node 25 y Deno 2.5 - la primera característica de plataforma JavaScript de su tipo en llegar, marcada como Baseline Newly available en el programa Baseline de los fabricantes de navegadores.

El objeto de opciones tiene dos mandos. El primero es alphabet: "base64" (el predeterminado) o "base64url". El segundo es lastChunkHandling, que controla qué pasa con el grupo final y parcial de caracteres:

Modo Regla para el último trozo
"loose" (predeterminado) dos o tres caracteres, o cuatro con relleno; los bits sobrantes se ignoran
"strict" exactamente cuatro caracteres (relleno solo donde la longitud lo exija), y los bits sobrantes deben ser todos cero
"stop-before-partial" solo se decodifican grupos completos de cuatro caracteres; una cola parcial se deja sin leer

Como atob(), el método ignora los espacios ASCII en la entrada, así que las líneas envueltas van bien. A diferencia de atob(), es opinionado sobre todo lo demás: un carácter fuera del alfabeto elegido, o un último trozo que viola el modo elegido, lanza un SyntaxError; pasar algo que no es una cadena lanza un TypeError. Aquí está el modo strict en acción, rechazando un trozo cuyo relleno falta:

const ok = Uint8Array.fromBase64('SGVsbG8=', { lastChunkHandling: 'strict' });
try {
  Uint8Array.fromBase64('VR', { lastChunkHandling: 'strict' });
} catch (error) {
  console.log(error.name); // "SyntaxError"
}

El rendimiento es la otra razón para preferirlo. En un Firefox reciente en la máquina del autor, decodificar un payload de 10 megabytes toma unos milisegundos de un solo dígito con fromBase64, mientras que el clásico atob más el mapeo carácter a carácter de bytes tarda unas veinte veces más, porque la parte lenta es el bucle a nivel JavaScript, no la matemática Base64. Si tus datos son bytes, salta la cadena por completo.

Para navegadores más viejos, la situación es simple: mantén el helper base64ToBytes de arriba, o incorpora un pequeño polyfill (core-js y el paquete es-arraybuffer-base64 del proyecto es-shims incluyen uno para fromBase64) si quieres escribir código de nuevo estilo en todas partes. La API es estable - ya está en la especificación de ECMAScript - así que lo que escribas sobre ella no va a ser deprecado.

Leer un JWT

La "cadena misteriosa" más común en los registros de aplicaciones es un JSON Web Token: tres segmentos separados por puntos, header.payload.signature, donde los dos primeros son objetos JSON codificados en base64url. Decodificar uno es asunto de cinco líneas, y es un calentamiento perfecto para todo lo visto hasta ahora:

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"

Ahora la parte que los principiantes se saltan y los sistemas de producción aprenden a la manera dura: el payload no está verificado por ser decodificable. Cualquiera puede escribir un JWT con el payload que le apetezca; el segmento de firma es lo que lo ata a un secreto. Verificar un token HS256 en el navegador usa la Web Crypto API, que necesita la firma como bytes - otra razón por la que el helper de segmento a bytes se gana el sueldo:

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 solo si la firma coincide con el secreto

Tres trampas merecen nombre. Primera: comprueba la cabecera antes de verificar: un token que afirma alg: "none" te pide que confíes en el payload sin firma, y código ingenuo se ha dejado engañar para hacer exactamente eso. Segunda: respeta las claims de tiempo - exp, nbf, iat - después de verificar, no antes. Tercera: el clásico ataque de confusión de claves: un servidor configurado para RS256 que también acepta HS256 deja que un atacante firme tokens con la clave pública (que es pública a propósito) usada como secreto HMAC. En resumen: decodifica libremente, no confíes en nada, verifica todo.

Abrir data URLs

Una data URL incrusta un archivo entero dentro de una URL: data:, un tipo de medio opcional, un flag opcional ;base64, una coma, y luego el payload. Los payloads de texto van percent-codificados, los binarios van en Base64, y el navegador los renderiza sin ninguna petición HTTP - sin fetch, sin viaje de ida y vuelta al servidor, nada que cachear. El navegador trata cada data URL como un origen opaco y único, y por eso también son un vector favorito para contenido tramposo: un documento data:text/html abierto en un iframe ejecuta sus scripts, y una Content-Security-Policy restrictiva puede bloquear data URLs por completo. Ten tu CSP en mente si empiezas a entregarlos a markup controlado por el usuario.

Decodificar una es casi todo cirugía de cadenas, y luego la misma canalización 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);

La pieza meta te dice el tipo de medio (aquí image/png, con la marca ;base64 confirmando que el payload es Base64). En cuanto el payload es un Blob, aplica todo lo normal: una object URL para un <img>, un enlace de descarga, o un post a un servidor. El único coste real de la ruta data-URL es el tamaño - el payload va un 33 por ciento más grande que el archivo original - y una imagen grande en una URL puede tensionar los límites de cadena de la página, otro voto a favor de las object URLs cuando el archivo nunca necesita salir del navegador.

Decodificar archivos que llegan como texto

Los archivos llegan al navegador de dos maneras. La moderna es bytes crudos: un fetch que lees como ArrayBuffer, o un File de un selector que lees con file.arrayBuffer(). Si vas por ese camino, enhorabuena - no hay Base64 en ninguna parte, y deberías quedarte en ese camino, porque los bytes no cuestan nada de llevar mientras que Base64 cuesta un tercio extra de ancho de banda y memoria por el privilegio. La otra manera es cuando el canal es solo de texto: una API JSON que devuelve {"attachment": "data:application/pdf;base64,JVBERi..."}, un adjunto de correo, una cadena de configuración, un valor en una columna de base de datos. Entonces Base64 es el protocolo, y tu trabajo es solo sacar los 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]);

Tres notas sobre ese fragmento. Dividir en la primera coma es todo lo que hace falta para quitar la cabecera data-URL (el tipo de medio no puede contener comas, así que la primera es siempre el separador). Y si el valor es Base64 plano sin prefijo data-URL, simplemente salta la división. Por último, ese await suelto es uno a nivel superior, y los navegadores solo permiten esos dentro de módulos, así que el fragmento necesita una etiqueta <script type="module"> o un envoltorio async alrededor de esas dos líneas. Las partes MIME de correo son la misma historia con más pasos: el cuerpo del adjunto es Base64 envuelto a 76 caracteres por línea, pero como atob() omite los espacios, puedes darle el texto envuelto tal y como llegó en el mensaje crudo - sin necesidad de desenvolverlo. Ese comportamiento salva en silencio mucho regex.

Verificar un handshake de WebSocket

Uno de los usos más encantadores de decodificar en el navegador es comprobar el propio handshake de WebSocket. El RFC 6455 exige al cliente enviar la cabecera Sec-WebSocket-Key (16 bytes aleatorios, codificados en Base64), y al servidor responder con Sec-WebSocket-Accept: el hash SHA-1 de la clave concatenada con un GUID mágico fijo, codificado en Base64. Si el valor no coincide, el handshake falla y la conexión no se actualiza. El punto entero de esta ceremonia es que un servidor que solo habla HTTP no puede completarla por accidente - el GUID mágico existe para hacer que el cálculo parezca deliberadamente sobrecomplicado. Y como el navegador tiene tanto el hash como la codificación, puedes calcular la respuesta esperada tú mismo, lo que convierte depurar proxies y gateways en una línea:

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="

Esa última línea no es una coincidencia - es el ejemplo exacto del RFC, reproducido byte a byte. Cuando tu gateway responda con cualquier otra cosa, ya sabes con precisión qué lado de la ecuación miente.

Cabeceras HTTP y cadenas de consulta

Base64 es favorito para cabeceras HTTP porque las cabeceras deben ser ASCII, y el caso más famoso es la autenticación Basic: Authorization: Basic seguido de la codificación Base64 de username:password. Leer tal cabecera (digamos, mientras muestras lo que lleva una petición) es una división y una decodificación:

const header = 'Basic YWxpY2U6c2VjcmV0MTIz';
const [user, ...rest] = atob(header.slice(6)).split(':');
const password = rest.join(':');
console.log(user, password); // "alice secret123"

El patrón de desplegar y reunir maneja el caso incómodo pero legal de una contraseña que contiene dos puntos, porque el punto de división es siempre el primero después del usuario. El mismo patrón aplica donde cualquier cabecera contrabandea un valor estructurado: Proxy-Authorization, algunas cabeceras específicas de fabricante, y la cookie ocasional. En cadenas de consulta y deep links, Base64 aparece cuando una app quiere compartir estado sin servidor: un valor de state de OAuth, un formulario de búsqueda restaurado, una marca de "retomar donde lo dejé". Decodifica de forma defensiva - envuelve en try/catch, porque el valor cruzó una frontera de red y puede haberle pasado de todo - y trata lo que obtengas como entrada no confiable, punto.

Lo cual nos lleva a la frase que debería estar clavada sobre cada terminal: Base64 no es cifrado. Ni siquiera es ofuscación en ningún sentido real, porque el "descifrado" es una llamada a una función que cada idioma de la tierra implementa. Si un valor necesita quedarse secreto, codificarlo primero en Base64 lo hace menos seguro, no más - crea la ilusión de privacidad y añade exactamente un paso trivial para cualquiera que quiera el original.

Estado en la URL y en el almacenamiento

La misma lógica se extiende a lo que tenga que sobrevivir a una recarga de página o a un enlace compartido. Los sospechosos habituales: valores de localStorage y sessionStorage que llevan datos estructurados o binarios, el fragmento hash de una URL para el estado de enrutamiento de single-page-apps, y blobs de configuración incrustados en páginas por herramientas de build. La historia del almacenamiento merece un ejemplo concreto, porque el lado de lectura va junto al lado de escritura que vas a querer recordar:

const raw = localStorage.getItem('profile');
const profile = JSON.parse(new TextDecoder().decode(base64ToBytes(raw)));

Tres cosas que tener en cuenta. Primera, presupuestos: los navegadores dan a cada origen unos 5 megabytes de localStorage, y tu cadena Base64 almacenada come unos 33 por ciento más que los datos originales, así que un archivo de 3,5 megabytes se convierte en silencio en 4,6 megabytes de almacenamiento - y la cadena vive en memoria como UTF-16, lo que duplica la huella otra vez mientras la página está abierta. Segunda, consistencia: codifica y decodifica con el mismo charset en ambos lados, o almacenarás bytes perfectamente buenos y leerás mojibake. Tercera, enlaces compartidos: si el estado viaja en la URL, usa el alfabeto URL-safe para que el valor sobreviva al copiar y pegar, y mantenlo corto, porque longitudes de URL por encima de un par de miles de caracteres empiezan a poner nerviosos a los clientes viejos y a las herramientas de registro.

Cuando los datos llegan en trozos

A veces el Base64 no llega como una sola cadena: un límite de mensaje de WebSocket lo corta por la mitad, un stream de eventos enviados por servidor lo gotea, una subida por trozos lo alimenta de pocos kilobytes en cada pasada. No puedes llamar a atob() sobre un fragmento, porque los grupos Base64 son unidades de 3 bytes expresadas en bloques de 4 caracteres, y un corte a mitad de un grupo deja un parcial colgando. La solución clásica era acumular caracteres en un buffer hasta tener un múltiplo de cuatro y decodificar el buffer en rebanadas. La API de 2025 lo limpia: Uint8Array.prototype.setFromBase64(string, options) escribe bytes decodificados en un array existente y devuelve un objeto con dos números, read (cuántos caracteres consumió) y written (cuántos bytes produjo). Con lastChunkHandling: "stop-before-partial", decodifica solo grupos completos y deja la cola parcial sin leer, que es exactamente el comportamiento que quiere un decodificador de stream:

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);

Lee el bucle interno despacio, porque es todo el patrón: alimenta el resto llevado más la pieza nueva, deja que el decodificador consuma tantos grupos completos como quepan, recuerda cuánto sobró cortando result.read caracteres, y cuando no quede nada completo (result.read === 0) guarda el resto como el nuevo carry y espera a la siguiente pieza. El Uint8Array(8) es solo un buffer de trabajo - un grupo de cuatro caracteres produce como máximo tres bytes, así que ocho es generoso. Al final, carry guarda lo que el stream nunca terminó, que es o bien tu señal de error o tu comprobación de "la conexión terminó limpiamente".

Cuando no decodificar Base64

Una referencia útil te enseña cuándo dejar la herramienta. Si controlas ambos extremos del canal, ve a por bytes crudos: fetch con response.arrayBuffer() para descargas, file.arrayBuffer() para archivos del selector, payloads ArrayBuffer en WebSockets, y FormData multipart para subidas. Nada de eso toca Base64, y obtienes los datos a toda velocidad sin el impuesto de tamaño ni la huella de cadena en memoria. Base64 se gana el sueldo precisamente cuando el canal es solo de texto: cuerpos JSON, cadenas de consulta, correo, almacenamiento, APIs heredadas, y lo que sea cuyo contrato diga "o ASCII o nada". En el momento en que un byte bastaría, una cadena Base64 está pagando un recargo del 33 por ciento por el privilegio de ser imprimible, y el recargo se cobra en ancho de banda, memoria y CPU - tres facturas que puedes evitar todas.

Trampas comunes de decodificación

Después de todos los caminos felices, aquí está la lista de formas en que esto muerde, más o menos en el orden en que las conocerás:

  • Tratar el resultado de atob() como texto. Es una cadena binaria. A través de TextDecoder se convierte en texto; impreso directamente, se convierte en mojibake. Esta única confusión causa la mayoría de los reportes de "Base64 no funciona".
  • Esperar que el Unicode funcione solo. Los bytes de "你好" se decodifican encantados, pero siguen siendo bytes hasta que un decodificador te dice que son UTF-8. Codifica y decodifica en el mismo charset en ambos lados.
  • Alimentar base64url a atob(). Un solo - o _ lanza. Convierte el alfabeto primero, o usa fromBase64 con la opción correcta.
  • Creer que cualquier cadena larga es Base64. Una cadena Base64 válida y rellena tiene una longitud múltiplo de cuatro (el base64url sin relleno puede terminar en 2 o 3) y usa como mucho un alfabeto. Una longitud de uno módulo cuatro es un fallo instantáneo - compruébalo antes de gastar un try/catch.
  • Confiar en un relleno que no acordaste. Algunos sistemas quitan =, algunos lo mantienen, y algunos lo añaden a mitad de una cadena envuelta, donde no toca. Acuerda con el emisor, y luego decide si ser indulgente (atob) o estricto (fromBase64).
  • Corrupción silenciosa de un decodificador indulgente. Un TextDecoder predeterminado sustituye los bytes inválidos por U+FFFD y no dice nada. Pon fatal: true cuando los datos importen.
  • Asumir que Base64 protege algo. No lo hace. Es un formato de serialización, a una llamada de función del texto plano, y "lo pasamos por Base64 para que los usuarios no lo lean" es una postura de seguridad, no un control.
  • Olvidar la memoria. Una cadena binaria decodificada de un megabyte ocupa dos megabytes como cadena UTF-16, mientras que un Uint8Array con los mismos datos ocupa uno. Para payloads grandes, ve directo a fromBase64.
  • Re-decodificar en cada render. Decodificar unos megabytes es rápido, pero no gratis - y no es algo para hacer una vez por frame. Decodifica una vez, cachea los bytes, renderiza desde la caché.

Notas de rendimiento

La versión corta: los decodificadores nativos son rápidos, y la parte lenta del código viejo suele ser el JavaScript que los rodea, no el Base64 en sí. En los tamaños que importan, el panorama es el mismo: un payload de 10 megabytes se decodifica en milisegundos de un solo dígito con Uint8Array.fromBase64; atob solo es unas pocas veces más lento, y el clásico bucle posterior que mapea caracteres a un array de bytes tarda unas veinte veces más que fromBase64 con la misma entrada, porque ejecuta unos trece millones de escrituras de propiedades en el hilo principal. Consecuencias prácticas: prefiere fromBase64 donde tu audiencia lo tiene; mantén el helper de atob donde no lo tiene; nunca construyas un array de bytes concatenando cadenas en un bucle; y si debes procesar un payload enorme, considera pasar el Uint8Array decodificado a un Web Worker - los bytes se transfieren sin copiar, y el hilo principal queda libre para mantener la UI a 60 fotogramas por segundo. Y recuerda la dirección de la matemática: decodificar encoge, así que un buffer decodificado siempre ocupa menos memoria que la cadena de la que salió. Nunca te quedarás sin memoria decodificando; solo te puedes quedar sin memoria manteniendo la cadena y los bytes más tiempo del que necesitas.

Una breve historia de la decodificación en navegadores

Base64 es más viejo que la mayor parte de la web moderna, pero los decodificadores del navegador tienen una historia que vale la pena conocer, porque explica por qué el ecosistema está lleno de reliquias. atob y su hermano btoa son anteriores a la especificación que ahora los cubre: el estándar HTML de WHATWG solo los definió en febrero de 2011, cuando su comportamiento navegador de larga data se inversó en el estándar. Los motores los habían lanzado temprano de todos modos: Firefox desde la versión 1 en 2004, Safari 3, Chrome 4. Internet Explorer se los saltó por completo hasta IE 10 en 2012, por eso el JavaScript anterior a 2012 es un museo de Base64 hecho a mano - tablas de búsqueda, gimnasia con String.fromCharCode, y la infame invocación unescape(encodeURIComponent()) para Unicode, un par de funciones deprecadas en el idioma y que sobrevivieron en navegadores durante una década por pura inercia. Luego llegó la capa de charset: TextEncoder y TextDecoder del estándar Encoding llegaron entre 2013 y 2017 (Firefox 18, Chrome 38, Safari 10.1, y nunca en ningún IE), dando por fin a la plataforma una forma principada de convertir bytes en palabras. Node.js, que nunca tuvo atob ni btoa como globales hasta la versión 16 en 2021, pasó su vida anterior con Buffer y un par de shims pequeños de npm. Y entonces el círculo se cerró: Firefox 133 (noviembre de 2024) y Safari 18.2 (diciembre de 2024) lanzaron primero Uint8Array.fromBase64, toBase64 y compañía, y la segunda mitad de 2025 completó el set cuando Chrome 140 (septiembre) y Node 25 (mediados de octubre) llegaron y el programa Baseline los marcó como Newly available, la primera vez que el propio idioma - no la plataforma web - tuvo Base64 integrado. Un formato de décadas de edad acaba de convertirse en una característica de la biblioteca estándar del idioma, y la próxima década de código puede dejar de copiar helpers por ahí.

Datos curiosos

  • La prueba más rápida de "¿esto es Base64 siquiera?" que existe es string.length % 4 === 0. Toda cadena Base64 válida y rellena la pasa; cualquier otra cosa es una desconocida.
  • atob('') devuelve ''. La cadena vacía es la única entrada sin bytes, y hace el redondo limpio por toda la canalización - nunca hace falta un caso especial.
  • El GUID mágico de WebSocket, 258EAFA5-E914-47DA-95CA-C5AB0DC85B11, es un valor fijo grabado en el RFC, elegido para que un servidor HTTP plano nunca pudiera completar el handshake por accidente. Es la constante más famosa de la ingeniería de protocolos que nadie genera jamás.
  • Chrome y Firefox lanzan la misma excepción para el mismo fallo pero con mensajes distintos. Atrapa por error.name, no por la cadena del mensaje, o tu manejo de errores tendrá acento de navegador.
  • Una cadena binaria de un megabyte pesa dos megabytes en memoria, porque las cadenas JavaScript son UTF-16: cada byte decodificado viaja acompañado de un byte de holgura sin usar. El Uint8Array no tiene ese impuesto.
  • "Data URI" es un nombre retirado. WHATWG lo renombró a "data URL" como parte de la gran armonización de URI a URL, por eso encontrarás ambas grafías en especificaciones, posts y nombres de paquetes.
  • El RFC 4648 incluye una tabla de vectores de prueba - "f", "fo", "foo", "foob", "fooba", "foobar" y compañía, cada uno con su codificación conocida - contra la que los autores de decodificadores han estado comprobando durante veinte años. Si tu decodificador pasa esas filas, es casi seguro que es correcto.
  • La cadena Base64 más producida en la historia de la computación es casi seguro aGVsbG8=, la codificación de "hello". Cada tutorial de "primeros pasos", suite de pruebas y respuesta de Stack Overflow del planeta aporta su voto.

Para terminar

Así que todo el oficio de decodificar en el navegador cabe en una página: atob() para la decodificación rápida, indulgente y universal; TextDecoder para convertir los bytes en las palabras que de verdad quieres, con fatal: true cuando los datos importan; y Uint8Array.fromBase64 para la ruta moderna, estricta y rápida que salta la cadena por completo. En el medio, las variantes tienen nombres y reglas: base64url para lo que viaja en una URL, relleno que puede estar o no, espacios que el decodificador viejo se come en silencio. Y bajo todo ello, dos actitudes: los bytes no son el texto, y el texto no es el secreto. Decodifica a propósito, verifica antes de confiar, y cuando el canal lo permita, salta Base64 y toma los bytes.

La otra mitad del viaje - tomar tus bytes y tu texto y convertirlos en la cadena imprimible con la que todo esto empezó - se cubre en detalle en la guía compañera sobre codificación Base64 en JavaScript, enlazada abajo.

Última actualización: 2026-09-08

Artículo relacionado: Codificación Base64 en JavaScript/Browser: una guía completa