Transfiere archivos grandes en React sin problemas de memoria
Para una descarga grande en React, espera cada escritura en un archivo seleccionado por el usuario en lugar de acumular la respuesta en un Blob. Este tutorial monta una aplicación React completa, sirve localmente un flujo binario conocido de 256 MiB y comprueba el número de bytes y el SHA-256 del archivo guardado. La vía de streaming limita los fragmentos que retiene tu aplicación; los búferes del navegador, de la red y del sistema de archivos siguen consumiendo memoria.
Por qué las descargas tradicionales con Blob fallan con archivos grandes
El conocido patrón fetch → blob → link.click() espera la respuesta completa antes de entregarla
al navegador. Response.blob()
lee el cuerpo hasta el final. El navegador elige el almacenamiento que respalda el Blob, por lo que
esto no implica necesariamente una única asignación enorme en el heap de JavaScript, pero sí
materializa el archivo completo.
Usa la File System Access API cuando necesites un selector de destino y progreso en la aplicación en navegadores Chromium de escritorio compatibles. Para un archivo grande en Firefox o Safari, ofrece un enlace común a un endpoint de archivos adjuntos. El navegador gestiona esa transferencia fuera de tu array de fragmentos de JavaScript. Este ejemplo también incluye una alternativa con Blob deliberadamente limitada para archivos pequeños. Aquí la tarea es guardar en nuevos destinos de descarga; almacenar y volver a abrir identificadores de archivos locales corresponde a un flujo de trabajo de gestión persistente de archivos independiente.
Transfiere datos con las API fetch y streams
Usa Bash, Node.js 24.15 o posterior de la rama 24 LTS con mantenimiento, y Corepack con Yarn 4.12.0. Instala Corepack por separado si tu distribución de Node lo omite. La vía del selector nativo se probó en Linux con Chromium 145; las vías de enlace y Blob se probaron en Firefox. Usa un directorio nuevo fuera de un proyecto o espacio de trabajo existente. Este bloque de creación con comprobaciones verifica los requisitos previos y la configuración de paquetes de los directorios superiores antes de escribir nada:
(
command -v node >/dev/null && command -v corepack >/dev/null &&
node --input-type=module -e '
import { existsSync } from "node:fs"
import { dirname, join, resolve } from "node:path"
const [major, minor] = process.versions.node.split(".").map(Number)
if (!(major === 24 && minor >= 15 || major >= 26)) throw Error("Use Node 24.15+ or 26+")
for (let dir = resolve("."); ; dir = dirname(dir)) {
if (["package.json", ".yarnrc.yml", ".pnp.cjs"].some(name => existsSync(join(dir, name)))) {
throw Error("Choose a directory outside an existing project or workspace")
}
if (dirname(dir) === dir) break
}
' && COREPACK_ENABLE_AUTO_PIN=0 corepack yarn --version && mkdir react-stream-demo
) && cd react-stream-demo
Se rechaza cualquier directorio react-stream-demo existente. Conserva sus archivos y
elige otro directorio padre vacío en lugar de eliminarlo. Si la instalación falla más adelante,
reinténtala en este nuevo proyecto; ninguno de los siguientes pasos requiere volver a crearlo.
Guarda estos archivos en el nuevo directorio. package.json fija las versiones de
las dependencias de la demostración:
{
"name": "react-stream-demo",
"private": true,
"type": "module",
"packageManager": "yarn@4.12.0",
"scripts": {
"typecheck": "tsc --noEmit",
"build": "yarn typecheck && vite build",
"start": "node server.ts"
},
"dependencies": { "react": "19.2.6", "react-dom": "19.2.6" },
"devDependencies": {
"@types/node": "24.10.1",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@types/wicg-file-system-access": "2023.10.7",
"typescript": "6.0.3",
"vite": "7.3.1"
}
}
Guarda tsconfig.json. Esta configuración está completa y no hereda ningún
extends:
{
"compilerOptions": {
"target": "ES2024",
"lib": ["ES2024", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"types": ["node", "react", "react-dom", "wicg-file-system-access"]
},
"include": ["*.ts", "*.tsx"]
}
Guarda .yarnrc.yml para que la instalación, las compilaciones y el inicio usen
el mismo enlazador:
nodeLinker: node-modules
Guarda index.html; Vite empaqueta el punto de entrada de TypeScript:
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><meta name="color-scheme" content="light dark"><meta name="viewport" content="width=device-width, initial-scale=1"><title>React download demo</title></head>
<body><main id="root"></main><script type="module" src="/main.tsx"></script></body>
</html>
Compatibilidad de navegadores de un vistazo
| Navegador | Selector de destino | Vía para archivos grandes aquí |
|---|---|---|
| Chrome y Edge de escritorio | Detectar showSaveFilePicker | Streaming al archivo seleccionado |
| Firefox y Safari | Sin showSaveFilePicker | Enlace a archivo adjunto |
Consulta la documentación de MDN sobre el selector para conocer la compatibilidad actual. La compatibilidad con el sistema de archivos privado del origen no implica disponer de un selector para guardar. El selector necesita un contexto seguro y un gesto del usuario; HTTP sobre la interfaz de bucle local es adecuado para esta demostración local. Node 24 es una versión LTS con mantenimiento; su ejecución nativa de TypeScript es suficiente para el servidor y el verificador, mientras que Vite compila TSX para el navegador.
Guarda downloads.ts. Ambas vías usan el mismo bucle de lectura. Este espera al
consumidor antes de solicitar otro fragmento, cancela la respuesta si ocurre un fallo y libera el
lector. Si Content-Length falta o corresponde a contenido comprimido, el progreso se
expresa solo en bytes: fetch expone los bytes del cuerpo decodificado, por lo que una
longitud de transmisión comprimida no es un denominador válido.
export type Progress = { received: number; total: number | null }
export type DownloadResult = { bytes: number; kind: 'saved' | 'handed-off' }
const MAX_BLOB_BYTES = 50 * 1024 * 1024
async function receive(
url: string,
consume: (chunk: Uint8Array<ArrayBuffer>) => Promise<void>,
onProgress: (progress: Progress) => void,
signal: AbortSignal,
limit = Infinity,
): Promise<number> {
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 reader = response.body.getReader()
const cancelReader = () => { void reader.cancel(signal.reason).catch(() => {}) }
signal.addEventListener('abort', cancelReader, { once: true })
try {
const length = response.headers.get('Content-Length')
const parsed = length === null ? NaN : Number(length)
const encoding = response.headers.get('Content-Encoding')
const total = (!encoding || encoding === 'identity') && Number.isSafeInteger(parsed) && parsed >= 0
? parsed : null
if (total !== null && total > limit) throw new Error('Use the direct download link for this file')
let received = 0
onProgress({ received, total })
while (true) {
signal.throwIfAborted()
const { value, done } = await reader.read()
signal.throwIfAborted()
if (done) break
if (received + value.byteLength > limit) throw new Error('Use the direct download link for this file')
await consume(value)
received += value.byteLength
onProgress({ received, total })
}
if (total !== null && received !== total) throw new Error('Incomplete response')
return received
} finally {
signal.removeEventListener('abort', cancelReader)
await reader.cancel().catch(() => {})
reader.releaseLock()
}
}
export async function streamToDisk(
url: string,
filename: string,
onProgress: (progress: Progress) => void,
signal: AbortSignal,
): Promise<DownloadResult> {
signal.throwIfAborted()
const handle = await window.showSaveFilePicker({ suggestedName: filename })
signal.throwIfAborted()
const writable = await handle.createWritable()
try {
const bytes = await receive(url, (chunk) => writable.write(chunk), onProgress, signal)
signal.throwIfAborted()
await writable.close()
signal.throwIfAborted()
return { bytes, kind: 'saved' }
} catch (error) {
await writable.abort(error).catch(() => {})
throw error
}
}
export async function saveWithFallback(
url: string,
filename: string,
onProgress: (progress: Progress) => void,
signal: AbortSignal,
): Promise<DownloadResult> {
const chunks: ArrayBuffer[] = []
const bytes = await receive(url, async (chunk) => {
chunks.push(chunk.slice().buffer)
}, onProgress, signal, MAX_BLOB_BYTES)
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)
}
return { bytes, kind: 'handed-off' }
}
La vía con Blob limita los bytes decodificados reales a 50 MiB, incluidas las respuestas sin longitud. La creación del Blob puede requerir copias adicionales, por lo que este es un límite de entrada de la aplicación, no un tope de RAM del navegador. La vía nativa solo retorna después de que se cierre el flujo de escritura. No valida el contenido del servidor; el paso de verificación de la suma de comprobación que aparece más abajo lo hace de forma independiente.
Crea un hook de React reutilizable
Guarda useDownload.ts. Llama a start directamente desde un
controlador de clics: abrir el selector antes de cualquier espera de red preserva la activación del
usuario. La protección de estado ocupado impide inicios simultáneos, y el desmontaje aborta el
trabajo activo para que una transferencia anterior no pueda actualizar un componente posterior.
import { useEffect, useRef, useState } from 'react'
import { saveWithFallback, streamToDisk } from './downloads.ts'
import type { DownloadResult, Progress } from './downloads.ts'
type UseDownloadReturn = {
progress: Progress | null
result: DownloadResult | null
message: string
busy: boolean
start: (url: string, filename: string) => Promise<void>
cancel: () => void
}
export function useDownload(): UseDownloadReturn {
const [progress, setProgress] = useState<Progress | null>(null)
const [result, setResult] = useState<DownloadResult | null>(null)
const [message, setMessage] = useState('Ready')
const [busy, setBusy] = useState(false)
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
setBusy(true)
setProgress(null)
setResult(null)
setMessage('Choose a destination or wait for the download…')
const onProgress = (value: Progress) => {
if (active.current === controller && !controller.signal.aborted) setProgress(value)
}
try {
const download = window.isSecureContext && typeof window.showSaveFilePicker === 'function'
? streamToDisk : saveWithFallback
const completed = await download(url, filename, onProgress, controller.signal)
controller.signal.throwIfAborted()
if (active.current !== controller) return
setResult(completed)
setMessage(completed.kind === 'saved'
? `Saved ${completed.bytes.toLocaleString()} bytes. Verify the file below.`
: 'Handed to browser. Check your downloads; disk saving is not confirmed.')
} catch (error) {
if (active.current !== controller) return
setProgress(null)
setMessage(controller.signal.aborted || error instanceof DOMException && error.name === 'AbortError'
? 'Canceled. Saving is not confirmed.'
: 'Download failed. Retry or use the direct download link.')
} finally {
if (active.current === controller) {
active.current = null
setBusy(false)
}
}
}
function cancel(): void { active.current?.abort() }
return { progress, result, message, busy, start, cancel }
}
Guarda main.tsx. La opción de 256 MiB pone a prueba la vía nativa; selecciona
1 MiB para probar la alternativa con Blob en Firefox. El enlace común sigue disponible para archivos
grandes.
import { useState } from 'react'
import type { ReactNode } from 'react'
import { createRoot } from 'react-dom/client'
import { useDownload } from './useDownload.ts'
function App(): ReactNode {
const [mib, setMib] = useState('256')
const { progress, message, busy, start, cancel } = useDownload()
const url = `/file?mib=${mib}`
const filename = `fixture-${mib}.bin`
const percent = progress?.total !== null && progress?.total !== undefined && progress.total > 0
? Math.min(99, progress.received / progress.total * 100) : undefined
return (
<section>
<h1>React download demo</h1>
<label>File size (MiB) <select value={mib} disabled={busy} onChange={(event) => setMib(event.target.value)}>
<option value="1">1</option><option value="256">256</option><option value="512">512</option>
</select></label>
<p><button disabled={busy} onClick={() => { void start(url, filename) }}>Save with progress</button>{' '}
<button disabled={!busy} onClick={cancel}>Cancel transfer</button></p>
<p><a href={url}>Direct download</a></p>
{busy && progress !== null ? (
<p><progress aria-label="Download progress" value={percent} max={100} />{' '}
{progress.received.toLocaleString()} bytes received</p>
) : null}
<p role="status">{message}</p>
</section>
)
}
const root = document.getElementById('root')
if (!root) throw new Error('Missing root element')
createRoot(root).render(<App />)
Sirve un flujo binario conocido
Guarda server.ts. Sirve la aplicación compilada y un archivo adjunto sin
comprimir desde el mismo origen, por lo que este ejemplo local no necesita CORS ni autenticación.
Cada bloque de 64 KiB contiene un patrón binario repetido, incluidos bytes de valor cero. El pipe de
Node respeta la contrapresión de la respuesta; si un cliente se desconecta, el productor se destruye.
/manifest?mib=256 proporciona los bytes esperados y el SHA-256 sin enviar el archivo en sí.
import { createHash } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { createServer } from 'node:http'
import type { ServerResponse } from 'node:http'
import { join } from 'node:path'
import { Readable } from 'node:stream'
import { setTimeout as delay } from 'node:timers/promises'
const block = Buffer.from(Uint8Array.from({ length: 64 * 1024 }, (_, i) => i % 251))
function manifest(bytes: number) {
const hash = createHash('sha256')
for (let offset = 0; offset < bytes; offset += block.length) {
hash.update(block.subarray(0, Math.min(block.length, bytes - offset)))
}
return { bytes, sha256: hash.digest('hex') }
}
async function serve(url: URL, response: ServerResponse): Promise<void> {
if (url.pathname === '/file' || url.pathname === '/manifest') {
const mib = Number(url.searchParams.get('mib') ?? 256)
if (!Number.isInteger(mib) || mib < 0 || mib > 1024) {
response.writeHead(400).end('Choose an integer size from 0 to 1024 MiB')
return
}
const bytes = mib * 1024 * 1024
if (url.pathname === '/manifest') {
response.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify(manifest(bytes)))
return
}
response.setHeader('Content-Type', 'application/octet-stream')
response.setHeader('Content-Disposition', `attachment; filename="fixture-${mib}.bin"`)
response.setHeader('Cache-Control', 'no-store')
if (url.searchParams.get('length') !== 'unknown') response.setHeader('Content-Length', bytes)
async function* chunks() {
for (let offset = 0; offset < bytes; offset += block.length) {
yield block.subarray(0, Math.min(block.length, bytes - offset))
await delay(1)
}
}
const source = Readable.from(chunks())
response.on('close', () => source.destroy())
source.on('error', () => response.destroy())
source.pipe(response)
return
}
const asset = url.pathname === '/' ? 'index.html' : url.pathname.slice(1)
if (asset !== 'index.html' && !/^assets\/[a-zA-Z0-9._-]+$/.test(asset)) {
response.writeHead(404).end('Not found')
return
}
const body = await readFile(join(import.meta.dirname, 'dist', asset))
response.writeHead(200, { 'Content-Type': asset.endsWith('.js') ? 'text/javascript' : 'text/html' }).end(body)
}
async function main(): Promise<void> {
const server = createServer((request, response) => {
void serve(new URL(request.url ?? '/', 'http://localhost'), response).catch(() => {
if (response.headersSent) response.destroy()
else response.writeHead(500).end('Could not serve the demo. Build it first.')
})
})
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(Number(process.env.PORT ?? 8787), '127.0.0.1', resolve)
})
const address = server.address()
if (!address || typeof address === 'string') throw new Error('Missing listening address')
console.log(`Open http://127.0.0.1:${address.port}`)
}
main().catch((error: unknown) => { console.error(error); process.exitCode = 1 })
Instala localmente con el enlazador node-modules de Yarn; luego comprueba los tipos,
compila e inicia el servidor:
corepack yarn install
corepack yarn build && corepack yarn start
Abre http://127.0.0.1:8787, haz clic en Save with progress y
elige una nueva ruta llamada fixture-256.bin en el cuadro de diálogo nativo para guardar.
Acepta cualquier solicitud de permiso de escritura del navegador. Espera al estado que comienza con
Saved.
Deja el servidor en ejecución para la verificación. Ctrl+C lo detiene; si el puerto 8787 está ocupado,
usa PORT=8788 corepack yarn start y el origen correspondiente en el verificador.
Seguridad, permisos y gestión de errores
El selector para guardar requiere la activación del usuario. Mantenlo en el flujo del clic; no esperes primero una solicitud a la API. En un sitio desplegado, usa HTTPS. Elige rutas de salida nuevas y descartables. Aprobar el reemplazo pone en riesgo el archivo existente antes de que la operación termine correctamente: en el cuadro de diálogo GTK de Linux probado, un cierre final rechazado dejó vacío el destino aprobado. Abortar una escritura fallida no garantiza la restauración de ese archivo. Un guardado cancelado en una ruta nueva también puede dejar un archivo vacío. Cancelar durante la confirmación final en el sistema de archivos no garantiza que se reviertan los bytes ya confirmados.
Prueba a cancelar el selector, cancelar después de que comience el progreso y volver a guardar. Una cancelación o un error HTTP, de lectura, de escritura o de cierre final nunca deben producir el estado de guardado correcto. El hook restablece su resultado anterior en cada inicio. Abre un selector nuevo cada vez y no almacena identificadores ni estados de permisos, tampoco entre recargas o reinicios del navegador. Los flujos de trabajo con identificadores almacenados necesitan sus propias comprobaciones de permisos.
En tu propia API, devuelve un estado significativo si ocurre un fallo. Sin una longitud o suma de
comprobación confiable, una respuesta que termina sin errores, pero antes de tiempo, es
indistinguible de un archivo válido más corto.
Para solicitudes fetch entre orígenes, configura CORS y expón los encabezados de progreso que uses.
Un enlace directo no puede añadir un encabezado Authorization personalizado; usa autenticación de
sesión o una URL de descarga autorizada.
Content-Disposition: attachment es importante para las descargas entre orígenes;
el atributo download por sí solo no basta.
Comprueba el archivo real en disco
Guarda verify.ts. Lee el archivo desde el disco mediante streaming y compara
tanto el tamaño como el hash con el manifiesto de los datos de prueba locales. Comprueba la
ruta que le pasas, no otro archivo generado ni el estado del navegador.
import { createHash } from 'node:crypto'
import { createReadStream } from 'node:fs'
async function main(): Promise<void> {
const [path, mib = '256'] = process.argv.slice(2)
if (!path) throw new Error('Usage: node verify.ts /path/to/fixture-256.bin [MiB]')
const origin = process.env.DOWNLOAD_ORIGIN ?? 'http://127.0.0.1:8787'
const response = await fetch(`${origin}/manifest?mib=${encodeURIComponent(mib)}`)
if (!response.ok) throw new Error(`Manifest HTTP ${response.status}`)
const expected: unknown = await response.json()
if (typeof expected !== 'object' || expected === null || !('bytes' in expected) ||
!('sha256' in expected) || typeof expected.bytes !== 'number' || typeof expected.sha256 !== 'string') {
throw new Error('Invalid manifest')
}
const hash = createHash('sha256')
let bytes = 0
for await (const chunk of createReadStream(path)) {
bytes += chunk.length
hash.update(chunk)
}
const actual = hash.digest('hex')
if (bytes !== expected.bytes || actual !== expected.sha256) throw new Error('Size or SHA-256 mismatch')
console.log(`Verified ${bytes} bytes; SHA-256 ${actual}`)
}
main().catch((error: unknown) => { console.error(error); process.exitCode = 1 })
En una segunda terminal, entra en el proyecto y ejecuta el verificador con la ruta real donde guardaste el archivo:
node verify.ts "/absolute/path/to/fixture-256.bin" 256
El resultado esperado es Verified 268435456 bytes; SHA-256 …. Un archivo distinto de la misma longitud
debe fallar con Size or SHA-256 mismatch. Para la opción de 1 MiB, pasa
1 como último argumento. En Firefox,
Save with progress entrega un Blob pequeño al navegador y muestra
Handed to browser. Check your downloads; disk saving is not confirmed.
Usa Direct download para el archivo de 256 MiB y luego verifica
también ese archivo en disco. Ni iniciar una descarga mediante un enlace ni crear una URL de objeto
confirman que se haya guardado.
Comparación del uso de memoria
| Método | Búferes de la aplicación | Finalización observable por esta aplicación |
|---|---|---|
response.blob() | Respuesta completa antes de entregarla | Blob creado |
| Streaming al archivo seleccionado | Espera la escritura de un fragmento antes de la siguiente lectura | Cierre correcto del flujo de escritura |
| Alternativa con Blob limitado | Hasta 50 MiB más las copias del Blob | Entrega al navegador |
| Enlace a archivo adjunto | Transferencia gestionada por el navegador | Usa la interfaz de descargas del navegador |
Para comparar el comportamiento de la aplicación, guarda las opciones de 256 MiB y 512 MiB e inspecciona el bucle de lectura y escritura. Nunca acumula fragmentos en la vía nativa. Las escrituras lentas retrasan la siguiente lectura de la aplicación; fetch y el navegador pueden seguir almacenando datos anticipadamente en búferes. Las mediciones del heap de JavaScript por sí solas no permiten establecer un tope de RAM para todo el navegador, una mejora de velocidad ni la durabilidad en el sistema de archivos después de un corte de energía.
Conclusión
Conserva la verificación de la suma de comprobación cuando reemplaces los datos de prueba por un endpoint de exportación real. Usa para ese archivo una suma de comprobación del publicador cuya fiabilidad hayas establecido de forma independiente, y mantén disponible un enlace a un archivo adjunto gestionado por el navegador allí donde no haya un selector nativo. Esta demostración realiza una descarga nueva; no reanuda una transferencia cancelada ni conserva el acceso a destinos anteriores.
