[JS] Base64 en JavaScript — atob, btoa y Buffer de Node
Una guía práctica de Base64 en JavaScript. Cuándo usar btoa/atob, por qué fallan con Unicode, cómo funciona Buffer en Node.js, y un helper base64url multi-runtime.
// EL PANORAMA
La historia de JavaScript con Base64 está fragmentada por la historia. El navegador heredó btoa() / atob() de Netscape en los años 90 — funciones cuyos nombres significan literalmente binary-to-ASCII y ASCII-to-binary. Solo manejan caracteres Latin-1, por lo que copiar y pegar emoji o texto chino a través de btoa() lanza un error.
Node.js agregó su propio camino a través de la clase Buffer. Entiende cada codificación que probablemente necesites (utf8, base64, base64url, hex, latin1, ascii), maneja datos binarios de forma nativa y es la opción obvia para código del lado del servidor.
Los runtimes modernos (Node 18+, Deno, Bun, navegadores modernos) también traen TextEncoder / TextDecoder — la forma basada en estándares de convertir cadenas Unicode en bytes antes de codificarlas en Base64. Saber qué herramienta elegir es la mitad de la batalla.
// NAVEGADOR: btoa / atob — EL USO CORRECTO
btoa(str) toma una cadena donde cada carácter debe caber en 1 byte (rango Latin-1, U+0000 a U+00FF) y devuelve una cadena Base64. atob(b64) lo revierte. Son síncronas, rápidas y están integradas en todos los navegadores desde siempre.
El uso correcto es: codificar en Base64 datos que ya son básicamente bytes. Bytes crudos leídos de un FileReader, de un XMLHttpRequest binario (responseType: 'arraybuffer'), o de un Uint8Array, donde el valor numérico de cada byte cabe en 0–255.
// Encode a Latin-1 string
const latin1 = 'Hello, world!';
const encoded = btoa(latin1); // 'SGVsbG8sIHdvcmxkIQ=='
// Decode it back
const decoded = atob(encoded); // 'Hello, world!'
// Encode a binary Uint8Array (the common real-world case)
const bytes = new Uint8Array([0xFF, 0xD8, 0xFF, 0xE0]); // JPEG magic
const binStr = String.fromCharCode(...bytes); // 'ÿØÿà' (Latin-1)
const b64 = btoa(binStr); // '/9j/4A=='
// POR QUÉ btoa() FALLA CON UNICODE
Prueba btoa('héllo') en la consola del navegador. Obtienes InvalidCharacterError: The string to be encoded contains characters outside of the Latin1 range. La é (U+00E9) está bien, pero btoa('日本語') falla porque 日 (U+65E5) está fuera de Latin-1. La función no tiene concepto de UTF-8; lee cada unidad de código de String de JavaScript como un solo byte.
La solución es convertir primero la cadena Unicode a bytes UTF-8, y luego codificar esos bytes en Base64. Cada navegador moderno trae TextEncoder exactamente para esto.
// ❌ broken
// btoa('日本語') → throws InvalidCharacterError
// ✅ correct: encode to UTF-8 first
function b64encodeUtf8(str) {
const bytes = new TextEncoder().encode(str);
let bin = '';
for (const byte of bytes) bin += String.fromCharCode(byte);
return btoa(bin);
}
b64encodeUtf8('日本語'); // '5pel5pys6Kqe'
// ✅ round-trip decode
function b64decodeUtf8(b64) {
const bin = atob(b64);
const bytes = Uint8Array.from(bin, c => c.charCodeAt(0));
return new TextDecoder().decode(bytes);
}
b64decodeUtf8('5pel5pys6Kqe'); // '日本語'
// NODE.JS: EL ENFOQUE Buffer
En Node, utiliza Buffer. Es consciente de UTF-8 por defecto, soporta todas las codificaciones imaginables y maneja binarios limpiamente. Node 16+ también entiende 'base64url' como nombre de codificación, así que no necesitas un helper separado para salida URL-safe.
// String → Base64
const encoded = Buffer.from('héllo 世界').toString('base64');
// 'aMOpbGxvIOS4lueVjA=='
// Base64 → string (UTF-8 by default)
const decoded = Buffer.from(encoded, 'base64').toString();
// 'héllo 世界'
// URL-safe (Node 16+): +/- omitted padding
const safe = Buffer.from('héllo 世界').toString('base64url');
// 'aMOpbGxvIOS4lueVjA' ← no = padding, uses -_
// Binary file → Base64
const fs = require('node:fs');
const imgB64 = fs.readFileSync('logo.png').toString('base64');
// Base64 → binary file
fs.writeFileSync('out.png', Buffer.from(imgB64, 'base64'));
// BUFFER VS btoa/atob — COMPARACIÓN RÁPIDA
- > Unicode: Buffer maneja UTF-8 de forma nativa; btoa/atob solo manejan bytes Latin-1.
- > Binario: Buffer es el tipo binario nativo en Node; los navegadores deben usar Uint8Array + String.fromCharCode.
-
>
URL-safe: Buffer soporta
'base64url'como una codificación de primera clase (Node 16+); los navegadores necesitan un reemplazo personalizado con regex. - > Rendimiento: Buffer está implementado en C++ y es ~3–5× más rápido que atob/btoa en entradas grandes.
- > Streaming: Buffer se puede canalizar a través de transform streams para archivos grandes; btoa/atob no pueden hacer streaming.
- > Disponibilidad: btoa/atob existen en Node 16+ como globales, pero elige Buffer cuando tengas la opción — maneja los casos límite.
// CODIFICAR Y DECODIFICAR ARCHIVOS BINARIOS EN EL NAVEGADOR
El patrón canónico del navegador para convertir una selección de un selector de archivos en una cadena Base64 es FileReader.readAsDataURL(), que devuelve un data URI listo para usar. Si solo quieres la carga Base64 cruda, elimina el prefijo data:...;base64,.
Para archivos muy grandes, lee con readAsArrayBuffer() y codifica en fragmentos para evitar el error 'maximum call stack size exceeded' que String.fromCharCode(...bytes) alcanza en arreglos con más de ~65,000 elementos.
// ▸ Simple: file → data URI → strip prefix
async function fileToBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const dataUri = reader.result; // 'data:image/png;base64,iVBOR...'
resolve(dataUri.split(',')[1]); // raw Base64 only
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
// ▸ Large files: chunked btoa() to avoid stack overflow
async function arrayBufferToBase64(buffer) {
const bytes = new Uint8Array(buffer);
const chunkSize = 0x8000; // 32 KB chunks
let binary = '';
for (let i = 0; i < bytes.length; i += chunkSize) {
binary += String.fromCharCode.apply(null,
bytes.subarray(i, i + chunkSize));
}
return btoa(binary);
}
// Usage
const file = inputEl.files[0];
const buf = await file.arrayBuffer();
const b64 = await arrayBufferToBase64(buf);
// DECODIFICAR BASE64 A BYTES EN EL NAVEGADOR
// ▸ Base64 → Uint8Array (binary bytes)
function base64ToBytes(b64) {
const bin = atob(b64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return bytes;
}
// ▸ Base64 → Blob → downloadable
function base64ToBlob(b64, mime = 'application/octet-stream') {
const bytes = base64ToBytes(b64);
return new Blob([bytes], { type: mime });
}
// Trigger a download
const blob = base64ToBlob(b64, 'image/png');
const url = URL.createObjectURL(blob);
Object.assign(document.createElement('a'), { href: url, download: 'out.png' }).click();
URL.revokeObjectURL(url);
// BASE64 URL-SAFE (base64url) EN JAVASCRIPT
Base64 estándar usa + y /, ambos son caracteres reservados en URLs. También termina con uno o dos caracteres = que se codifican como porcentaje a %3D, desperdiciando espacio. RFC 4648 §5 define la variante URL-safe (base64url): cambia + por -, / por _, y descarta el relleno.
En Node 16+, simplemente usa Buffer.from(x).toString('base64url'). En el navegador, impleméntalo como un paso de post-procesamiento encima de btoa().
// ▸ Browser: standard Base64 → URL-safe Base64
function toBase64Url(b64) {
return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
// ▸ URL-safe Base64 → standard Base64 (before feeding atob)
function fromBase64Url(b64u) {
let b64 = b64u.replace(/-/g, '+').replace(/_/g, '/');
while (b64.length % 4) b64 += '='; // re-add padding
return b64;
}
// Round-trip
const token = toBase64Url(btoa('hello+world/'));
// 'aGVsbG8rd29ybGQv'
atob(fromBase64Url(token)); // 'hello+world/'
// TRAMPAS COMUNES Y CÓMO DETECTARLAS
-
>
InvalidCharacterError de btoa() — tu entrada contiene caracteres fuera de Latin-1. Solución: codifica a UTF-8 con
TextEncoderprimero. -
>
InvalidCharacterError de atob() — tu entrada no es Base64 válido. Generalmente falta relleno (para base64url), o tiene espacios en blanco. Elimina espacios en blanco, vuelve a agregar relleno
=. -
>
Unicode ilegible al decodificar — decodificaste con
atob()directamente en lugar de pasar porTextDecoder. Las secuencias multi-byte de UTF-8 aparecen como mojibake. -
>
'Maximum call stack size exceeded' — usaste
String.fromCharCode(...bytes)en un arreglo de más de ~65,000 bytes. Solución: fragméntalo (ver el bucle de 32 KB arriba). - > Salida diferente entre navegador y Node — generalmente significa que un lado es URL-safe y el otro es estándar. Normaliza a una variante en el límite.
-
>
Carga doblemente codificada — los datos se codificaron en Base64 dos veces. Verifica: ¿
atob(input)parece Base64 otra vez? Entonces decodifica una vez más. - > Base64 en JSON codificado accidentalmente como JSON — envolviste una cadena Base64 en JSON.stringify, luego el receptor analiza JSON y obtiene la cadena sin cambios, pero tu código del servidor la re-codificó por error. Inspecciona la carga por cable.
// HELPER MULTI-RUNTIME (navegador + Node + Deno + Bun)
// A portable Base64/base64url helper. Uses Buffer when available,
// falls back to btoa/atob + TextEncoder in the browser / Deno.
const hasBuffer = typeof Buffer !== 'undefined';
export function encode(str, { urlSafe = false } = {}) {
if (hasBuffer) {
return Buffer.from(str, 'utf8').toString(urlSafe ? 'base64url' : 'base64');
}
const bytes = new TextEncoder().encode(str);
let bin = '';
for (const b of bytes) bin += String.fromCharCode(b);
const b64 = btoa(bin);
return urlSafe
? b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
: b64;
}
export function decode(b64, { urlSafe = false } = {}) {
if (hasBuffer) {
return Buffer.from(b64, urlSafe ? 'base64url' : 'base64').toString('utf8');
}
if (urlSafe) {
b64 = b64.replace(/-/g, '+').replace(/_/g, '/');
while (b64.length % 4) b64 += '=';
}
const bin = atob(b64);
const bytes = Uint8Array.from(bin, c => c.charCodeAt(0));
return new TextDecoder().decode(bytes);
}
// Usage
encode('héllo 世界'); // 'aMOpbGxvIOS4lueVjA=='
encode('héllo 世界', { urlSafe: true }); // 'aMOpbGxvIOS4lueVjA'
decode('aMOpbGxvIOS4lueVjA=='); // 'héllo 世界'
// NOTAS DE RENDIMIENTO
- > Buffer es el más rápido — en Node, se ejecuta en C++ y maneja entradas de megabytes sin contratiempos.
- > btoa/atob son competitivos para cargas pequeñas (< 100 KB) en el navegador; ambos son síncronos y bloquearán el hilo principal en entradas grandes.
-
>
Para entradas > 10 MB en el navegador, usa un Worker para que la codificación se ejecute fuera del hilo principal — o usa
Blob.text()+FileReader.readAsDataURL(), que está optimizado internamente. - > Evita ciclos repetidos de codificar/decodificar — cada viaje de ida y vuelta es O(n) en tiempo y memoria. Almacena en caché la forma Base64 si la usas varias veces.
-
>
Streaming — para cargas realmente grandes (100 MB+), usa
stream.Transformde Node o la APITransformStreamdel navegador con un codificador Base64 por fragmentos.
// LECTURAS RELACIONADAS
• ¿Qué es Base64 y cómo funciona? — el truco del agrupamiento de 6 bits explicado desde cero.
• Base64 para UTF-8 y Unicode — el baile de TextEncoder en profundidad.
• URL-safe vs Base64 estándar — cuándo necesitas -_ en lugar de +/.
• Prueba el codificador Base64 en vivo — pega texto, ve la codificación, copia el resultado.
Guarda esta página en marcadores si alguna vez recurres a btoa() — nueve de cada diez veces, la pregunta que estás a punto de buscar en Google está aquí.