Transmite archivos grandes en React sin problemas de memoria
Descargar archivos grandes en aplicaciones React se complica en cuanto los archivos superan unos
cientos de megabytes. El patrón ingenuo fetch → blob → link.click() mantiene todo el archivo en
memoria, lo que lleva directo a pestañas que se bloquean y usuarios molestos. Por suerte, las API
modernas del navegador nos permiten transmitir datos desde la red al disco del usuario sin retener
el archivo completo en JavaScript. La transmisión por streams sigue necesitando búferes del
navegador, de red y del sistema de archivos; no usa cero memoria.
Por qué las descargas tradicionales basadas en blobs fallan con archivos grandes
Una función auxiliar de descarga clásica se ve así:
async function traditionalDownload(url) {
const res = await fetch(url)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const blob = await res.blob() // Materializes the complete response before saving
const objectUrl = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = objectUrl
a.download = 'file.zip'
a.click()
setTimeout(() => URL.revokeObjectURL(objectUrl), 60000)
}
Los problemas aparecen en cuanto el archivo es más grande que el presupuesto de memoria disponible del usuario:
- La respuesta completa se materializa antes de guardarla; el almacenamiento de respaldo y el pico de RAM dependen del navegador y pueden crecer con el tamaño del archivo.
- El almacenamiento en búfer añade sobrecarga de asignación y procesamiento, aunque
blob()en sí es asíncrono. - Esta función auxiliar no expone ninguna información de progreso mientras se lee la respuesta.
- La configuración de descargas del navegador controla el destino y si se le pregunta al usuario.
Transmite datos con las API de fetch y streams
fetch() nos da un ReadableStream en response.body. En lugar de acumular los fragmentos en un array
(que volvería a crecer con el tamaño del archivo), podemos enviar cada fragmento directamente a un WritableStream.
Cuando la File System Access API está disponible, ese flujo de escritura apunta al archivo que el
usuario seleccionó en el disco, y mantiene el almacenamiento en búfer de la aplicación acotado en
lugar de proporcional al tamaño del archivo.
Guarda las siguientes funciones auxiliares en downloads.ts dentro de un proyecto de React con
TypeScript. Si tus tipos de DOM no declaran la API del selector de archivos, instala sus
declaraciones. Incluye wicg-file-system-access si tu configuración de
TypeScript restringe la lista types. Habilita allowImportingTsExtensions y noEmit
para las importaciones de .ts que usa este ejemplo; el bundler se encarga de la salida de JavaScript:
npm install --save-dev @types/wicg-file-system-access
Llama a streamToDisk directamente desde un manejador de clic para que el selector cuente con
activación del usuario. Propaga los errores y la cancelación a quien la llama, aborta las escrituras
incompletas y libera su lector. El servidor debe permitir la solicitud mediante CORS cuando esté en
otro origen.
export async function streamToDisk(
url: string,
suggestedName: string,
onProgress: (percent: number) => void,
signal?: AbortSignal,
): Promise<void> {
if (!window.isSecureContext || typeof window.showSaveFilePicker !== 'function') {
throw new Error('File System Access API not supported in this browser')
}
signal?.throwIfAborted()
const fileHandle = await window.showSaveFilePicker({ suggestedName })
signal?.throwIfAborted()
const writable = await fileHandle.createWritable()
try {
signal?.throwIfAborted()
const response = await fetch(url, { signal })
if (!response.ok) {
await response.body?.cancel()
throw new Error(`HTTP ${response.status}`)
}
if (!response.body) throw new Error('The response has no readable body')
const total = Number(response.headers.get('Content-Length'))
let written = 0
const reader = response.body.getReader()
const cancelReader = () => { void reader.cancel(signal?.reason).catch(() => {}) }
signal?.addEventListener('abort', cancelReader, { once: true })
try {
while (true) {
signal?.throwIfAborted()
const { value, done } = await reader.read()
signal?.throwIfAborted()
if (done) break
await writable.write(value)
written += value.byteLength
if (Number.isFinite(total) && total > 0) {
onProgress(Math.min(99, (written / total) * 100))
}
}
signal?.throwIfAborted()
await writable.close()
signal?.throwIfAborted()
} finally {
signal?.removeEventListener('abort', cancelReader)
// Cleanup must not replace the original transfer error.
await reader.cancel().catch(() => {})
reader.releaseLock()
}
} catch (error) {
await writable.abort(error).catch(() => {})
throw error
}
}
Puntos clave:
- Ningún
Blobenorme queda en memoria; los fragmentos van directo al disco. - El progreso requiere un
Content-Lengthpreciso que coincida con los bytes decodificados del cuerpo. Sirve las descargas sin compresión de contenido para este cálculo; de lo contrario, muestra un progreso indeterminado. - El progreso se mantiene por debajo del 100 % hasta que el flujo de escritura se cierra correctamente.
- El selector de guardado está disponible en los navegadores Chromium compatibles, pero no en Firefox ni Safari.
Compatibilidad de navegadores de un vistazo
| Navegador | showSaveFilePicker() |
|---|---|
| Chrome para escritorio | 86+ |
| Edge para escritorio | 86+ |
| Firefox | No compatible |
| Safari | No compatible |
Estos son resultados específicos del selector, tomados de los datos de compatibilidad de MDN, verificados en septiembre de 2026. La compatibilidad con el sistema de archivos privado del origen no implica compatibilidad con un selector de guardado. Detecta siempre la capacidad en tiempo de ejecución.
Para archivos grandes en navegadores sin esta API, es preferible un enlace de descarga normal a un
endpoint que devuelva Content-Disposition: attachment. El navegador gestiona la descarga sin un array
de fragmentos en JavaScript. Usa autenticación de sesión del mismo origen o una URL de descarga
autorizada cuando un enlace no pueda proporcionar las cabeceras de autorización habituales de la
API. El atributo download por sí solo no es suficiente para URL arbitrarias de
origen cruzado.
Solo para archivos pequeños, una alternativa basada en Blob puede resultar cómoda. Leer por
fragmentos sigue reteniendo el archivo completo. Esta función auxiliar aplica un límite de
aplicación de 50 MiB tanto frente a una longitud declarada como frente a los bytes recibidos reales;
redúcelo para los dispositivos de tu audiencia. La creación de un Blob puede necesitar copias
adicionales de forma temporal, así que esto no es un tope de 50 MiB sobre la RAM del navegador.
Añade esto a downloads.ts:
const MAX_BLOB_BYTES = 50 * 1024 * 1024
export async function saveWithFallback(
url: string,
filename: string,
onProgress: (percent: number) => void,
signal?: AbortSignal,
): Promise<void> {
const response = await fetch(url, { signal })
if (!response.body) throw new Error('The response has no readable body')
const reader = response.body.getReader()
const cancelReader = () => { void reader.cancel(signal?.reason).catch(() => {}) }
signal?.addEventListener('abort', cancelReader, { once: true })
try {
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const total = Number(response.headers.get('Content-Length'))
if (total > MAX_BLOB_BYTES) throw new Error('Use the direct download link for this file')
const chunks: ArrayBuffer[] = []
let received = 0
while (true) {
signal?.throwIfAborted()
const { done, value } = await reader.read()
signal?.throwIfAborted()
if (done) break
received += value.byteLength
if (received > MAX_BLOB_BYTES) throw new Error('Use the direct download link for this file')
chunks.push(value.slice().buffer)
if (Number.isFinite(total) && total > 0) {
onProgress(Math.min(99, (received / total) * 100))
}
}
signal?.throwIfAborted()
const objectUrl = URL.createObjectURL(new Blob(chunks))
const link = document.createElement('a')
link.href = objectUrl
link.download = filename
try {
document.body.appendChild(link)
link.click()
} finally {
link.remove()
setTimeout(() => URL.revokeObjectURL(objectUrl), 60000)
}
} finally {
signal?.removeEventListener('abort', cancelReader)
await reader.cancel().catch(() => {})
reader.releaseLock()
}
}
La alternativa se resuelve cuando entrega el Blob al navegador. JavaScript no puede confirmar que el
usuario lo haya guardado en el disco. Una biblioteca como browser-fs-access puede simplificar la integración
con el navegador, pero su alternativa basada en Blob tiene la misma limitación de almacenar en búfer
el archivo completo.
Crea un hook de React reutilizable
Guarda este hook como useDownload.ts. Usa las funciones auxiliares anteriores, evita descargas
superpuestas y aborta el trabajo activo cuando el componente se desmonta. Un selector o una
transferencia que se cancela no informa éxito.
import { useEffect, useRef, useState } from 'react'
import { saveWithFallback, streamToDisk } from './downloads.ts'
type UseDownloadReturn = {
progress: number | null
isDownloading: boolean
error: string | null
start: (url: string, filename: string) => Promise<void>
cancel: () => void
}
export function useDownload(): UseDownloadReturn {
const [progress, setProgress] = useState<number | null>(null)
const [isDownloading, setIsDownloading] = useState(false)
const [error, setError] = useState<string | null>(null)
const active = useRef<AbortController | null>(null)
useEffect(() => () => {
active.current?.abort()
active.current = null
}, [])
async function start(url: string, filename: string): Promise<void> {
if (active.current) return
const controller = new AbortController()
active.current = controller
setIsDownloading(true)
setError(null)
setProgress(null)
const onProgress = (p: number) => {
if (!controller.signal.aborted) setProgress(p)
}
try {
if (typeof window.showSaveFilePicker === 'function' && window.isSecureContext) {
await streamToDisk(url, filename, onProgress, controller.signal)
} else {
// Fallback for browsers that do not support the File System Access API
// or when not in a secure context.
await saveWithFallback(url, filename, onProgress, controller.signal)
}
controller.signal.throwIfAborted()
setProgress(100)
} catch (error) {
if (active.current !== controller) return
setProgress(null)
if (!controller.signal.aborted && !(error instanceof DOMException && error.name === 'AbortError')) {
setError('Download failed. Try again or use the direct download link.')
}
} finally {
if (active.current === controller) {
active.current = null
setIsDownloading(false)
}
}
}
function cancel(): void {
active.current?.abort()
}
return { progress, isDownloading, error, start, cancel }
}
Ahora, usar el hook dentro de un componente es trivial:
import type { ReactNode } from 'react'
import { useDownload } from './useDownload.ts'
interface DownloadButtonProps {
url: string
filename: string
}
export function DownloadButton({ url, filename }: DownloadButtonProps): ReactNode {
const { progress, isDownloading, error, start, cancel } = useDownload()
return (
<div>
<button onClick={() => start(url, filename)} disabled={isDownloading}>
{isDownloading ? 'Downloading…' : 'Save with progress (small files in fallback browsers)'}
</button>
<button onClick={cancel} disabled={!isDownloading}>Cancel</button>
<a href={url} download={filename}>Direct download (recommended for large files)</a>
{isDownloading ? (
<div>
<progress aria-label="Download progress" value={progress ?? undefined} max={100} />
<span>{progress == null ? 'Downloading…' : `${Math.round(progress)}%`}</span>
</div>
) : null}
{error ? <div role="alert">{error}</div> : null}
</div>
)
}
Seguridad, permisos y manejo de errores
La File System Access API es potente y, por lo tanto, está protegida por varias salvaguardas. Entenderlas es clave para una experiencia de usuario fluida y un manejo de errores robusto.
- Contexto seguro: la página debe servirse mediante HTTPS o desde
localhost. Siwindow.isSecureContextesfalse,showSaveFilePicker()no estará disponible. Tu código debería comprobarlo y, llegado el caso, informar al usuario o usar la alternativa. - Gesto del usuario: el selector de archivos solo puede abrirse como respuesta directa a una interacción del usuario, como un clic o la pulsación de una tecla. Las llamadas programáticas sin un gesto previo del usuario fallarán.
- Alcance de los permisos: el acceso se concede únicamente al archivo que el usuario selecciona. Tu aplicación no puede escribir en otros archivos ni ubicaciones sin permiso explícito del usuario en cada caso.
- Persistencia de los permisos: no asumas que un identificador almacenado conserva el permiso. Esta función auxiliar abre el selector en cada guardado; los flujos de trabajo que conservan identificadores deberían consultar el permiso antes de reutilizarlos.
- Carpetas restringidas: los navegadores impiden el acceso a directorios sensibles del sistema. El selector de archivos los filtra, de modo que los usuarios no puedan seleccionarlos por accidente (ni de forma malintencionada).
- Manejo de errores: es fundamental envolver las llamadas a
showSaveFilePicker()y las operaciones de flujo posteriores en bloquestry...catch.AbortError: este error se lanza si el usuario descarta el selector de archivos (por ejemplo, al hacer clic en «Cancelar»). Es un caso frecuente y debería manejarse con elegancia, quizá restableciendo el estado de la interfaz sin mostrar un mensaje de error agresivo.- Otros errores: los problemas de red, las limitaciones de espacio en disco o un comportamiento inesperado de la API también pueden provocar errores. Regístralos para depurarlos y ofrece un mensaje claro para el usuario.
La función auxiliar streamToDisk anterior maneja estos fallos en la ruta que el hook usa realmente.
Aborta el flujo de escritura cuando hay un fallo, y cancela y libera el lector de la respuesta en finally.
Cierra el flujo de escritura solo después de leer todos los bytes. Cancelar durante la confirmación
final en el sistema de archivos no puede garantizar que se deshaga un guardado que ya se completó.
Del mismo modo, la alternativa basada en Blob no puede cancelar una descarga del navegador después
de delegarla.
Comparación del uso de memoria
| Enfoque | Búfer de la aplicación | Progreso |
|---|---|---|
response.blob() | Respuesta completa antes de guardar; almacenamiento según el navegador | No lo expone esta función auxiliar |
| Transmisión a File System Access | Un fragmento a la vez, más los búferes del navegador y del sistema de archivos | Cuando hay una longitud precisa disponible |
| Alternativa Blob acotada | Archivo completo hasta 50 MiB, más copias temporales | Cuando hay una longitud precisa disponible |
| Enlace de descarga normal | Gestionado por el navegador, fuera de este búfer de JavaScript | Interfaz de descargas del navegador |
Conclusión
Con los streams de fetch() y la File System Access API, puedes permitir que los usuarios descarguen
recursos de tamaño gigabyte sin retener el archivo completo en JavaScript. Usa la alternativa Blob
acotada solo para archivos pequeños. Para descargas grandes en navegadores sin selector de guardado,
ofrece un endpoint de descarga normal y deja que el navegador gestione la transferencia.
¿Necesitas una solución equivalente para las subidas? Echa un vistazo a nuestro Robot que impulsa el servicio de subida de archivos: encaja a la perfección en el mismo flujo de trabajo.
