Como Criar um Arquivo para Download no Navegador
Criar um arquivo para download no navegador combina quatro APIs do navegador: um Blob para os dados, URL.createObjectURL() para uma referência em memória, uma âncora com o atributo download para acionar o salvamento, e URL.revokeObjectURL() para liberar a referência posteriormente. O padrão completo tem menos de dez linhas:
function downloadBlob(data, filename, type) {
const blob = new Blob([data], { type });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
downloadBlob(JSON.stringify({ ok: true }), 'config.json', 'application/json');
Este é o padrão canônico, e cobre o caso mais comum de salvar um CSV gerado dinamicamente, uma configuração JSON ou uma imagem. Porém, o caminho feliz esconde falhas reais: o atributo download se desativando silenciosamente em URLs de origens cruzadas, o iOS Safari abrindo downloads em uma nova aba, URLs de objetos vazando entre re-renderizações de componentes, e o Excel corrompendo CSVs em UTF-8. Este artigo aborda o padrão funcional, a API moderna File System Access, streaming para arquivos grandes demais para caber na memória, e as particularidades de plataforma que quebram downloads em produção.
Principais Conclusões
- O download canônico no lado do cliente é
new Blob([data], { type })→URL.createObjectURL()→ uma âncora com o atributodownload→click()→URL.revokeObjectURL(). - O tempo de vida de uma URL de objeto está vinculado ao documento que a criou, portanto revogue-a em uma função de limpeza do framework (
useEffectreturn, VueonUnmounted) em vez de depender de uma relação de temporização não verificada coma.click(). showSaveFilePicker()é a única forma nativa do navegador de permitir que o usuário escolha um local para salvar, mas é experimental e exclusiva do Chromium, portanto detecte o recurso e forneça um fallback para o padrão de âncora.- O Excel abre um CSV em UTF-8 corretamente somente quando o arquivo possui um BOM, portanto adicione
\uFEFFao início da string. - O iOS Safari historicamente tem suporte pouco confiável para downloads com o atributo
download, razão pela qual os downloads frequentemente abrem em uma nova aba em vez de serem salvos.
O padrão canônico: fazer download de um arquivo com JavaScript no navegador
Discover how at OpenReplay.com.
A maneira confiável de fazer download de um arquivo gerado com JavaScript é construir um Blob, criar uma URL de objeto com URL.createObjectURL(), atribuí-la ao href de uma âncora, definir o atributo download com o nome do arquivo, clicar na âncora programaticamente e, em seguida, liberar a URL com URL.revokeObjectURL(). O construtor do Blob permite definir o tipo MIME independentemente dos dados, e a URL de objeto é uma referência curta (blob:https://…) para a qual a âncora pode navegar.
Evite data URIs como padrão. Data URIs são a escolha errada para geração de arquivos no lado do cliente: o Base64 codifica cada 3 bytes como 4 caracteres, aumentando o tamanho do payload em cerca de um terço antes do preenchimento (conforme a RFC 4648), e toda a string codificada deve caber na memória como valor de um atributo DOM. Os limites atuais de tamanho para URLs data: são 512 MB no Chromium e Firefox, e 2048 MB no Safari/WebKit (referência MDN para URLs data:) — mas o custo de codificação e o gasto de memória com a string fazem do Blob a melhor escolha padrão muito antes de atingir esses limites.
A âncora não precisa estar anexada ao DOM para que click() funcione nos navegadores atuais, o que mantém o utilitário autocontido:
// Works in Chrome 14+, Firefox 20+ (download attribute, HTML5).
// caniuse: https://caniuse.com/download
function downloadBlob(data, filename, type = 'application/octet-stream') {
const blob = new Blob([data], { type });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a); // appended for the broadest compatibility
a.click();
a.remove();
URL.revokeObjectURL(url);
}
const csv = 'name,role\nAda,Engineer\nGrace,Architect';
downloadBlob(csv, 'team.csv', 'text/csv;charset=utf-8;');
A chamada URL.revokeObjectURL(url) é mais importante do que a maioria dos exemplos sugere. O tempo de vida de uma URL de objeto está vinculado ao documento que a criou, portanto ela persiste na memória até que você a revogue ou o documento seja descarregado. Em um script descartável de uso único, isso é inofensivo; em um aplicativo baseado em componentes onde o utilitário é executado a cada clique de botão, as URLs não liberadas se acumulam. A revogação síncrona acima é segura aqui porque nada nesta função sobrevive após a chamada — mas, como a seção de frameworks demonstra, esse posicionamento está errado dentro de um componente.
O atributo HTML download e sua falha silenciosa em origens cruzadas
Para um arquivo que já está hospedado na sua própria origem, você não precisa de JavaScript — basta adicionar o atributo download a uma âncora simples:
<a href="/reports/q3.pdf" download="q3-report.pdf">Baixar relatório</a>
O atributo download instrui o navegador a salvar o recurso vinculado em vez de navegar até ele. Foi adicionado no HTML5 e é suportado no Chrome 14+ e Firefox 20+ (caniuse: download attribute). Ele também aceita URLs blob: e data:, razão pela qual se combina com o padrão canônico acima.
O modo de falha ocorre com URLs de origens cruzadas. Para downloads de origens cruzadas, o atributo download é respeitado somente quando a resposta também inclui Content-Disposition: attachment; sem ele, o navegador ignora o atributo e o link não é um download forçado confiável (conforme o WHATWG HTML Standard, downloading resources). Esta é uma fonte frequente de confusão: o mesmo markup que faz download de um arquivo da mesma origem irá navegar ou renderizar um arquivo servido por um CDN em uma origem diferente. Se você controla o servidor, defina o cabeçalho lá. Se não controla, busque o arquivo e re-sirva-o como um blob da sua própria origem:
// Re-serve a cross-origin file as a same-origin blob so `download` is honored.
async function downloadCrossOrigin(remoteUrl, filename) {
const res = await fetch(remoteUrl); // requires CORS to permit the fetch
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
API File System Access: permitindo que o usuário escolha o local de salvamento
A showSaveFilePicker() da File System Access API é a única forma nativa do navegador de permitir que o usuário escolha um local e nome de arquivo antes de salvar. Ela abre o diálogo de salvamento do sistema operacional, retorna um identificador de arquivo e permite escrever por meio de um FileSystemWritableFileStream. O MDN a classifica como “disponibilidade limitada” e “experimental”; foi implementada nos navegadores da família Chromium a partir da versão 86 (Chrome Status) e não é suportada no Firefox ou Safari, o que torna obrigatório detectar o recurso e fornecer um fallback para o padrão de âncora em código de produção.
showSaveFilePicker() lança uma DOMException com o nome AbortError se o usuário fechar o seletor, portanto trate esse caso explicitamente em vez de considerá-lo uma falha:
async function saveFile(data, filename, type = 'application/octet-stream') {
// Feature-detect. Note: the property can exist but throw in some
// sandboxed iframe contexts, so the call itself is still guarded.
if ('showSaveFilePicker' in window) {
try {
const handle = await window.showSaveFilePicker({
suggestedName: filename,
types: [{ description: 'File', accept: { [type]: ['.' + filename.split('.').pop()] } }],
});
const writable = await handle.createWritable();
await writable.write(new Blob([data], { type }));
await writable.close();
return;
} catch (err) {
if (err.name === 'AbortError') return; // user cancelled, not an error
// fall through to the anchor fallback on any other failure
}
}
// Fallback: anchor + object URL (no location choice; browser default).
downloadBlob(data, filename, type);
}
A verificação 'showSaveFilePicker' in window é a porta de entrada correta, mas não é infalível: em alguns contextos de iframe com sandbox, a propriedade existe mas lança uma exceção ao ser chamada, razão pela qual o try/catch envolve a invocação real e não apenas a detecção.
Streaming de arquivos grandes que excedem a memória disponível
O streaming contorna o limite de memória do Blob gravando chunks diretamente no disco em vez de armazenar o payload completo na RAM. Não há um limite fixo em bytes para esse teto — ele depende do dispositivo, do sistema operacional e da heap disponível do navegador. Duas abordagens de streaming evitam o armazenamento em buffer do arquivo completo.
No Chromium, escreva incrementalmente por meio do FileSystemWritableFileStream retornado por showSaveFilePicker(). Cada write() adiciona um chunk ao stream gravável, de modo que o aplicativo não precisa manter o arquivo inteiro na memória de uma só vez:
// Chromium 86+ only. Writes in chunks; never holds the full file in memory.
async function streamToDisk(filename, chunks) {
const handle = await window.showSaveFilePicker({ suggestedName: filename });
const writable = await handle.createWritable();
for await (const chunk of chunks) {
await writable.write(chunk); // chunk: string | ArrayBuffer | Blob
}
await writable.close();
}
Para navegadores sem a File System Access API, o StreamSaver.js é a alternativa multiplataforma. Conforme seu README, o StreamSaver.js salva arquivos grandes gerados no lado do cliente criando um stream gravável e emulando um download orientado pelo servidor usando cabeçalhos de resposta e um service worker, de modo que os dados são gravados no disco conforme o streaming ocorre, em vez de serem montados em um único Blob. Ele é construído sobre o WHATWG Streams Standard:
import streamSaver from 'streamsaver';
// Emulates a server download via a service worker; data streams to disk.
function streamWithStreamSaver(filename, readableStream) {
const fileStream = streamSaver.createWriteStream(filename);
// readableStream: a WHATWG ReadableStream of Uint8Array chunks
return readableStream.pipeTo(fileStream);
}
Recorra ao streaming quando a saída for genuinamente grande — exportações de centenas de megabytes, vídeo gerado ou um feed de dados de longa duração. Para um CSV de alguns milhares de linhas, o padrão canônico com Blob é mais simples e suficiente.
Particularidades do iOS Safari e como contorná-las
O iOS Safari historicamente tem suporte pouco confiável para arquivos gerados e baixados via atributo download (WebKit bug 167341), portanto um arquivo gerado frequentemente abre em uma nova aba ou é renderizado inline em vez de ser salvo — tornando o iOS a plataforma onde o código de download mais frequentemente falha em produção. O README do FileSaver.js documenta a consequência prática: no iOS, saveAs() deve ser executado dentro de uma interação do usuário, como onClick, e setTimeout impedirá que seja acionado; devido às restrições do iOS, saveAs() pode abrir uma nova janela em vez de fazer o download (FileSaver.js README).
As soluções concretas:
- Mantenha o download síncrono com o gesto do usuário. Acione o clique diretamente no manipulador de eventos, não após um
awaitousetTimeoutque o desvincula do gesto. Se você precisar fazer trabalho assíncrono antes, busque e prepare os dados antes do clique do usuário e, em seguida, acione o salvamento de forma síncrona. - Ofereça ao usuário um fallback explícito. Quando uma nova aba abrir com o arquivo renderizado inline, exiba uma dica “toque em Compartilhar → Salvar nos Arquivos”, pois o usuário deverá concluir o salvamento manualmente.
- Prefira
text/plainou um tipo visualizável para exportações de texto no iOS, aceitando que o arquivo possa abrir em um visualizador que o usuário então salva, em vez de tentar forçar um download que a plataforma não oferece de forma confiável.
Essas falhas são invisíveis para logs de servidor e analytics, pois nenhuma requisição chega ao seu backend e nenhuma exceção é lançada. Gravações de sessão em produção de fluxos de download frequentemente mostram usuários clicando no gatilho repetidamente porque nenhum feedback visível foi acionado — no iOS isso frequentemente aparece como uma nova aba abrindo e o usuário a fechando imediatamente, retornando confuso. A reprodução de sessão é uma das poucas técnicas que expõe essa classe de falhas silenciosas, vinculadas a gestos e plataformas.
// iOS-aware: do async work first, then save synchronously inside the gesture.
async function prepareThenDownload(button, getData, filename, type) {
const data = await getData(); // network/CPU work happens before the click path
button.onclick = () => {
// synchronous with the user gesture — no setTimeout, no await here
downloadBlob(data, filename, type);
};
}
Armadilhas com tipos MIME: exibição inline, CSV no Excel e quebras de linha
O tipo MIME que você passa ao construtor do Blob ajuda o navegador a determinar como interpretar o arquivo, e definí-lo incorretamente é uma causa comum de conteúdo baixado abrindo de forma inesperada ou sendo tratado incorretamente. Defina um tipo que corresponda aos dados e combine-o com o atributo download para que o navegador trate o resultado como um anexo.
O bug de CSV mais relatado é o de caracteres acentuados aparecendo como lixo quando o arquivo é aberto no Excel. A correção é um byte-order mark. O Excel abre um arquivo CSV em UTF-8 corretamente quando o arquivo é salvo com um BOM, portanto adicione-o ao início da string (Suporte Microsoft: abrindo CSV UTF-8 no Excel):
// Without BOM: Excel may show garbled characters for accented text (é, ü, ñ).
// With BOM: Excel opens the UTF-8 file correctly.
function downloadCSV(csvString, filename = 'export.csv') {
downloadBlob('\uFEFF' + csvString, filename, 'text/csv;charset=utf-8;');
}
Dois detalhes adicionais da RFC 4180, que registra text/csv como o tipo MIME para CSV: a especificação define CRLF (\r\n) como delimitador de registro, portanto prefira \r\n em vez de \n entre linhas para máxima compatibilidade com planilhas, e campos que contenham vírgulas, aspas ou quebras de linha devem ser envolvidos em aspas duplas com aspas internas duplicadas.
Evite usar application/octet-stream como um mecanismo universal para forçar downloads. É o tipo registrado pela IANA para dados binários arbitrários, mas não é um mecanismo confiável para forçar downloads — o README do FileSaver.js observa que usar application/octet-stream para forçar downloads pode causar problemas no Safari. Use o tipo correto e específico e dependa do atributo download (ou Content-Disposition) para impor o salvamento.
Armadilhas em frameworks: SSR, ciclo de vida de URLs de objetos e vazamentos de memória
Dois bugs no ciclo de vida de componentes quebram o padrão canônico de download no React, Vue e Svelte: chamar APIs do DOM durante a renderização no lado do servidor (onde document é indefinido), e revogar URLs de objetos no momento errado do ciclo de vida do componente. Ambos têm a mesma causa raiz — componentes são renderizados no servidor e re-renderizados no cliente, mas o padrão vanilla assume um único documento e uma única execução.
Proteja-se contra SSR. No Next.js, Nuxt ou SvelteKit, o código do componente pode ser executado onde document é indefinido; chamar document.createElement lá lança uma exceção. Proteja qualquer utilitário de download com uma verificação em tempo de execução:
function triggerDownload(data, filename, type) {
if (typeof document === 'undefined') return; // SSR guard
downloadBlob(data, filename, type);
}
Revogue URLs de objetos na limpeza, não imediatamente. O URL.revokeObjectURL(url) síncrono do padrão canônico é adequado para um utilitário de uso único que termina antes de qualquer outra coisa ser executada. Mas se você armazenar uma URL de blob no estado para usar como href ou src, revogá-la cedo demais quebra o download, e nunca revogá-la vaza memória entre re-renderizações. A regra verificada: o tempo de vida de uma URL de objeto está vinculado ao documento que a criou, portanto revogue-a assim que não for mais necessária; em frameworks baseados em componentes, armazene a URL de blob no estado e revogue-a em uma função de limpeza em vez de depender de uma relação de temporização não verificada com a.click().
import { useEffect, useState } from 'react';
function DownloadLink({ data, filename, type = 'application/json' }) {
const [url, setUrl] = useState(null);
useEffect(() => {
const blob = new Blob([data], { type });
const objectUrl = URL.createObjectURL(blob);
setUrl(objectUrl);
// Cleanup runs on unmount and before the effect re-runs:
return () => URL.revokeObjectURL(objectUrl);
}, [data, type]);
// ANTI-PATTERN — do NOT revoke here:
// <a href={url} download={filename} onClick={() => URL.revokeObjectURL(url)}>
// Revoking inside the click can release the URL before the download starts.
return url ? <a href={url} download={filename}>Download</a> : null;
}
O equivalente no Vue cria a URL em onMounted (ou em um watch) e a revoga em onUnmounted; o Svelte usa onDestroy. Nos três, o vazamento que ocorre é uma nova URL de objeto criada a cada re-renderização ou clique de botão sem uma revogação correspondente — a memória cresce durante todo o tempo de vida do documento.
Para navegadores legados que não possuem o atributo download, o FileSaver.js continua sendo um polyfill de uma linha — mas em alvos modernos, o padrão de plataforma acima o substitui.
Conclusão
A sequência Blob → URL de objeto → âncora → download → revogar cobre a grande maioria do trabalho de download no lado do cliente, com showSaveFilePicker() como a alternativa exclusiva do Chromium quando os usuários precisam escolher um local, e o streaming como a saída de emergência quando os arquivos excedem a memória. Os bugs que chegam à produção raramente estão no caminho feliz — são o atributo de origem cruzada que navega silenciosamente, o CSV que precisa de um BOM, a aba do iOS que abre em vez de salvar, e a URL de objeto revogada no momento errado. Integre a limpeza ao ciclo de vida do seu componente, detecte o recurso antes de chamar APIs experimentais, e teste o fluxo em um dispositivo iOS real antes de publicar.
Perguntas Frequentes
Um Blob armazena dados brutos e é referenciado por uma URL de objeto curta criada com URL.createObjectURL(), enquanto uma data URI incorpora o payload completo como uma string Base64 diretamente na URL. A codificação Base64 aumenta o payload em cerca de um terço antes do preenchimento, e a string codificada completa deve caber na memória como valor de um atributo. O Blob permite definir um tipo MIME independentemente dos dados e evita ambos os custos, tornando-o a melhor escolha padrão para geração de arquivos no lado do cliente.
O atributo download é respeitado para URLs da mesma origem, mas ignorado para URLs de origens cruzadas, a menos que a resposta também inclua um cabeçalho Content-Disposition: attachment, conforme o WHATWG HTML Standard. Um arquivo servido por um CDN em uma origem diferente será navegado ou renderizado inline em vez de ser baixado. A solução é definir o cabeçalho no servidor que você controla, ou buscar o arquivo de origem cruzada e re-servi-lo como um blob da mesma origem usando URL.createObjectURL().
Não. O showSaveFilePicker() da File System Access API é suportado apenas em navegadores da família Chromium a partir da versão 86 e não está implementado no Firefox ou Safari. O MDN o classifica como 'disponibilidade limitada' e 'experimental'. Por isso, o código de produção deve detectar o recurso com 'showSaveFilePicker' in window e fornecer um fallback para o padrão de âncora e URL de objeto. Envolva a chamada em try/catch, pois a propriedade pode existir mas lançar uma exceção em alguns contextos de iframe com sandbox, e ela lança AbortError quando o usuário fecha o diálogo.
Revogar a URL de objeto antes do início do download a quebra, pois o navegador não consegue mais resolver a referência de blob para a qual a âncora aponta. Em um utilitário de uso único que termina de forma síncrona, revogar logo após click() é seguro. Em um componente que armazena a URL de blob no estado para um href ou src, revogue-a em uma função de limpeza, como o valor de retorno do useEffect, o onUnmounted do Vue, ou o onDestroy do Svelte. Nunca revogá-la, por outro lado, vaza memória entre re-renderizações durante todo o tempo de vida do documento.
Understand every bug
Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data. Check our GitHub repo and join the thousands of developers in our community.