Cómo Crear un Archivo Descargable en el Navegador
Crear un archivo descargable en el navegador combina cuatro APIs del navegador: un Blob para los datos, URL.createObjectURL() para una referencia en memoria, un ancla con el atributo download para activar el guardado, y URL.revokeObjectURL() para liberar la referencia posteriormente. El patrón completo tiene menos de diez líneas:
function downloadBlob(data, filename, type) {
const blob = new Blob([data], { type });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
downloadBlob(JSON.stringify({ ok: true }), 'config.json', 'application/json');
Este es el patrón canónico y cubre el caso habitual de guardar un CSV generado, una configuración JSON o una imagen. Sin embargo, el camino ideal oculta fallos reales: el atributo download que se desactiva silenciosamente en URLs de origen cruzado, iOS Safari abriendo descargas en una nueva pestaña, URLs de objetos que generan fugas de memoria entre re-renderizados de componentes, y Excel corrompiendo archivos CSV en UTF-8. Este artículo cubre el patrón funcional, la moderna File System Access API, el streaming para archivos demasiado grandes para caber en memoria, y las peculiaridades de cada plataforma que rompen las descargas en producción.
Puntos Clave
- La descarga canónica en el lado del cliente es
new Blob([data], { type })→URL.createObjectURL()→ un ancla con el atributodownload→click()→URL.revokeObjectURL(). - El tiempo de vida de una URL de objeto está ligado al documento que la creó, por lo que debe revocarse en una función de limpieza del framework (
useEffectreturn, VueonUnmounted) en lugar de depender de una relación de temporización no verificada cona.click(). showSaveFilePicker()es la única forma nativa del navegador de permitir al usuario elegir una ubicación de guardado, pero es experimental y exclusiva de Chromium, por lo que es obligatorio detectarla y recurrir al patrón de ancla como alternativa.- Excel solo abre correctamente un CSV en UTF-8 cuando el archivo incluye un BOM, por lo que hay que anteponer
\uFEFFa la cadena de texto. - iOS Safari ha tenido históricamente un soporte poco fiable para descargas con el atributo
download, razón por la cual las descargas en ese sistema suelen abrirse en una nueva pestaña en lugar de guardarse.
El patrón canónico: descargar un archivo con JavaScript en el navegador
Discover how at OpenReplay.com.
La forma fiable de descargar un archivo generado con JavaScript consiste en construir un Blob, crear una URL de objeto con URL.createObjectURL(), asignarla al href de un ancla, establecer el atributo download con el nombre del archivo, hacer clic en el ancla mediante programación y, a continuación, liberar la URL con URL.revokeObjectURL(). El constructor de Blob permite establecer el tipo MIME de forma independiente a los datos, y la URL de objeto es una referencia corta (blob:https://…) a la que el ancla puede navegar.
Evita las data URIs como opción predeterminada. Las data URIs son la elección incorrecta por defecto para la generación de archivos en el lado del cliente: Base64 codifica cada 3 bytes como 4 caracteres, aumentando el tamaño del payload en aproximadamente un tercio antes del relleno (según el RFC 4648), y la cadena codificada completa debe caber en memoria como valor de un atributo del DOM. Los límites de tamaño actuales para URLs data: son 512 MB en Chromium y Firefox, y 2048 MB en Safari/WebKit (referencia de URLs data: en MDN), pero la sobrecarga de codificación y el coste de la cadena en memoria hacen que Blob sea la mejor opción predeterminada mucho antes de alcanzar esos límites.
El ancla no necesita estar adjunta al DOM para que click() funcione en los navegadores actuales, lo que mantiene el helper autocontenido:
// Works in Chrome 14+, Firefox 20+ (download attribute, HTML5).
// caniuse: https://caniuse.com/download
function downloadBlob(data, filename, type = 'application/octet-stream') {
const blob = new Blob([data], { type });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a); // appended for the broadest compatibility
a.click();
a.remove();
URL.revokeObjectURL(url);
}
const csv = 'name,role\nAda,Engineer\nGrace,Architect';
downloadBlob(csv, 'team.csv', 'text/csv;charset=utf-8;');
La llamada a URL.revokeObjectURL(url) importa más de lo que la mayoría de los ejemplos sugieren. El tiempo de vida de una URL de objeto está ligado al documento que la creó, por lo que persiste en memoria hasta que se revoca o el documento se descarga. En un script desechable de un solo uso esto es inofensivo; en una aplicación basada en componentes donde el helper se ejecuta en cada clic de botón, las URLs no liberadas se acumulan. La revocación síncrona del ejemplo anterior es segura aquí porque nada en esta función sobrevive a la llamada; sin embargo, como se muestra en la sección de frameworks, esa ubicación es incorrecta dentro de un componente.
El atributo HTML download y su silencioso fallo en origen cruzado
Para un archivo que ya está alojado en tu propio origen, no necesitas JavaScript en absoluto: basta con añadir el atributo download a un ancla normal:
<a href="/reports/q3.pdf" download="q3-report.pdf">Descargar informe</a>
El atributo download indica al navegador que guarde el recurso enlazado en lugar de navegar hacia él. Se añadió en HTML5 y está soportado en Chrome 14+ y Firefox 20+ (caniuse: atributo download). También acepta URLs blob: y data:, razón por la que se combina con el patrón canónico anterior.
El fallo se produce con URLs de origen cruzado. Para descargas de origen cruzado, el atributo download solo se respeta cuando la respuesta también incluye Content-Disposition: attachment; sin él, el navegador ignora el atributo y el enlace no constituye una descarga forzada fiable (según el Estándar HTML de WHATWG, descarga de recursos). Esta es una fuente frecuente de confusión: el mismo marcado que descarga un archivo del mismo origen navegará hacia un archivo servido desde una CDN en un origen diferente o lo mostrará en línea. Si controlas el servidor, establece la cabecera allí. Si no lo controlas, descarga el archivo y sírvelo de nuevo como blob desde tu propio origen:
// Re-serve a cross-origin file as a same-origin blob so `download` is honored.
async function downloadCrossOrigin(remoteUrl, filename) {
const res = await fetch(remoteUrl); // requires CORS to permit the fetch
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
File System Access API: permitir al usuario elegir una ubicación de guardado
La función showSaveFilePicker() de la File System Access API es la única forma nativa del navegador de permitir al usuario elegir una ubicación y nombre de archivo antes de escribir. Abre el diálogo de guardado del sistema operativo, devuelve un identificador de archivo y permite escribir a través de un FileSystemWritableFileStream. MDN la marca como de “disponibilidad limitada” y “experimental”; se introdujo en los navegadores de la familia Chromium a partir de la versión 86 (Chrome Status) y no está soportada en Firefox ni Safari, lo que hace que la detección de características y el uso del patrón de ancla como alternativa sean obligatorios para uso en producción.
showSaveFilePicker() lanza una DOMException con el nombre AbortError si el usuario cierra el selector, por lo que hay que gestionar ese caso explícitamente en lugar de tratarlo como un fallo:
async function saveFile(data, filename, type = 'application/octet-stream') {
// Feature-detect. Note: the property can exist but throw in some
// sandboxed iframe contexts, so the call itself is still guarded.
if ('showSaveFilePicker' in window) {
try {
const handle = await window.showSaveFilePicker({
suggestedName: filename,
types: [{ description: 'File', accept: { [type]: ['.' + filename.split('.').pop()] } }],
});
const writable = await handle.createWritable();
await writable.write(new Blob([data], { type }));
await writable.close();
return;
} catch (err) {
if (err.name === 'AbortError') return; // user cancelled, not an error
// fall through to the anchor fallback on any other failure
}
}
// Fallback: anchor + object URL (no location choice; browser default).
downloadBlob(data, filename, type);
}
La comprobación 'showSaveFilePicker' in window es la barrera adecuada, pero no es infalible: en algunos contextos de iframes con sandbox la propiedad existe pero lanza una excepción al ser invocada, razón por la que el bloque try/catch envuelve la invocación real y no solo la detección.
Streaming de archivos grandes que superan la memoria disponible
El streaming evita el límite de memoria de Blob escribiendo fragmentos directamente en disco en lugar de almacenar el payload completo en RAM. No existe un umbral de bytes fijo para ese límite: depende del dispositivo, el sistema operativo y el heap disponible del navegador. Dos enfoques de streaming evitan almacenar el archivo completo en memoria.
En Chromium, se puede escribir de forma incremental a través del FileSystemWritableFileStream devuelto por showSaveFilePicker(). Cada llamada a write() añade un fragmento al stream escribible, por lo que la aplicación no necesita mantener el archivo completo en memoria en ningún momento:
// Chromium 86+ only. Writes in chunks; never holds the full file in memory.
async function streamToDisk(filename, chunks) {
const handle = await window.showSaveFilePicker({ suggestedName: filename });
const writable = await handle.createWritable();
for await (const chunk of chunks) {
await writable.write(chunk); // chunk: string | ArrayBuffer | Blob
}
await writable.close();
}
Para navegadores sin la File System Access API, StreamSaver.js es la solución compatible con todos los navegadores. Según su README, StreamSaver.js guarda archivos grandes generados en el cliente creando un stream escribible y emulando una descarga impulsada por el servidor mediante cabeceras de respuesta y un service worker, de modo que los datos se escriben en disco a medida que se transmiten en lugar de ensamblarse en un único Blob. Se basa en el Estándar de Streams de WHATWG:
import streamSaver from 'streamsaver';
// Emulates a server download via a service worker; data streams to disk.
function streamWithStreamSaver(filename, readableStream) {
const fileStream = streamSaver.createWriteStream(filename);
// readableStream: a WHATWG ReadableStream of Uint8Array chunks
return readableStream.pipeTo(fileStream);
}
Recurre al streaming cuando el resultado sea genuinamente grande: exportaciones de varios cientos de megabytes, vídeo generado o un feed de datos de larga duración. Para un CSV de unos pocos miles de filas, el patrón canónico con Blob es más sencillo y suficiente.
Peculiaridades de iOS Safari y cómo resolverlas
iOS Safari ha tenido históricamente un soporte poco fiable para archivos generados y descargados mediante el atributo download (bug de WebKit 167341), por lo que un archivo generado frecuentemente se abre en una nueva pestaña o se muestra en línea en lugar de guardarse, convirtiendo a iOS en la plataforma donde el código de descarga falla con mayor frecuencia en producción. El README de FileSaver.js documenta la consecuencia práctica: en iOS, saveAs() debe ejecutarse dentro de una interacción del usuario como onClick, y setTimeout impedirá que se active; debido a las restricciones de iOS, saveAs() puede abrir una nueva ventana en lugar de descargar (README de FileSaver.js).
Las soluciones concretas son las siguientes:
- Mantén la descarga sincrónica con el gesto del usuario. Activa el clic directamente en el manejador de eventos, no después de un
awaitosetTimeoutque lo desconecte del gesto. Si es necesario realizar trabajo asíncrono primero, obtén y prepara los datos antes de que el usuario haga clic y, a continuación, activa el guardado de forma síncrona. - Ofrece al usuario una alternativa explícita. Cuando se abra una nueva pestaña con el archivo mostrado en línea, muestra un mensaje del tipo “toca Compartir → Guardar en Archivos”, ya que el usuario deberá completar el guardado manualmente.
- Prefiere
text/plaino un tipo visualizable para exportaciones de texto en iOS, aceptando que el archivo puede abrirse en un visor desde el que el usuario lo guarda, en lugar de intentar forzar una descarga que la plataforma no proporciona de forma fiable.
Estos fallos son invisibles para los registros del servidor y las analíticas, porque ninguna petición llega al backend y no se lanza ninguna excepción. Las repeticiones de sesión en producción de flujos de descarga muestran frecuentemente a usuarios haciendo clic varias veces en el activador porque no se produjo ningún feedback visible; en iOS esto suele manifestarse como una nueva pestaña que se abre y el usuario cierra de inmediato, regresando confundido. La repetición de sesión es una de las pocas técnicas que permite detectar esta clase de fallos silenciosos, ligados a gestos y plataformas específicas.
// iOS-aware: do async work first, then save synchronously inside the gesture.
async function prepareThenDownload(button, getData, filename, type) {
const data = await getData(); // network/CPU work happens before the click path
button.onclick = () => {
// synchronous with the user gesture — no setTimeout, no await here
downloadBlob(data, filename, type);
};
}
Problemas con tipos MIME: visualización en línea, CSV en Excel y saltos de línea
El tipo MIME que se pasa al constructor de Blob ayuda al navegador a determinar cómo interpretar el archivo, y especificarlo incorrectamente es una causa habitual de que el contenido descargado se abra de forma inesperada o se gestione incorrectamente. Establece un tipo que corresponda a los datos y combínalo con el atributo download para que el navegador trate el resultado como un adjunto.
El error más reportado con CSV es que los caracteres acentuados aparecen como caracteres extraños al abrir el archivo en Excel. La solución es un byte-order mark (BOM). Excel abre correctamente un archivo CSV en UTF-8 cuando el archivo se guarda con un BOM, por lo que hay que anteponerlo a la cadena de texto (Soporte de Microsoft: apertura de archivos CSV UTF-8 en Excel):
// Without BOM: Excel may show garbled characters for accented text (é, ü, ñ).
// With BOM: Excel opens the UTF-8 file correctly.
function downloadCSV(csvString, filename = 'export.csv') {
downloadBlob('\uFEFF' + csvString, filename, 'text/csv;charset=utf-8;');
}
Dos detalles adicionales del RFC 4180, que registra text/csv como el tipo MIME para CSV: la especificación define CRLF (\r\n) como delimitador de registros, por lo que se recomienda usar \r\n en lugar de \n entre filas para una máxima compatibilidad con hojas de cálculo; además, los campos que contengan comas, comillas o saltos de línea deben ir entre comillas dobles con las comillas internas duplicadas.
Evita usar application/octet-stream como mecanismo universal para forzar descargas. Es el tipo registrado por IANA para datos binarios arbitrarios, pero no es un mecanismo de descarga forzada fiable: el README de FileSaver.js señala que usar application/octet-stream para forzar descargas puede causar problemas en Safari. Usa el tipo correcto y específico, y confía en el atributo download (o en Content-Disposition) para imponer el guardado.
Problemas con frameworks: SSR, ciclo de vida de URLs de objetos y fugas de memoria
Dos errores relacionados con el ciclo de vida de los componentes rompen el patrón canónico de descarga en React, Vue y Svelte: llamar a APIs del DOM durante el renderizado en el lado del servidor (donde document no está definido), y revocar las URLs de objetos en el momento incorrecto del ciclo de vida del componente. Ambos tienen la misma causa raíz: los componentes se renderizan en el servidor y se vuelven a renderizar en el cliente, pero el patrón básico asume un único documento y una única ejecución.
Protégete contra el SSR. En Next.js, Nuxt o SvelteKit, el código de los componentes puede ejecutarse donde document no está definido; llamar a document.createElement en ese contexto lanza una excepción. Protege cualquier helper de descarga con una comprobación en tiempo de ejecución:
function triggerDownload(data, filename, type) {
if (typeof document === 'undefined') return; // SSR guard
downloadBlob(data, filename, type);
}
Revoca las URLs de objetos en la limpieza, no de forma inmediata. La llamada síncrona a URL.revokeObjectURL(url) del patrón canónico es correcta para un helper de un solo uso que termina antes de que ocurra cualquier otra cosa. Sin embargo, si almacenas una URL de blob en el estado para usarla como href o src, revocarla demasiado pronto rompe la descarga, y no revocarla nunca genera fugas de memoria entre re-renderizados. La regla verificada es la siguiente: el tiempo de vida de una URL de objeto está ligado al documento que la creó, por lo que debe revocarse una vez que ya no sea necesaria; en frameworks basados en componentes, almacena la URL de blob en el estado y revócala en una función de limpieza, en lugar de depender de una relación de temporización no verificada con a.click().
import { useEffect, useState } from 'react';
function DownloadLink({ data, filename, type = 'application/json' }) {
const [url, setUrl] = useState(null);
useEffect(() => {
const blob = new Blob([data], { type });
const objectUrl = URL.createObjectURL(blob);
setUrl(objectUrl);
// Cleanup runs on unmount and before the effect re-runs:
return () => URL.revokeObjectURL(objectUrl);
}, [data, type]);
// ANTI-PATTERN — do NOT revoke here:
// <a href={url} download={filename} onClick={() => URL.revokeObjectURL(url)}>
// Revoking inside the click can release the URL before the download starts.
return url ? <a href={url} download={filename}>Descargar</a> : null;
}
El equivalente en Vue crea la URL en onMounted (o en un watch) y la revoca en onUnmounted; Svelte usa onDestroy. En los tres casos, la fuga que se produce es una nueva URL de objeto creada en cada re-renderizado o cada clic de botón sin una revocación correspondiente, lo que hace que la memoria crezca durante toda la vida del documento.
Para navegadores heredados que carecen por completo del atributo download, FileSaver.js sigue siendo un polyfill de una sola línea, pero para objetivos modernos el patrón de plataforma descrito anteriormente lo reemplaza.
Conclusión
La secuencia Blob → URL de objeto → ancla → download → revocación cubre la gran mayoría del trabajo de descarga en el lado del cliente, con showSaveFilePicker() como mejora exclusiva de Chromium cuando los usuarios necesitan elegir una ubicación, y el streaming como vía de escape cuando los archivos superan la memoria disponible. Los errores que llegan a producción rara vez están en el camino ideal: son el atributo de origen cruzado que navega silenciosamente, el CSV que necesita un BOM, la pestaña de iOS que se abre en lugar de guardar, y la URL de objeto revocada en el momento incorrecto. Integra la limpieza en el ciclo de vida de tu componente, detecta las APIs experimentales antes de llamarlas, y prueba el flujo en un dispositivo iOS real antes de publicarlo.
Preguntas Frecuentes
Un Blob almacena datos en bruto y se referencia mediante una URL de objeto corta creada con URL.createObjectURL(), mientras que una data URI incrusta el payload completo como una cadena Base64 directamente en la URL. La codificación Base64 aumenta el payload en aproximadamente un tercio antes del relleno, y la cadena codificada completa debe caber en memoria como valor de un atributo. Blob permite establecer un tipo MIME de forma independiente a los datos y evita ambos costes, lo que lo convierte en la mejor opción predeterminada para la generación de archivos en el lado del cliente.
El atributo download se respeta para URLs del mismo origen, pero se ignora para URLs de origen cruzado a menos que la respuesta también incluya una cabecera Content-Disposition: attachment, según el Estándar HTML de WHATWG. Un archivo servido desde una CDN en un origen diferente navegará o se mostrará en línea en lugar de descargarse. La solución es establecer la cabecera en el servidor que controlas, o descargar el archivo de origen cruzado y servirlo de nuevo como blob del mismo origen usando URL.createObjectURL().
No. La función showSaveFilePicker() de la File System Access API solo está soportada en navegadores de la familia Chromium a partir de la versión 86 y no está implementada en Firefox ni Safari. MDN la marca como de 'disponibilidad limitada' y 'experimental'. Por este motivo, el código en producción debe detectar la característica con 'showSaveFilePicker' in window y recurrir al patrón de ancla y URL de objeto como alternativa. Envuelve la llamada en un try/catch, ya que la propiedad puede existir pero lanzar una excepción en algunos contextos de iframes con sandbox, y lanza AbortError cuando el usuario cierra el diálogo.
Revocar la URL de objeto antes de que se inicie la descarga la rompe, porque el navegador ya no puede resolver la referencia blob a la que apunta el ancla. En un helper de un solo uso que termina de forma síncrona, revocar justo después de click() es seguro. En un componente que almacena la URL de blob en el estado para un href o src, revócala en una función de limpieza, como el valor de retorno de useEffect, onUnmounted de Vue o onDestroy de Svelte. No revocarla nunca genera fugas de memoria entre re-renderizados durante toda la vida del documento.
Understand every bug
Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data. Check our GitHub repo and join the thousands of developers in our community.