Streaming de arquivos grandes no React sem problemas de memória
Baixar arquivos grandes em aplicações React fica complicado quando os arquivos passam de algumas
centenas de megabytes. O padrão ingênuo fetch → blob → link.click() mantém o arquivo inteiro na
memória, o que é um caminho rápido para abas travando e usuários irritados. Felizmente, as APIs
modernas dos navegadores permitem fazer streaming de dados da rede direto para o disco do usuário
sem reter o arquivo inteiro no JavaScript. O streaming ainda precisa de buffers do navegador, da
rede e do sistema de arquivos; ele não tem consumo zero de memória.
Por que downloads tradicionais com blob falham com arquivos grandes
Um helper clássico de download se parece com isto:
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)
}
Os problemas aparecem assim que o arquivo fica maior que o orçamento de memória disponível do usuário:
- A resposta completa é materializada antes de salvar; o armazenamento de apoio e o pico de RAM dependem do navegador e podem crescer com o tamanho do arquivo.
- O buffering adiciona overhead de alocação e processamento, embora o próprio
blob()seja assíncrono. - Este helper não expõe nenhum feedback de progresso enquanto a resposta está sendo lida.
- As configurações de download do navegador controlam o destino e se o usuário recebe uma pergunta.
Fazer streaming de dados com as APIs fetch e streams
fetch() nos dá um ReadableStream em response.body. Em vez de acumular chunks em um array
(que novamente cresceria com o tamanho do arquivo), podemos canalizar cada chunk diretamente para um WritableStream.
Quando a File System Access API está disponível, esse writable aponta para o arquivo selecionado
pelo usuário no disco, mantendo o buffering da aplicação limitado em vez de proporcional ao tamanho
do arquivo.
Salve os helpers a seguir em downloads.ts em um projeto React com TypeScript. Se os seus tipos DOM não
declararem a API do seletor, instale as declarações dela. Inclua wicg-file-system-access se a sua
configuração do TypeScript restringir a lista types. Ative allowImportingTsExtensions e noEmit
para os imports .ts usados neste exemplo; o bundler cuida da saída em JavaScript:
npm install --save-dev @types/wicg-file-system-access
Chame streamToDisk diretamente de um handler de clique para que o seletor tenha ativação do usuário. Ele
propaga erros e cancelamentos para quem o chamou, aborta gravações incompletas e libera o seu
reader. O servidor precisa permitir a requisição via CORS quando estiver em outra origem.
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
}
}
Pontos principais:
- Nenhum
Blobenorme fica na memória; os chunks vão direto para o disco. - O progresso exige um
Content-Lengthpreciso que corresponda aos bytes decodificados do corpo. Sirva os downloads sem compressão de conteúdo para esse cálculo; caso contrário, mostre um progresso indeterminado. - O progresso fica abaixo de 100% até que o writable seja fechado com sucesso.
- O seletor de salvamento está disponível nos navegadores Chromium com suporte, mas não no Firefox nem no Safari.
Suporte dos navegadores em resumo
| Navegador | showSaveFilePicker() |
|---|---|
| Chrome para desktop | 86+ |
| Edge para desktop | 86+ |
| Firefox | Sem suporte |
| Safari | Sem suporte |
Estes são resultados específicos do seletor, com base nos dados de compatibilidade do MDN, verificados em setembro de 2026. O suporte ao origin-private file system não implica suporte a um seletor de salvamento. Sempre detecte o recurso em tempo de execução.
Para arquivos grandes em navegadores sem essa API, prefira um link de download normal para um
endpoint que retorne Content-Disposition: attachment. O navegador gerencia o download sem um array de
chunks em JavaScript. Use autenticação de sessão same-origin ou uma URL de download autorizada
quando um link não puder enviar os headers de autorização habituais da API. O atributo download
sozinho não é suficiente para URLs cross-origin arbitrárias.
Apenas para arquivos pequenos, um fallback com Blob pode ser conveniente. Ler em chunks ainda retém
o arquivo inteiro. Este helper impõe um limite de aplicação de 50 MiB tanto em relação a um tamanho
declarado quanto aos bytes efetivamente recebidos; reduza-o conforme os dispositivos do seu público.
A criação do Blob pode exigir temporariamente cópias adicionais, portanto isso não é um teto de
50 MiB para a RAM do navegador. Adicione isto ao final de 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()
}
}
O fallback é resolvido quando entrega o Blob ao navegador. O JavaScript não consegue confirmar que o
usuário o salvou em disco. Uma biblioteca como browser-fs-access pode simplificar a integração com o
navegador, mas o fallback com Blob dela tem a mesma limitação de buffering do arquivo inteiro.
Criar um hook React reutilizável
Salve este hook como useDownload.ts. Ele usa os helpers acima, evita downloads sobrepostos e
aborta o trabalho ativo quando o componente é desmontado. Um seletor ou uma transferência cancelada
não reporta sucesso.
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 }
}
Agora, usar o hook dentro de um componente é 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>
)
}
Segurança, permissões e tratamento de erros
A File System Access API é poderosa e, por isso, protegida por várias salvaguardas. Entendê-las é fundamental para uma boa experiência do usuário e um tratamento de erros robusto.
- Contexto seguro: a página precisa ser servida via HTTPS ou a partir de
localhost. Sewindow.isSecureContextforfalse,showSaveFilePicker()não estará disponível. Seu código deve verificar isso e, possivelmente, informar o usuário ou usar o fallback. - Gesto do usuário: o seletor de arquivos só pode ser aberto em resposta direta a uma interação do usuário, como um clique ou o pressionamento de uma tecla. Chamadas programáticas sem um gesto prévio do usuário vão falhar.
- Escopo da permissão: o acesso é concedido apenas ao arquivo selecionado pelo usuário. Sua aplicação não pode gravar em outros arquivos ou locais sem permissão explícita do usuário para cada caso.
- Persistência da permissão: não presuma que um handle armazenado mantém a permissão. Este helper abre o seletor a cada salvamento; fluxos que retêm handles devem consultar a permissão antes de reutilizá-los.
- Pastas restritas: os navegadores impedem o acesso a diretórios sensíveis do sistema. O seletor de arquivos os filtra, então os usuários não podem selecioná-los acidentalmente (ou maliciosamente).
- Tratamento de erros: é essencial envolver as chamadas a
showSaveFilePicker()e as operações de stream subsequentes em blocostry...catch.AbortError: este erro é lançado se o usuário fechar o seletor de arquivos (por exemplo, clicando em “Cancelar”). É um cenário comum e deve ser tratado com elegância, talvez redefinindo o estado da UI sem exibir uma mensagem de erro agressiva.- Outros erros: problemas de rede, limitações de espaço em disco ou comportamento inesperado da API também podem causar erros. Registre-os em log para depuração e forneça uma mensagem amigável ao usuário.
O helper streamToDisk acima trata essas falhas no caminho efetivamente usado pelo hook. Ele
aborta o writable em caso de falha e cancela e libera o reader da resposta em finally. Ele fecha
o writable somente depois que todos os bytes foram lidos. Cancelar durante o commit final no
sistema de arquivos não garante desfazer um salvamento que já foi concluído. Da mesma forma, o
fallback com Blob não consegue cancelar um download do navegador depois de entregá-lo.
Comparação do uso de memória
| Abordagem | Buffer da aplicação | Progresso |
|---|---|---|
response.blob() | Resposta completa antes de salvar; armazenamento dependente do navegador | Não exposto por este helper |
| Stream para File System Access | Um chunk por vez, mais buffers do navegador e do sistema de arquivos | Quando há um tamanho preciso disponível |
| Fallback limitado com Blob | Arquivo inteiro até 50 MiB, mais cópias temporárias | Quando há um tamanho preciso disponível |
| Link de download normal | Gerenciado pelo navegador, fora deste buffer JavaScript | UI de download do navegador |
Conclusão
Com streams de fetch() e a File System Access API, você permite que os usuários baixem assets
na casa dos gigabytes sem reter o arquivo completo no JavaScript. Use o fallback limitado com Blob
apenas para arquivos pequenos. Para downloads grandes fora de navegadores com seletor de
salvamento, ofereça um endpoint de download normal e deixe o navegador gerenciar a transferência.
Precisa de uma solução equivalente para uploads? Confira o nosso Robot que alimenta o serviço de uploads de arquivos. Ele se encaixa perfeitamente no mesmo fluxo de trabalho.
