[UNICODE] Base64 para UTF-8 y Unicode
btoa('héllo') falla. btoa('日本語') falla. Esto no es un bug — es una API de los 90 chocando con el texto de los 2020. Aquí está la forma correcta de codificar Unicode en Base64, byte por byte.
// LA TRAMPA
btoa() en el navegador parece inocente. Le pasas una cadena, obtienes Base64. Entonces un día un usuario envía su nombre — Renée — y tu código se bloquea con InvalidCharacterError: The string to be encoded contains characters outside of the Latin1 range.
Esto no es una rareza, y no es culpa de JavaScript de forma aislada. btoa() se especificó en una era en la que "cadena" y "bytes" se trataban como lo mismo, siempre que te mantuvieras debajo de U+00FF. Unicode se comió el mundo, pero el contrato de btoa() se quedó congelado en Latin-1. Para codificar en Base64 cualquier cosa más allá de los caracteres de Europa occidental, necesitas tomar un desvío a través de UTF-8 primero.
// LA SOLUCIÓN EN 30 SEGUNDOS
// Browser — encode any Unicode string to Base64
function encodeUtf8Base64(str) {
const bytes = new TextEncoder().encode(str); // UTF-8 bytes
let bin = '';
for (const b of bytes) bin += String.fromCharCode(b); // Latin-1 wrapper
return btoa(bin); // now btoa can handle it
}
// Browser — decode Base64 back to a Unicode string
function decodeUtf8Base64(b64) {
const bin = atob(b64);
const bytes = Uint8Array.from(bin, c => c.charCodeAt(0));
return new TextDecoder().decode(bytes); // UTF-8 → string
}
// Usage
encodeUtf8Base64('Renée'); // 'UmVuw6ll'
encodeUtf8Base64('日本語'); // '5pel5pys6Kqe'
encodeUtf8Base64('👋 hello'); // '8J+RiyBoZWxsbw=='
decodeUtf8Base64('5pel5pys6Kqe'); // '日本語'
// POR QUÉ FUNCIONA — UN BREVE DESVÍO POR UTF-8
Las cadenas de JavaScript son secuencias de unidades de código UTF-16. El carácter 日 es U+65E5, que es una sola unidad de código de 16 bits. El emoji 👋 (U+1F44B) está fuera del Plano Multilingüe Básico y se representa mediante un par sustituto de dos unidades de código de 16 bits.
btoa() necesita bytes, no unidades de código. Y no cualquier byte — quiere bytes Latin-1 en el rango 0–255. Las unidades de código UTF-16 rutinariamente exceden 255 (por ejemplo, 日 = 0x65E5 = 26085), por lo que btoa() falla.
UTF-8 es una codificación limpia de 8 bits del conjunto completo de caracteres Unicode. Cada carácter Unicode se convierte en 1–4 bytes, y cada byte está garantizado que esté en el rango 0–255. Si convertimos nuestra cadena a bytes UTF-8 primero, y luego engañamos a btoa() para que vea esos bytes como una cadena Latin-1 (un carácter por byte), todo se alinea: btoa() ve bytes que puede manejar, y el receptor decodifica los mismos bytes de vuelta a UTF-8.
// LO QUE HACE TextEncoder POR DENTRO
TextEncoder es la forma basada en estándares de convertir una cadena de JavaScript en bytes UTF-8. Ha estado en todos los navegadores desde ~2017 y es un global en Node.js 11+.
Un vistazo rápido a las secuencias de bytes que produce:
new TextEncoder().encode('A');
// Uint8Array [ 0x41 ] (1 byte)
new TextEncoder().encode('é');
// Uint8Array [ 0xC3, 0xA9 ] (2 bytes)
new TextEncoder().encode('日');
// Uint8Array [ 0xE6, 0x97, 0xA5 ] (3 bytes)
new TextEncoder().encode('👋');
// Uint8Array [ 0xF0, 0x9F, 0x91, 0x8B ] (4 bytes)
new TextEncoder().encode('Renée');
// Uint8Array [ 0x52, 0x65, 0x6E, 0xC3, 0xA9, 0x65 ] (6 bytes for 5 chars)
// NODE.JS: Buffer ES CONSCIENTE DE UTF-8 POR DEFECTO
La clase Buffer de Node hace la conversión UTF-8 internamente. Nunca necesitas TextEncoder; pasar una cadena a Buffer.from() la codifica como UTF-8 a menos que elijas explícitamente otra codificación. Por eso Base64 del lado del servidor en Node es una sola línea:
// Encode
Buffer.from('日本語').toString('base64');
// '5pel5pys6Kqe'
// Decode
Buffer.from('5pel5pys6Kqe', 'base64').toString();
// '日本語'
// Explicit UTF-8 (the default, but clearer)
Buffer.from('Renée', 'utf8').toString('base64');
// 'UmVuw6ll'
// BASE64 UNICODE EN CADA LENGUAJE
// Python
import base64
base64.b64encode('日本語'.encode('utf-8'))
# b'5pel5pys6Kqe'
base64.b64decode('5pel5pys6Kqe').decode('utf-8')
# '日本語'
// PHP
base64_encode('日本語'); // '5pel5pys6Kqe'
base64_decode('5pel5pys6Kqe'); // '日本語'
// (PHP strings are byte strings, so UTF-8 is handled implicitly
// if your source file is saved as UTF-8.)
// Java
import java.util.Base64;
import java.nio.charset.StandardCharsets;
Base64.getEncoder().encodeToString("日本語".getBytes(StandardCharsets.UTF_8));
// '5pel5pys6Kqe'
new String(Base64.getDecoder().decode("5pel5pys6Kqe"), StandardCharsets.UTF_8);
// '日本語'
// Go
import "encoding/base64"
base64.StdEncoding.EncodeToString([]byte("日本語"))
// '5pel5pys6Kqe'
b, _ := base64.StdEncoding.DecodeString("5pel5pys6Kqe")
string(b)
// '日本語'
// Ruby (source file must be UTF-8)
require 'base64'
Base64.strict_encode64('日本語') # '5pel5pys6Kqe'
Base64.decode64('5pel5pys6Kqe') # '日本語'
// Rust
use base64::{Engine, engine::general_purpose::STANDARD};
STANDARD.encode("日本語"); // "5pel5pys6Kqe"
STANDARD.decode("5pel5pys6Kqe")
.map(|b| String::from_utf8(b).unwrap())?; // "日本語"
// Shell (bash/zsh with GNU coreutils)
echo -n '日本語' | base64 # '5pel5pys6Kqe'
echo -n '5pel5pys6Kqe' | base64 -d # '日本語'
// FALLAS UNICODE COMUNES
-
>
Mojibake al decodificar — decodificaste con
atob()directamente y el receptor ve basura. Solución: envuélvelo conTextDecoder. -
>
Errores de pares sustitutos — tu código divide cadenas por conteo de caracteres y golpea la mitad de un par sustituto 👋. Solución: usa
TextEncoderen toda la cadena, no carácter por carácter. -
>
Problemas con BOM — tu texto fuente comienza con un BOM UTF-8 (
EF BB BF). Se codifica junto con todo lo demás, y el receptor ve un\uFEFFperdido. Elimina los BOMs antes de codificar. -
>
Metadatos de charset incorrectos — pones Base64 de bytes UTF-8 en un campo etiquetado como
charset=iso-8859-1. El decodificador usa la etiqueta para interpretar los bytes. Siempre alinea los metadatos con la codificación real. -
>
Confusión con Base64 URL-safe — usaste base64url para almacenamiento pero olvidaste restaurar el alfabeto estándar antes de decodificar. Solución: normaliza con
replace(/-/g, '+').replace(/_/g, '/')y rellena a un múltiplo de 4. -
>
Codificación dependiente del locale — en Python 2,
'日本語'.encode()predeterminaba a ASCII y fallaba. En cualquier runtime moderno, siempre especificautf-8explícitamente.
// PRUEBA DE IDA Y VUELTA — LA PRUEBA DEFINITIVA
La mejor manera de verificar que tu pipeline Base64 maneja Unicode correctamente es una prueba de ida y vuelta. Codifica una cadena conocida como buena, decodifícala y compara con el original byte por byte.
// Vitest / Jest / any test runner
const samples = [
'hello',
'héllo',
'日本語',
'한국어',
'👋 Renée — 日本語 — مرحبا',
'\n\t\0 edge cases',
'𐀀 ancient greek (U+10000)',
];
for (const s of samples) {
const encoded = encodeUtf8Base64(s);
const decoded = decodeUtf8Base64(encoded);
expect(decoded).toBe(s);
expect([...new TextEncoder().encode(s)])
.toEqual([...new TextEncoder().encode(decoded)]);
}
// BASE64 DE BINARIO vs BASE64 DE TEXTO
Una distinción sutil pero importante. Cuando codificas en Base64 un archivo PNG, la entrada ya son bytes — no hay pregunta Unicode que responder. Simplemente pasa esos bytes directamente al codificador.
Cuando codificas en Base64 una cadena, la entrada es una secuencia de puntos de código Unicode, no bytes. Debes comprometerte con una codificación de bytes (prácticamente siempre UTF-8 en 2026) antes de que el codificador pueda ver nada. La cadena Base64 que produces es válida solo si el receptor revierte la misma codificación de bytes.
Esta es la fuente de la mayoría de los tickets "¿por qué mi Base64 está mal?": dos sistemas codificaron en Base64 la misma cadena lógica usando diferentes codificaciones de bytes subyacentes (uno UTF-8, uno UTF-16, o uno Latin-1), y el decodificador del receptor felizmente reproduce los bytes incorrectos.
// ¿DEBO USAR SIEMPRE UTF-8?
Sí, a menos que estés trabajando dentro de un sistema heredado que especifique lo contrario. UTF-8 es:
• La codificación dominante en la web (> 97% de los sitios web en 2026)
• El predeterminado en la biblioteca estándar de cada lenguaje de programación moderno
• Compatible con ASCII para los primeros 128 puntos de código, por lo que nunca rompe el inglés
• Bien definido y de ida y vuelta para cada carácter Unicode
• El charset recomendado por la IETF para protocolos nuevos (RFC 2277)
Las alternativas antiguas (UTF-16, UTF-32, Latin-1, GBK, Shift-JIS) todavía aparecen en bases de datos heredadas, APIs de Windows y algunos protocolos de telecomunicaciones. Si tus datos vinieron de uno de esos sistemas, convierte a UTF-8 en el límite, codifica en Base64 el UTF-8, y documenta la decisión de codificación en el formato de la carga.
// EL FALLBACK SIN TextEncoder (NAVEGADORES HEREDADOS)
Si necesitas soportar un navegador muy antiguo sin TextEncoder (IE11 y anteriores), el truco clásico es usar encodeURIComponent como codificador UTF-8, luego unescape de vuelta a una cadena de bytes. Este patrón aparece en cada respuesta de StackOverflow sobre Base64 y Unicode desde 2008 hasta 2020.
// Legacy browser fallback (pre-2017)
function encodeUtf8Base64Legacy(str) {
return btoa(unescape(encodeURIComponent(str)));
}
function decodeUtf8Base64Legacy(b64) {
return decodeURIComponent(escape(atob(b64)));
}
encodeUtf8Base64Legacy('日本語'); // '5pel5pys6Kqe'
decodeUtf8Base64Legacy('5pel5pys6Kqe'); // '日本語'
// Note: unescape() and escape() are deprecated.
// Only use this pattern if TextEncoder is genuinely unavailable.
// It also mishandles some edge-case surrogates.
// QUÉ RECORDAR
- > Base64 codifica bytes, no caracteres. Elige una codificación de bytes primero — casi siempre UTF-8.
-
>
En el navegador:
TextEncoder→String.fromCharCode→btoa→ una dirección. -
>
En el navegador:
atob→Uint8Array.from→TextDecoder→ la otra dirección. -
>
En Node.js:
Buffer.from(str).toString('base64')yBuffer.from(b64, 'base64').toString()— ambos son UTF-8 por defecto. -
>
En Python / Go / Java: siempre pasa un
'utf-8'explícito a.encode()/.getBytes(). - > Siempre haz pruebas de ida y vuelta con Unicode real (emoji, CJK, árabe, marcas combinantes) antes de publicar.
// RELACIONADOS
• ¿Qué es Base64 y cómo funciona? — el agrupamiento de 6 bits que subyace a todo aquí.
• Base64 en JavaScript (navegador + Node) — helper multi-runtime completo.
• URL-safe vs Base64 estándar — una elección ortogonal que quizás también necesites hacer.
• Prueba el codificador Base64 en vivo — el mismo pipeline seguro para Unicode, en tu navegador.