Download resumível no browser com Fetch Streams, Range, IndexedDB e AbortSignal
Este artigo explica como implementar downloads resumíveis no navegador usando as APIs modernas: Fetch com Streams, cabeçalho Range, IndexedDB para armazenamento intermitente e AbortSignal para controle e cancelamento. O foco é prático: padrões, armadilhas e código funcional que você pode adaptar para produção.
Quando e por que usar
Downloads resumíveis são necessários quando arquivos são grandes, redes são instáveis ou quando o usuário precisa pausar/retomar sem reiniciar do zero. O objetivo é minimizar retransmissões e melhorar UX em conexões móveis ou quando o servidor tem limite de banda.
Casos de uso típicos
Use resumable downloads em: transferências de mídia (vídeo/áudio), pacotes de atualização, imagens de alta resolução e cenários PWA que precisam trabalhar offline. O padrão reduz custo de dados e tempo de espera.
Benefícios técnicos
As vantagens técnicas incluem: menor consumo de banda, tolerância a falhas, integração simples com IndexedDB para persistência e controle do ciclo com AbortSignal. Além disso, usar Streams permite processar dados conforme chegam, sem alocar grandes buffers na memória.
Comparação com alternativas
Antes de mergulhar na implementação, compare com abordagens alternativas para ver quando esta solução é a correta.
Alternativa: Blob em memória
Guardar o arquivo inteiro em memória (por exemplo concatenar arrays) funciona para arquivos pequenos, mas para arquivos maiores é inviável: você consome RAM do cliente e aumenta o risco de crashes em dispositivos limitados.
Alternativa: Service Worker sem IndexedDB
Service Workers podem interceptar requisições e usar cache, mas o Cache Storage não foi projetado para escrita parcial e concorrente. IndexedDB é mais adequada para armazenar chunks e metadados, permitindo resumos e reassemblagem.
Escolha IndexedDB para persistência parcial; use Streams para processar dados incrementalmente; use Range quando o servidor suportar para reaproveitar bytes já recebidos.
Implementação progressiva
A implementação se divide em camadas: (1) detectar progresso local, (2) solicitar ranges, (3) consumir stream e persistir em IndexedDB, (4) permitir pause/resume com AbortSignal. Iremos construir exemplos incrementais.
1. Detectar o que já foi baixado
Você precisa de um registro local por arquivo: total esperado, bytes já recebidos e referências para blobs/chunks armazenados. Em IndexedDB, mantenha um objectStore com chave composta por fileId e chunkIndex ou por byteRange. Isso facilita buscas e reconstrução.
2. Requisição inicial e verificação de suporte a Range
Faça uma requisição HEAD (ou GET com Range 0-0) para confirmar suporte a Range e obter Content-Length. Se o servidor não suportar, degrade para download simples.
Exemplo: checar suporte a Range e Content-Length.
/* Faz HEAD para descobrir Content-Length e suporte a Range */
async function probeServer(url) {
const res = await fetch(url, { method: 'HEAD' });
const length = Number(res.headers.get('content-length')) || null;
const acceptRanges = res.headers.get('accept-ranges');
return { length, acceptRanges: acceptRanges === 'bytes' };
}
O código acima retorna o tamanho do recurso e se o servidor declara suporte a ranges. Se acceptRanges não for 'bytes', ainda é possível tentar GET com Range e verificar a resposta 206.
3. Pedido com Range e consumo via ReadableStream
Uma vez que saibamos quantos bytes faltam, solicitamos um intervalo e consumimos o stream enquanto gravamos no IndexedDB. O padrão é ler o Body como stream e escrever chunks assim que chegam.
Exemplo funcional: solicitar um range e gravar em IndexedDB enquanto lê o stream. Este exemplo pressupõe um objectStore 'chunks' com chaves 'fileId:offset'.
/* Faz download de um range e grava chunks no IndexedDB */
async function downloadRangeToIndexedDB(url, fileId, start, end, db, signal) {
const headers = { Range: `bytes=${start}-${end}` };
const res = await fetch(url, { headers, signal });
if (res.status !== 206 && res.status !== 200) throw new Error('Range not satisfiable or server error');
const reader = res.body.getReader();
let offset = start;
while (true) {
const { done, value } = await reader.read();
if (done) break;
// value é Uint8Array
await saveChunk(db, fileId, offset, value);
offset += value.byteLength;
}
}
async function saveChunk(db, fileId, offset, uint8arr) {
const tx = db.transaction('chunks', 'readwrite');
const store = tx.objectStore('chunks');
await store.put({ fileId, offset, data: uint8arr });
await tx.complete;
}
Após a leitura, os chunks estarão persistidos ordenados por offset e podem ser montados no final. O uso de getReader() permite pausar usando signal.abort() (ver seção AbortSignal).
4. Reconstituir arquivo a partir dos chunks
Depois de baixar todos os chunks, leia os registros ordenados, concatene ArrayBuffers e crie um Blob. Se o total for grande, use Blob streaming APIs ou criar um stream acoplado para evitar copiar grandes buffers de uma vez.
Exemplo de montagem simples (concatenando):
/* Reconstroi o arquivo a partir de chunks no IndexedDB */
async function assembleFileFromDB(db, fileId, totalBytes) {
const tx = db.transaction('chunks', 'readonly');
const store = tx.objectStore('chunks');
const chunks = await store.index('fileId').getAll(IDBKeyRange.only(fileId));
// ordenar por offset
chunks.sort((a, b) => a.offset - b.offset);
const parts = chunks.map(c => c.data.buffer || c.data);
return new Blob(parts);
}
O resultado é um Blob pronto para ser baixado via URL.createObjectURL ou enviado para File System Access API.
Integração com ecossistema real
Quando for integrar com backend, service workers, UI e bibliotecas, considere contratos claros de metadados, timeouts, retries exponenciais e limites de concorrência.
Backend: suporte Range e cabeçalhos consistentes
O servidor deve suportar Range requests e responder com 206 Partial Content quando Range é solicitado. Além disso, Content-Length e ETag/Last-Modified ajudam a validar se o recurso mudou entre tentativas.
Exemplo mínimo em Node/Express para suportar Range (apenas para referência):
/* Server Node.js - resposta parcial (pseudocódigo funcional) */
const fs = require('fs');
const express = require('express');
const app = express();
app.get('/file', (req, res) => {
const path = '/path/to/file.bin';
const stat = fs.statSync(path);
const range = req.headers.range;
if (!range) {
res.setHeader('Content-Length', stat.size);
fs.createReadStream(path).pipe(res);
return;
}
const [startStr, endStr] = range.replace(/bytes=/, '').split('-');
const start = Number(startStr);
const end = endStr ? Number(endStr) : stat.size - 1;
res.statusCode = 206;
res.setHeader('Content-Range', `bytes ${start}-${end}/${stat.size}`);
res.setHeader('Accept-Ranges', 'bytes');
res.setHeader('Content-Length', end - start + 1);
fs.createReadStream(path, { start, end }).pipe(res);
});
Isso garante compatibilidade com o cliente que pede Intervalos. Produção precisa de streaming eficiente e controle de security headers.
Client-side: service worker e UI
Service Workers podem interceptar requisições e rotear downloads, mas delegue ao cliente a lógica de controle de chunking/persistência. Use SW para políticas de cache e fallback offline.
Para a UI: exiba progresso por byte recebido, permita pausar/resumir e mostre estados claros (baixando, pausado, completo, erro). Persistência em IndexedDB permite retomar após refresh ou crash do navegador.
Erros comuns, armadilhas e dicas práticas
Existem várias pegadinhas ao implementar resumable download. Listo as mais frequentes e como corrigi-las.
Problema: servidor sem suporte a Range
Nem todo servidor retorna accept-ranges. Faça um probe e se não suportar, degrade graciosamente: baixe tudo de uma vez ou faça chunking no servidor. Detecte mudanças de ETag/Last-Modified para invalidar progresso.
Problema: índices e ordenação no IndexedDB
Se você armazenar chunks sem índice adequado, a reordenação no assemble pode falhar. Sempre use um índice por fileId e offset e garanta atomicidade das transações onde necessário.
Outras dicas práticas:
- Use tamanho de chunk adequado: entre 256KB e 2MB costuma equilibrar overhead e espaço.
- Controle concorrência: não faça mais de 3-5 ranges simultâneos para evitar saturar rede/CPU.
- Grave metadados: totalBytes, downloadedBytes, etag, lastModified, chunkSize e status para recuperação.
- Limpeza: remova chunks antigos quando o usuário descartar o download.
Dica: ao montar o Blob, evite concatenar grandes ArrayBuffers em memória. Prefira construir um array de partes (Uint8Array/ArrayBuffer/Blob) e criar um Blob direto.
AbortSignal: pausar, cancelar e retomar
AbortSignal é a API recomendada para controlar fetchs e streams. Ao criar o fetch, passe um AbortController.signal. Para pausar, chame abort() — mas atenção: abort termina a requisição. Para retomar, inicie um novo fetch com Range começando no último byte gravado.
Exemplo de controle com AbortController
O exemplo abaixo mostra como iniciar um download e dar suporte a pausar através do AbortController. Ao abortar, a stream interrompe e podemos salvar estado para retomar.
/* Controlador simples para iniciar/pause/resume */
class DownloadController {
constructor(url, db, fileId) {
this.url = url; this.db = db; this.fileId = fileId; this.controller = null;
}
async start(startByte, endByte) {
this.controller = new AbortController();
try {
await downloadRangeToIndexedDB(this.url, this.fileId, startByte, endByte, this.db, this.controller.signal);
} catch (err) {
if (err.name === 'AbortError') {
console.log('download pausado');
} else throw err;
}
}
pause() { if (this.controller) this.controller.abort(); }
}
Ao chamar pause(), a requisição ativa é abortada. Para retomar, crie uma nova instância ou reinicie start() usando o offset salvo no IndexedDB.
Erro comum: esquecer tratar AbortError
Se você não capturar AbortError, seu fluxo de erros pode interpretar o cancelamento como falha irreversível. Trate aborts separadamente e não marque o download como 'erro'.
Checklist de qualidade para produção
Antes de colocar em produção, verifique os pontos abaixo. Eles garantem robustez e boa experiência.
- Probe do servidor: HEAD/Range para Content-Length e Accept-Ranges
- Metadados: guarde etag/last-modified para detectar alterações
- Consistência: transações IndexedDB atômicas para cada chunk
- Concorrência: limite de requests simultâneos e backoff exponencial
- Resiliência: retries com jitter e tratamento claro de abortos
- Limpeza: políticas de retenção e remoção de chunks órfãos
- UI: feedback de progresso e estados (pausado, resumindo, erro)
- Segurança: valide URLs, evite XSS ao gerar object URLs
- Testes: simule redes lentas/instáveis e restaurações de sessão
- Logs/telemetria: métricas de sucesso, tempo e número de resumes
Além desse checklist, instrumente a solução para capturar falhas comuns e padrões de retry que levam a desperdício de banda.
Conclusão prática
Implementar downloads resumíveis no browser exige coordenação entre cliente e servidor: Range no servidor, Streams e AbortSignal no cliente e IndexedDB para persistência. O padrão reduz tráfego, melhora UX e permite pausar/resumir com segurança.
Comece com a estratégia de probe, confirme suporte a Range, adote chunking e salve cada pedaço em IndexedDB com metadados. Use AbortController para controle e sempre trate AbortError separadamente. Finalmente, implemente políticas de retry, limites de concorrência e limpeza de dados.
Este conjunto de práticas e padrões é aplicável a aplicações PWA, gestores de downloads e clientes ricos que precisam de resiliência em redes móveis. Adaptando os exemplos acima você terá uma base sólida para produção.
Implementações futuras podem adicionar checksums por chunk, deduplicação e integração com File System Access API para gravar direto no disco onde suportado.
