Transmitir arquivos grandes no React sem problemas de memória
Para um download grande no React, aguarde cada gravação em um arquivo escolhido pelo usuário em vez de acumular a resposta em um Blob. Este guia monta uma aplicação React completa, serve localmente um fluxo binário conhecido de 256 MiB e verifica a contagem de bytes e o SHA-256 do arquivo salvo. O caminho de streaming limita os blocos retidos pela aplicação; os buffers do navegador, da rede e do sistema de arquivos ainda usam memória.
Por que downloads tradicionais com Blob falham com arquivos grandes
O conhecido padrão fetch → blob → link.click() aguarda a resposta completa antes de entregá-la ao
navegador. Response.blob()
lê o corpo até o fim. O navegador escolhe onde armazenar os dados do Blob, então isso não é
necessariamente uma única alocação gigante no heap do JavaScript, mas ainda materializa o arquivo
inteiro.
Use a File System Access API quando precisar de um seletor de destino e de progresso na aplicação em navegadores Chromium para desktop compatíveis. Para um arquivo grande no Firefox ou Safari, ofereça um link comum para um endpoint de anexo. O navegador gerencia essa transferência fora do seu array de blocos em JavaScript. Este exemplo também inclui uma alternativa com Blob deliberadamente limitada para arquivos pequenos. O objetivo aqui é salvar em novos destinos de download; armazenar e reabrir identificadores de arquivos locais faz parte de um fluxo de trabalho separado para manipulação persistente de arquivos.
Transmitir dados com as APIs fetch e streams
Use Bash, Node.js 24.15 ou mais recente da linha 24 LTS em manutenção e Corepack com Yarn 4.12.0. Instale o Corepack separadamente se ele não estiver incluído na sua distribuição do Node. O caminho com seletor nativo foi testado no Linux com Chromium 145; os caminhos com link e Blob foram testados no Firefox. Use um novo diretório fora de um projeto ou espaço de trabalho existente. Este bloco de criação com verificações de segurança confere os pré-requisitos e a configuração de pacotes dos diretórios superiores antes de gravar qualquer coisa:
(
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
Um diretório react-stream-demo existente é recusado. Mantenha seus arquivos e escolha
outro diretório pai vazio em vez de excluí-lo. Se a instalação falhar depois, tente instalá-lo
novamente neste novo projeto; nenhuma das etapas a seguir exige recriá-lo.
Salve estes arquivos no novo diretório. package.json fixa as versões das
dependências da demonstração:
{
"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"
}
}
Salve tsconfig.json. Esta configuração está completa, sem
extends herdado:
{
"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"]
}
Salve .yarnrc.yml para que a instalação, as compilações e a inicialização usem o
mesmo vinculador:
nodeLinker: node-modules
Salve index.html; o Vite empacota o ponto de entrada 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>
Resumo do suporte dos navegadores
| Navegador | Seletor de destino | Caminho para arquivos grandes aqui |
|---|---|---|
| Chrome e Edge para desktop | Detectar showSaveFilePicker | Transmitir para o arquivo selecionado |
| Firefox e Safari | Sem showSaveFilePicker | Link de anexo |
Consulte a documentação do seletor na MDN para verificar a compatibilidade atual. O suporte ao sistema de arquivos privado da origem não implica a presença de um seletor de salvamento. O seletor precisa de um contexto seguro e de um gesto do usuário; HTTP em loopback é adequado para esta demonstração local. O Node 24 está em LTS com manutenção ativa; sua execução nativa de TypeScript é suficiente para o servidor e o verificador, enquanto o Vite compila TSX para o navegador.
Salve downloads.ts. Ambos os caminhos usam o mesmo laço de leitura. Ele aguarda o
consumidor antes de solicitar outro bloco, cancela a resposta em caso de falha e libera o leitor. Um
Content-Length ausente ou referente a dados comprimidos produz progresso apenas em
bytes: fetch expõe os bytes decodificados do corpo, então o
tamanho transmitido pela rede com compressão não é um 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' }
}
O caminho com Blob limita os bytes reais decodificados a 50 MiB, incluindo respostas sem tamanho informado. A criação do Blob pode exigir cópias extras, então esse é um limite de entrada da aplicação, não um teto para a RAM do navegador. O caminho nativo só retorna após o fechamento do fluxo de gravação. Ele não valida o conteúdo do servidor; a etapa de soma de verificação abaixo faz isso de forma independente.
Criar um hook reutilizável no React
Salve useDownload.ts. Chame start diretamente de um manipulador
de clique: abrir o seletor antes de aguardar qualquer operação de rede preserva a ativação do usuário.
A proteção de estado ocupado impede inícios simultâneos, e a desmontagem aborta o trabalho ativo para
que uma transferência antiga não possa atualizar um 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 }
}
Salve main.tsx. A opção de 256 MiB exercita o caminho nativo; selecione 1 MiB para
testar a alternativa com Blob no Firefox. O link comum continua disponível para arquivos 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 />)
Servir um fluxo binário conhecido
Salve server.ts. Ele serve a aplicação compilada e um anexo sem compressão na mesma
origem, então este exemplo local não precisa de CORS nem de autenticação. Cada bloco de 64 KiB contém
um padrão binário repetido, incluindo bytes zero. O pipe do Node respeita a contrapressão da resposta;
a desconexão de um cliente destrói o produtor. /manifest?mib=256 fornece os bytes esperados
e o SHA-256 sem enviar o arquivo em si.
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 })
Instale localmente usando o vinculador node-modules do Yarn, depois verifique os
tipos, compile e inicie o servidor:
corepack yarn install
corepack yarn build && corepack yarn start
Abra http://127.0.0.1:8787, clique em Save with progress e
escolha um novo caminho chamado fixture-256.bin na caixa de diálogo nativa de
salvamento. Aceite qualquer solicitação de permissão de gravação do navegador. Aguarde o status que
começa com Saved.
Deixe o servidor em execução para a verificação. Ctrl+C o encerra; se a porta 8787 estiver ocupada, use
PORT=8788 corepack yarn start e a origem correspondente no verificador.
Segurança, permissões e tratamento de erros
O seletor de salvamento exige ativação do usuário. Mantenha-o no fluxo do clique; não aguarde uma requisição de API antes. Em um site em produção, use HTTPS. Escolha caminhos de saída novos e descartáveis. Aprovar a substituição coloca o arquivo existente em risco antes da conclusão bem-sucedida: na caixa de diálogo GTK do Linux testada, a rejeição do fechamento final deixou o destino aprovado vazio. Abortar uma gravação que falhou não garante a restauração desse arquivo. Um salvamento cancelado em um novo caminho também pode deixar um arquivo vazio. Cancelar durante a confirmação final no sistema de arquivos não garante desfazer bytes já confirmados.
Experimente cancelar o seletor, cancelar após o início do progresso e salvar novamente. O cancelamento e qualquer erro de HTTP, leitura, gravação ou fechamento final nunca devem produzir o status de arquivo salvo. O hook limpa o resultado anterior a cada início. Ele abre um novo seletor a cada vez e não armazena identificadores nem estado de permissão, inclusive entre recarregamentos ou reinícios do navegador. Fluxos de trabalho com identificadores armazenados precisam de suas próprias verificações de permissão.
Na sua própria API, retorne um status significativo em caso de falha. Sem um tamanho ou uma soma de
verificação confiável, não é possível distinguir uma resposta encerrada sem erros, mas antes da hora,
de um arquivo válido mais curto.
Para fetch entre origens, configure CORS e exponha os cabeçalhos de progresso que você usar. Um link
direto não pode adicionar um cabeçalho Authorization personalizado; use autenticação por sessão ou
uma URL de download autorizada.
Content-Disposition: attachment é importante para downloads entre origens;
o atributo download sozinho não é suficiente.
Verificar o arquivo real no disco
Salve verify.ts. Ele lê o arquivo do disco em fluxo e compara tanto o tamanho
quanto o hash com o manifesto local dos dados de teste. Ele verifica o caminho que você fornece,
não outro arquivo gerado nem o status do 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 })
Em um segundo terminal, entre no projeto e execute o verificador com o caminho real do arquivo salvo:
node verify.ts "/absolute/path/to/fixture-256.bin" 256
O resultado esperado é Verified 268435456 bytes; SHA-256 …. Um arquivo diferente de mesmo tamanho deve
falhar com Size or SHA-256 mismatch. Para a opção de 1 MiB, passe 1
como argumento final. No Firefox,
Save with progress entrega um Blob pequeno ao navegador e
exibe Handed to browser. Check your downloads; disk saving is not confirmed.
Use Direct download para o arquivo de 256 MiB e depois
verifique esse arquivo no disco também. Nem iniciar um download por link nem criar uma URL de objeto
confirma o salvamento.
Comparação do uso de memória
| Abordagem | Buffer na aplicação | Conclusão observável por esta aplicação |
|---|---|---|
response.blob() | Resposta completa antes da entrega | Blob criado |
| Transmissão para o arquivo selecionado | Aguardar a gravação de um bloco antes da próxima leitura | Fluxo de gravação fechado com sucesso |
| Alternativa com Blob limitado | Até 50 MiB mais cópias do Blob | Entrega ao navegador |
| Link de anexo | Transferência gerenciada pelo navegador | Usar a interface de downloads do navegador |
Para comparar o comportamento da aplicação, salve as opções de 256 MiB e 512 MiB e inspecione o laço de leitura e gravação. Ele nunca acumula blocos no caminho nativo. Gravações lentas atrasam a próxima leitura da aplicação; fetch e o navegador ainda podem armazenar dados antecipadamente em buffers. Medições apenas do heap do JavaScript não podem estabelecer um teto para a RAM do navegador inteiro, um ganho de velocidade nem a durabilidade do sistema de arquivos após uma queda de energia.
Conclusão
Mantenha a validação da soma de verificação ao substituir os dados de teste por um endpoint real de exportação. Use uma soma de verificação do publicador cuja confiabilidade tenha sido estabelecida de forma independente para esse arquivo e mantenha um link de anexo gerenciado pelo navegador disponível onde o seletor nativo estiver ausente. Esta demonstração faz um novo download; ela não retoma uma transferência cancelada nem mantém o acesso a destinos anteriores.
