Codificación Base64 en JavaScript/Browser: una guía completa
Tienes algo que necesita viajar, y el camino solo tiene suficiente anchura para ASCII plano. Puede ser una imagen que pertenece dentro de una respuesta JSON, un objeto de configuración que tiene que ir a bordo en una URL, un token cuyos tres segmentos son puntos y letras, un archivo que una API exige que llegue como cadena Base64 dentro de un cuerpo JSON. Base64 es el peaje para exactamente esta situación, y la página principal de este sitio ya repasa el formato - cuatro caracteres imprimibles que sustituyen a cada tres bytes, con = de relleno para rematar el grupo - así que aquí va el único número que hay que guardar en la cabeza mientras lees: codificar es la dirección que crece. Cada tres bytes que entregas vuelven como cuatro caracteres, un impuesto de tamaño de unos 33 por ciento, cobrado en ancho de banda, almacenamiento y memoria. Usa Base64 cuando el canal exija texto imprimible, y conoce exactamente cuánto te cuesta ese impuesto.
La noticia animadora es que el navegador siempre ha podido hacer este trabajo sin un solo paquete. btoa() lleva en circulación desde principios de los 2000, TextEncoder convirtió tu texto Unicode real en bytes honestos hace una década, y en la ola Baseline 2025, la plataforma por fin añadió Uint8Array.toBase64(), que codifica arrays de bytes directamente con una opción para el alfabeto URL-safe. Este artículo es el mapa de decisiones: qué herramienta para qué trabajo, dónde se esconden los filos cortantes (todos remontan a la misma frontera), y las recetas concretas para los lugares donde de verdad te van a pedir producir Base64.
Elegir tu codificador
Ya no existe un único codificador "el", y agarrar el equivocado es como nacen los bugs clásicos. La tabla de abajo es todo el árbol de decisiones:
| Situación | Agarra |
|---|---|
| Texto ASCII plano, valor puntual | btoa(text) |
| Texto real con acentos, emoji, CJK | new TextEncoder().encode(text), luego btoa o toBase64 |
Bytes ya en un Uint8Array |
bytes.toBase64() en navegadores 2025+, el puente btoa por trozos en el resto |
| URLs, JWTs, nombres de archivo | toBase64({ alphabet: 'base64url', omitPadding: true }) |
| Navegadores viejos o una base de código compartida | js-base64, o la receta clásica de TextEncoder + btoa |
El patrón bajo la tabla: btoa() solo lee caracteres de un byte, así que todo lo que no sea ASCII tiene que convertirse primero en un array de bytes, y ese array de bytes es lo que rodea las APIs modernas. Guarda en la cabeza "el texto se convierte en bytes, los bytes se convierten en Base64" y cada receta de este artículo sigue los mismos dos pasos con nombres distintos.
btoa y la frontera Latin1
btoa(stringToEncode) - de cadena binaria a cadena ASCII - es el codificador original, disponible en todos los navegadores que importan (Chrome 4, Firefox 1, Safari 3, IE 10 y posteriores, todos los ámbitos worker, y Node desde la versión 16). Su contrato tiene una cláusula, y esa cláusula es donde todo se tuerce: cada carácter de la entrada debe tener un punto de código entre 0 y 255. La función lee puntos de código, no bytes UTF-8, así que "é" (punto de código 233) pasa sin problemas mientras que "你" (punto de código 20320) lanza una DOMException llamada InvalidCharacterError antes de codificar un solo carácter. La frontera no es "ASCII", no es "Unicode", es exactamente 256, e incluye los caracteres de control del fondo - codificar un byte NUL es legal y tiene sentido, que es una de las razones por las que la función existe en absoluto.
El comportamiento completo, fila a fila:
| Entrada | Resultado |
|---|---|
"Hello, World!" |
"SGVsbG8sIFdvcmxkIQ==" - el caso de manual |
"" (cadena vacía) |
"" - nada entra, nada sale |
"\u0000" (NUL) |
"AA==" - los caracteres de control son ciudadanos de primera clase |
"a\u00e9z" (é, punto de código 233) |
"Yel6" - todo el rango Latin1 pasa |
"\u0100" (punto de código 256) |
lanza InvalidCharacterError - un paso más allá de la frontera |
"h\u4f60" (你, punto de código 20320) |
lanza InvalidCharacterError - y lo mismo con cada emoji, porque todos están muy por encima de 255 |
Dos notas prácticas. El mensaje de error difiere según el motor - Firefox dice "String contains an invalid character", Chrome dice que la cadena "contains characters outside of the Latin1 range" - así que en cualquier código defensivo atrapas por el nombre de la excepción. Y la excepción se lanza en el primer carácter ofensivo, no al final: btoa no codifica la mitad de la cadena y pide disculpas. Cuando de verdad quieres el comportamiento Latin1 a propósito (codificar una cadena de bytes construida deliberadamente a partir de puntos de código 0-255), la función está haciendo exactamente lo que le pediste, y la tabla de arriba es toda su personalidad.
El puente de bytes
Así que la pregunta se convierte en: ¿cómo llegan los datos reales - los bytes UTF-8 de tu texto, el contenido de un archivo, la salida de un canvas - a la entrada de btoa()? La respuesta es el "puente de bytes": una cadena JavaScript en la que cada carácter guarda un valor de byte, el mismo truco que producen los decodificadores y que btoa entiende nativamente. La versión ingenua es un bucle:
function bytesToBase64 (bytes) {
let binary = '';
for (let i = 0; i < bytes.length; i += 1) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
Correcto, pero la concatenación de cadenas en un bucle es lenta para archivos grandes, y el atajo popular - String.fromCharCode.apply(null, bytes), que alimenta el array entero como argumentos en una sola llamada - tiene un acantilado duro. Las llamadas a función tienen un límite en el número de argumentos, y se alcanza mucho antes de tu primer megabyte:
const big = new Uint8Array(1000000);
btoa(String.fromCharCode.apply(null, big));
// RangeError en Firefox: "too many arguments provided for a function call"
// RangeError en Chrome: "Maximum call stack size exceeded"
La reparación que ha salvado más funciones de subida de archivos que cualquier otro cambio es cruzar el puente por trozos, de unos pocos miles de caracteres en cada pasada, y unir los resultados:
function bytesToBase64Chunked (bytes) {
const CHUNK = 0x8000;
const parts = [];
for (let i = 0; i < bytes.length; i += CHUNK) {
parts.push(String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK)));
}
return btoa(parts.join(''));
}
Cada trozo es lo suficientemente pequeño para aplicarse con seguridad, subarray da una vista sin copiar, y la unión produce exactamente la misma cadena binaria que habría producido el bucle. Ahora el lado de texto de la moneda. Para cualquier texto real, TextEncoder - el codificador UTF-8 de la plataforma, disponible en Firefox 18, Chrome 38, Safari 10.1 y en todas partes desde entonces - convierte tu cadena en bytes honestos antes de que el puente haga su trabajo:
const bytes = new TextEncoder().encode('hello 你好');
const base64 = bytesToBase64Chunked(bytes);
console.log(base64); // "aGVsbG8g5L2g5aW9"
Esa salida es lo que "hello 你好" es de verdad en el cable: seis bytes ASCII más seis bytes UTF-8 para los dos caracteres chinos, todos llevando el mismo disfraz imprimible. Si tu texto no es UTF-8 - y en la web, normalmente lo es - necesitas el otro charset primero, lo que significa codificarlo en algún sitio que hable ese charset, normalmente el servidor. TextEncoder se niega a adivinar a propósito, y hace bien.
El atajo de 2025: Uint8Array.toBase64
Si ya sostienes un Uint8Array, el puente es un desvío, porque la nueva característica de ECMAScript (ES2026) codifica el array directamente: bytes.toBase64(options). Llegó en Chrome 140, Edge 140, Firefox 133, Safari 18.2, Node 25 y Deno 2.5 - la misma ola Baseline 2025 que su hermano de decodificación - y toma dos opciones que lo convierten en el codificador más versátil de la plataforma. La primera es alphabet: "base64" (el predeterminado) o "base64url". La segunda es omitPadding: ponla en true y los caracteres = finales se eliminan, que es la forma que la mayoría de los consumidores amigables con URLs quieren. Pasar cualquier otra cosa como opciones lanza un TypeError, que es la API siendo educada sobre tu error tipográfico:
const bytes = new Uint8Array([251, 255]);
console.log(bytes.toBase64()); // "+/8="
console.log(bytes.toBase64({ omitPadding: true })); // "+/8"
console.log(bytes.toBase64({ alphabet: 'base64url' })); // "-_8="
Esos dos bytes están elegidos para ser máximamente maleducados con el alfabeto: producen un + y un / en modo estándar, así que la última línea muestra exactamente qué cambia cuando pasas a base64url. El rendimiento es el bonus silencioso: en un Firefox reciente, codificar diez megabytes toma unos cinco milisegundos con toBase64, mientras que la ruta del puente de cadenas de arriba toma unas quince veces más, porque construye una cadena intermedia gigantesca en el proceso. En navegadores viejos el puente sigue siendo perfectamente servicial para lo que esté por debajo de unos pocos megabytes - y la versión por trozos de arriba es la que quieres, por las razones de la sección anterior.
Salida URL-safe
Base64 tiene una variante dedicada para los lugares donde +, / y = causan daños, y merece su propia sección porque tanto código roto es solo Base64 estándar que se encontró con una URL. En una cadena de consulta, + es un espacio; en una ruta, / es un separador; y = quiere percent-codificación en algunas posiciones. El alfabeto seguro para URLs y nombres de archivo del RFC 4648, sección 5 - base64url - sustituye esos dos caracteres por - y _, y como la longitud de los datos normalmente se conoce en el lado receptor, también permite eliminar el relleno por completo. La salida viaja por URLSearchParams, segmentos de ruta, fragmentos y nombres de archivo sin una sola percent-codificación.
Con la API de 2025 esto es un objeto de opciones:
const params = new URLSearchParams();
params.set('payload', bytes.toBase64({ alphabet: 'base64url', omitPadding: true }));
console.log(params.toString()); // "payload=-_8" - sin percent-codificación en absoluto
En navegadores viejos, convierte después de codificar con btoa. Dos sustituciones y un recorte hacen todo el trabajo:
function toUrlBase64 (base64) {
return base64
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
console.log(toUrlBase64(btoa('hi?/x'))); // "aGk_L3g"
Tres reglas mantienen el canal limpio. Elige un alfabeto por canal y cúmplalo - un valor que mezcla + y - no pertenece a ninguna familia, y ningún decodificador adivinará cuál de los dos querías. El relleno es un contrato, no una sugerencia: si lo omites, el receptor debe estar preparado para un valor sin relleno, y si lo mantienes, el receptor no debe toparse con él (los navegadores son indulgentes, algunos esquemas JSON no). Y recuerda que el intercambio es reversible y sin pérdida - - y _ mapean a las mismas posiciones 62 y 63 del alfabeto que ocupan + y /, así que no se pierde nada por elegir el par más amable.
Hacer viajar imágenes: data URLs
El uso más antiguo y más visible de Base64 en el navegador es la data 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 - imágenes, fuentes, audio - van en Base64, y el navegador los renderiza con cero peticiones HTTP. Para un archivo de imagen que el usuario acaba de elegir, el FileReader hace la codificación por ti y devuelve la URL terminada:
const reader = new FileReader();
reader.onload = () => {
console.log(reader.result); // "data:image/png;base64,iVBORw0KGgo..."
imageElement.src = reader.result;
};
reader.readAsDataURL(file);
El resultado es un src hecho y derecho, un valor que puedes guardar en localStorage, o enviar en un cuerpo JSON. Si la imagen está en un canvas - una captura de pantalla, una foto procesada, una gráfica generada - canvas.toDataURL() hace este trabajo desde las primeras versiones de los navegadores, y hasta deja elegir el formato y, para los formatos con pérdida, la calidad:
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
ctx.drawImage(photo, 0, 0);
const pngUrl = canvas.toDataURL('image/png');
const jpegUrl = canvas.toDataURL('image/jpeg', 0.8);
Tres trampas para planear en torno. Primera, la regla del canvas contaminado: si dibujaste una imagen cross-origin en el canvas sin permiso CORS, cada intento de leer los píxeles de vuelta - incluido toDataURL - lanza un SecurityError. La reparación es cargar la imagen con crossOrigin = 'anonymous' y asegurarse de que el servidor envíe las cabeceras correctas. Segunda, el argumento de calidad se ignora para PNG y solo tiene sentido para JPEG (y WebP) - una fuente común de "por qué mi PNG es más grande". Tercera, y la más grande: el payload va un 33 por ciento más grande que el archivo, y vive en la página como cadena. Para imágenes que nunca salen del navegador, hay una alternativa gratis - una object URL, que envuelve el Blob sin codificarlo en absoluto:
const objectUrl = URL.createObjectURL(blob);
imageElement.src = objectUrl;
URL.revokeObjectURL(objectUrl); // cuando termines de usarla
La división del trabajo que sale de esto: object URLs para lo que se queda en la página, data URLs para lo que tiene que copiarse, guardarse o enviarse como texto. Ambas son de primera clase; solo resuelven problemas distintos.
Construir y firmar un JWT
Si generas tokens en el navegador - para un flujo de autenticación autoalojado, una demo, o un front end serverless - el formato compacto JWS son tres segmentos base64url: cabecera, payload, firma, sin relleno en ninguna parte. La Web Crypto API se encarga de la firma; la codificación es exactamente la salida URL-safe de dos secciones atrás:
const encoder = new TextEncoder();
const segment = (bytes) =>
bytes.toBase64({ alphabet: 'base64url', omitPadding: true });
const header = segment(encoder.encode(JSON.stringify({ alg: 'HS256', typ: 'JWT' })));
const payload = segment(encoder.encode(JSON.stringify({ sub: '1234567890', name: 'John Doe' })));
const key = await crypto.subtle.importKey(
'raw',
encoder.encode('shared-secret'),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signature = segment(
new Uint8Array(
await crypto.subtle.sign('HMAC', key, encoder.encode(header + '.' + payload))
)
);
const token = header + '.' + payload + '.' + signature;
Dos detalles importan más que la tubería. La firma cubre exactamente header + '.' + payload - los segmentos crudos, no el JSON - así que cualquier edición a cualquiera de las dos partes invalida el token, que es todo el punto. Y crypto.subtle.sign devuelve un ArrayBuffer crudo, de ahí el envoltorio de una línea en un Uint8Array antes del codificador de segmento. Para tokens basados en RSA el flujo es idéntico con RS256 y un par de claves, y si exportas una clave pública como JWK (crypto.subtle.exportKey('jwk', key)), los miembros numéricos - n, e, y para claves privadas d, p, q - salen como base64url sin relleno automáticamente. Las advertencias de seguridad son las mismas que para cualquier token: una cabecera alg: "none" es una petición para saltarse la verificación, las claims de tiempo (exp, nbf) deben forzarse, y un servidor que acepta tanto HMAC como RSA para la misma audiencia abre la clásica puerta de confusión de claves. Codifica correctamente, firma correctamente, verifica en el lado receptor.
Cabeceras de autenticación
El esquema de autenticación más simple de la web es también el más instructivo sobre lo que Base64 es y no es. HTTP Basic envía Authorization: Basic seguido del Base64 de username:password - una llamada, sin puente de bytes, porque los nombres de usuario y las contraseñas son (esperemos) texto plano:
const credentials = btoa('alice:secret123');
fetch('/api/me', {
headers: { Authorization: 'Basic ' + credentials }
});
// Authorization: Basic YWxpY2U6c2VjcmV0MTIz
Y aquí va la lección que cabe en una línea: Base64 no es cifrado. La cabecera de arriba está a una llamada de atob de alice:secret123 - para el atacante y para cualquiera que lea los logs - así que la autenticación Basic solo es aceptable sobre HTTPS, donde el transporte es la protección real y Base64 es solo el formato. Para lo que viva más que una petición, prefiere esquemas basados en tokens: un token Bearer es también una sola cabecera, pero es un valor aleatorio cuyo secreto nunca necesita viajar en la cabecera, y puede revocarse. La elección de codificación entre ambos es trivial - ambos son btoa o texto plano - pero la elección de seguridad no lo es, y debería hacerse a propósito.
Archivos entrando, texto saliendo
Las subidas son donde el impuesto del 33 por ciento se cita en dinero real, porque el archivo suele ser lo más grande de la página. Hay dos caminos, y el primero es el que deberías tomar por defecto: datos de formulario multipart. FormData lleva el archivo como bytes crudos en un cuerpo estándar, con el navegador haciendo el enmarcado, y no hay Base64 en ninguna parte - sin impuesto de tamaño, sin cadena intermedia, y los bytes fluyen al servidor tal y como se leen:
const form = new FormData();
form.append('upload', file);
await fetch('/api/upload', { method: 'POST', body: form });
El segundo camino es para las APIs que insisten en un cuerpo JSON con el archivo como cadena - algunas funciones serverless, algunos backends móviles, algunos servicios heredados. Allí la codificación es una línea por archivo, y el coste es exactamente lo que el impuesto dice que es: un archivo de 5 megabytes se convierte en una cadena de 6,7 megabytes, que luego se serializa a JSON, que luego se envía. Bien para una foto, doloroso para un vídeo:
const bytes = new Uint8Array(await file.arrayBuffer());
const body = JSON.stringify({
name: file.name,
content: bytes.toBase64()
});
await fetch('/api/upload-json', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body
});
Para archivos grandes por ese camino, no construyas una cadena gigante en una sola llamada - constrúyela en rebanadas, donde cada rebanada es un múltiplo de tres bytes. Esa alineación es lo que hace el truco legal: un múltiplo de tres bytes codifica a un múltiplo limpio de cuatro caracteres sin relleno, así que las rebanadas codificadas independientemente se concatenan a exactamente la codificación del archivo entero, y solo la rebanada final lleva relleno alguna vez:
async function encodeLargeFile (file) {
const bytes = new Uint8Array(await file.arrayBuffer());
const SLICE = 3 * 1000 * 1000;
const parts = [];
for (let i = 0; i < bytes.length; i += SLICE) {
parts.push(bytes.subarray(i, i + SLICE).toBase64());
}
return parts.join('');
}
La misma idea de alineación es por qué nunca deberías partir una cadena Base64 en un punto arbitrario y esperar que las piezas se decodifiquen por sí solas - un grupo de tres bytes es el átomo, y un corte a mitad de uno deja un fragmento colgando. Las descargas son el espejo: para un archivo generado, uno pequeño puede salir por una data URL en un enlace de descarga, pero para lo que sea sustancial, un Blob más una object URL es la ruta sana, porque el navegador nunca tiene que cargar el payload entero como cadena en primer lugar.
Guardar y compartir estado
Dos canales más solo de texto donde Base64 hace trabajo real. El primero es el almacenamiento: localStorage y sessionStorage guardan cadenas, así que los datos estructurados o binarios se codifican antes de entrar. El viaje de ida y vuelta es una codificación y una decodificación, y vale la pena ver ambos lados juntos, porque un bug de almacenamiento casi siempre es un desajuste de charset entre ellos:
const state = { theme: 'dark', draft: 'hello' };
const packed = new TextEncoder().encode(JSON.stringify(state));
localStorage.setItem('app-state', new Uint8Array(packed).toBase64());
const raw = atob(localStorage.getItem('app-state'));
const bytes = Uint8Array.from(raw, (c) => c.codePointAt(0));
const state = JSON.parse(new TextDecoder().decode(bytes));
Presupuéstalo bien, aunque: el origen tiene unos 5 megabytes de localStorage, tu cadena almacenada es 33 por ciento más gorda que los datos, y mientras la página está abierta la cadena también vive en memoria como UTF-16 - otra vez el doble de su longitud. Un activo de 3 megabytes es 4 megabytes de almacenamiento y 8 megabytes de memoria, que es como una función "pequeña" se convierte en un error de cuota. El segundo canal es la URL en sí: enlaces compartidos, deep links y estado OAuth, todos quieren datos estructurados en un lugar que sobreviva al copiar y pegar. La receta es estado compacto, JSON, y luego base64url sin relleno, para que el valor no necesite percent-codificación en absoluto - y mantén toda la URL por debajo de un par de miles de caracteres, que es donde los clientes viejos, los proxies y las herramientas de registro empiezan a ponerse nerviosos.
Correo y MIME
Base64 es más viejo que la web, y su terreno natal es el correo. Los adjuntos MIME con Content-Transfer-Encoding: base64 son como viaja un archivo binario dentro de un protocolo de texto, y la convención que salió del viejo límite de 76 caracteres por línea del formato de mensaje merece saberse: envuelve el cuerpo codificado a 76 caracteres por línea. El navegador no puede enviar SMTP, pero hace dos trabajos de correo - construir cuerpos MIME que un relay backend enviará, y mostrar los adjuntos de los mensajes que recibe - y ambos tocan la codificación. El envolvimiento en sí es una función de dos líneas, y el orden de operaciones importa: codifica primero, envuelve después, porque btoa no lanza por un salto de línea en su entrada - codificará el salto como un byte de payload, y tus saltos de línea acabarán dentro de la salida:
function wrapForMime (base64, width) {
const w = width || 76;
return base64.match(new RegExp('.{1,' + w + '}', 'g')).join('\r\n');
}
El lado receptor es el que sale gratis: atob omite los espacios ASCII como parte de su comportamiento estándar, así que un cuerpo MIME envuelto se decodifica exactamente como llegó, saltos de línea y todo, sin un paso de desenvolver. Si estás construyendo un cliente webmail o un selector de adjuntos, esa asimetría única - el codificador debe producir líneas limpias, al decodificador le da igual - es toda la historia MIME en una frase.
Cuándo recurrir a una librería
Con las herramientas nativas de arriba, una librería rara vez es necesaria, y la guía honesta es: por defecto ve a la plataforma, y añade un paquete solo cuando un requisito real apunte a uno. Las tres que de verdad aparecen en bases de código:
js-base64 (npm install js-base64) es la de uso general: un pequeño transcodificador JavaScript puro que trata las cadenas UTF-8 como ciudadanos de primera clase - Base64.encode sobre una cadena CJK hace la danza UTF-8 por ti - y, útil tanto para decodificar como para codificar, acepta los dos alfabetos en decode e incluye una comprobación isValid. Es la respuesta correcta cuando apuntas a navegadores donde faltan las APIs de 2025 y quieres una sola importación que cubra cadenas y bytes:
import { Base64 } from 'js-base64';
const encoded = Base64.encode('小飼弾'); // "5bCP6aO85by+" - UTF-8 manejado por ti
const decoded = Base64.decode('5bCP6aO85by-'); // lee estándar y URL-safe por igual
const valid = Base64.isValid(encoded); // true
base64-js es la enfocada en bytes: fromByteArray y toByteArray sobre Uint8Array, sin dependencias, la mula de carga del viejo ecosistema browserify y todavía una elección fina cuando tu código vive en typed arrays y quieres que la codificación sea una función pura de los bytes. Y si tu razón para querer una librería es "me gusta la API de 2025 pero no puedo exigir navegadores de 2025", la respuesta no es un paquete de Base64 en absoluto sino un polyfill: core-js (y el preset de Babel que lo arrastra) implementa Uint8Array.fromBase64 y compañía, así que puedes escribir el código de nuevo estilo una vez y dejar que el shim cubra el hueco en motores viejos. Elige por restricción - navegadores viejos, comodidad de cadenas, o pureza de bytes - no por costumbre.
Trampas que cuestan horas a los desarrolladores
- Llamar a
btoasobre una cadena con un carácter por encima del punto de código 255. Lanza, no malforma, y se detiene en el primer ofensor. La reparación es siempre la misma:TextEncoderprimero, puente después. - El acantilado de
fromCharCode.applyen arrays grandes. Un millón de argumentos es unRangeErroren los dos motores mayores. Cruza el puente por trozos, o pásate atoBase64. - Olvidar el impuesto de tamaño donde más duele: el almacenamiento. Un archivo en
localStoragees 33 por ciento más grande que el archivo, y la cuota es por origen, compartida con todo lo demás que tu app guarda. - Base64 estándar encontrándose con una cadena de consulta. El
+llega como un espacio, el/rompe la ruta, y los reportes de bugs dicen "la API es inestable". Salida URL-safe, sin relleno, y todo el género de bug desaparece. - Relleno inconsistente entre servicios. Un gateway mantiene el
=, otro lo quita, un tercero lo añade de vuelta. El receptor debe estar preparado para ambas formas, y el contrato debería decir cuál es la canónica. - Tratar Base64 como un candado. Es un formato de serialización, a una llamada de función del texto plano, y "codificado con Base64" en una revisión de seguridad es un hallazgo, no un control.
- Cadenas binarias como modelo de memoria. Un megabyte decodificado o codificado viaja en UTF-16 a dos megabytes; un
Uint8Arraylo guarda a uno. Para payloads grandes, mantén los bytes en typed arrays de punta a punta. - Codificación doble. Un valor que ya era Base64 se codifica otra vez, y el consumidor decodifica una vez y obtiene una cadena de letras en vez de datos. Cuando tengas dudas, comprueba antes de envolver - una cadena que ya está en el alfabeto con relleno válido es un mal augurio.
- Confiar en un payload de JWT porque se decodificó limpiamente. La decodificabilidad no es autenticidad. Verifica la firma con la clave correcta y el algoritmo correcto antes de leer una sola claim.
Rendimiento: cuánto cuestan un millón de bytes
Base64 en el navegador es barato donde antes era caro, y el presupuesto ahora tiene tres partidas en vez de una. CPU: en un Firefox reciente, Uint8Array.toBase64 codifica diez megabytes en unos cinco milisegundos, mientras que el puente btoa por trozos toma unas quince veces más - no porque btoa sea lento, sino porque el puente construye una cadena intermedia gigantesca de camino. Si tu presupuesto de codificación está en milisegundos, usa el método nativo; si codificas un objeto de configuración de 2 kilobytes, ambos están por debajo del umbral de percepción. Ancho de banda: este es el impuesto permanente - cada byte que codificas cuesta 1,33 bytes en el cable, más lo que el transporte añada de enmarcado. Mide la transferencia antes de "optimizar" la codificación. Memoria: la cadena codificada es la mayor asignación transitoria que harás, y para un archivo de 5 megabytes es una cadena de 6,7 megabytes, o unos 13,4 megabytes de memoria UTF-16 mientras la página la sostiene. Las consecuencias prácticas salen de la aritmética: parte las codificaciones grandes para que ninguna cadena se haga enorme, libera los bytes intermedios en cuanto la cadena existe, prefiere object URLs y multipart cuando los bytes nunca necesitaron ser imprimibles, y mueve el trabajo de varios megabytes a un Web Worker si el hilo principal debe seguir haciendo scroll con suavidad. El formato tiene casi cuatro décadas; la plataforma por fin le dio alcance.
Cómo aprendieron los navegadores a codificar
El codificador tiene una historia, y explica las reliquias que heredarás. btoa - "de binario a ASCII", el nombre es literal, y atob son justo las mismas palabras al revés - se escribió en la especificación HTML en 2011, inferido por ingeniería inversa de los navegadores que ya lo traían: Firefox desde 2004, Safari 3, Chrome 4. Internet Explorer, característicamente, se saltó las dos funciones hasta la versión 10 en 2012, y esa ausencia única es la razón por la que una década de JavaScript está llena de tablas Base64 hechas a mano y una invocación particular para Unicode: btoa(unescape(encodeURIComponent(str))). Funcionaba - encodeURIComponent produce UTF-8 percent-escapado, y unescape lo convertía en una cadena de bytes - pero se construyó sobre unescape(), la del par que el lenguaje había deprecado, y sobrevivió en código de navegadores durante años por pura inercia. La solución rigurosa llegó con el estándar Encoding: TextEncoder y TextDecoder, en Firefox 18 (2013), Chrome 38 (2014), Safari 10.1 (2017), y en ninguna versión de IE - otra grieta de IE, otra década de workarounds. Node.js cuenta la mitad del lado servidor: tuvo Buffer con Base64 desde el día uno, pero atob y btoa como globales solo desde la versión 16 en 2021, con dos shims pequeños de npm cargando el peso antes de eso. Y entonces, a lo largo de finales de 2024 y 2025, el propio lenguaje trajo Base64 - Uint8Array.toBase64 y compañía en Firefox 133 (noviembre de 2024), Safari 18.2 (diciembre de 2024), Chrome 140 (septiembre de 2025) y Node 25 (octubre de 2025) - y la característica se marcó Baseline 2025 - el mismo conjunto de características que la plataforma había estado aproximando con helpers durante veinte años, ahora estándar. Las curiosidades divertidas al final del artículo van sobre todo de cuánto tardó cada pieza en llegar.
¿Lo sabías?
- Los nombres de las funciones son una frase:
btoaes "de binario a ASCII" yatobes "de ASCII a binario". La dirección va en el nombre, por eso el par se autodocumenta desde los 2000. - La cadena más codificada en la historia de la computación es probablemente "hello":
btoa('hello')esaGVsbG8=, la salida de cada tutorial, suite de pruebas y pizarra de entrevista del planeta. - Cada cadena Base64 válida tiene una longitud que es múltiplo de cuatro, relleno incluido. Los caracteres
=son una huella dactilar: uno de ellos significa que el último grupo llevó dos bytes, dos de ellos significa que llevó uno. - El envolvimiento a 76 caracteres en MIME y en la mayoría de las herramientas de línea de comandos es una herencia de la era del correo, cuando la longitud de línea del formato de mensaje ponía el límite. El número ha sobrevivido tres décadas de todo más rápido.
- "Data URI" es un nombre retirado. WHATWG lo renombró a "data URL" durante la gran armonización de URI a URL, por eso especificaciones, posts de blogs y nombres de paquetes lo escriben de distinta forma en el mismo párrafo.
btoa('')devuelve'': una entrada vacía produce una salida vacía, sin relleno, sin caso especial - la única cadena Base64 con cero caracteres (su longitud, 0, sigue siendo múltiplo de cuatro).- Un canvas puede convertir una foto en una data URL con
toDataURL- una capacidad que existe desde IE 9, Firefox 2 y Safari 4, anterior a la mayor parte de la plataforma web que consideramos "moderna" - y devolverla al estado original con una etiqueta<img>y unFileReader. - El handshake de WebSocket codifica
SHA-1(key + 258EAFA5-E914-47DA-95CA-C5AB0DC85B11)en Base64, y el GUID es una constante fija del RFC que se eligió precisamente para que ningún servidor HTTP plano pudiera completar el handshake por accidente.
Hacia dónde ir desde aquí
Todo el oficio de codificar en el navegador cabe en una página: btoa para los casos planos de un byte para los que nació; TextEncoder más el puente por trozos para texto y archivos reales en cualquier navegador; Uint8Array.toBase64 con sus opciones de alfabeto y relleno para la ruta moderna y directa; y la variante URL-safe, con o sin relleno, para lo que vivirá en una URL. El resto es juicio: conoce el impuesto del 33 por ciento antes de pagarlo, mantén los bytes en typed arrays mientras sean grandes, codifica primero y envuelve después, y nunca llames candado a un formato de serialización. Cuando el canal puede llevar bytes crudos, toma los bytes - Base64 es para los caminos que solo admiten texto imprimible, y ahora sabes exactamente cómo pagar el peaje.
La otra mitad del viaje - recibir una de estas cadenas y sacar de ella los bytes, el texto y el significado - se cubre en detalle en la guía compañera sobre decodificación Base64 en JavaScript, enlazada abajo.
Última actualización: 2026-09-08
Artículo relacionado: Decodificación Base64 en JavaScript/Browser: una guía completa