Back

How to Create a Downloadable File in the Browser

How to Create a Downloadable File in the Browser

Creating a downloadable file in the browser combines four browser APIs: a Blob for the data, URL.createObjectURL() for an in-memory reference, an anchor with the download attribute to trigger the save, and URL.revokeObjectURL() to release the reference afterwards. The full pattern is fewer than ten lines:

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');

That is the canonical pattern, and it covers the common case of saving a generated CSV, JSON config, or image. But the happy path hides real failure modes: the download attribute silently disabling itself on cross-origin URLs, iOS Safari opening downloads in a new tab, object URLs leaking across component re-renders, and Excel garbling UTF-8 CSV. This article covers the working pattern, the modern File System Access API, streaming for files too large to fit in memory, and the platform quirks that break downloads in production.

Key Takeaways

  • The canonical client-side download is new Blob([data], { type })URL.createObjectURL() → an anchor with the download attribute → click()URL.revokeObjectURL().
  • An object URL’s lifetime is tied to the document that created it, so revoke it in a framework cleanup function (useEffect return, Vue onUnmounted) rather than relying on an unverified timing relationship with a.click().
  • showSaveFilePicker() is the only browser-native way to let users choose a save location, but it is experimental and Chromium-only, so feature-detect and fall back to the anchor pattern.
  • Excel opens a UTF-8 CSV correctly only when the file carries a BOM, so prepend \uFEFF to the string.
  • iOS Safari has historically had unreliable support for download-attribute downloads, which is why downloads there often open in a new tab instead of saving.

The canonical pattern: download a file with JavaScript in the browser

The reliable way to download a file generated with JavaScript is to construct a Blob, create an object URL with URL.createObjectURL(), assign it to an anchor’s href, set the download attribute to the filename, click the anchor programmatically, then release the URL with URL.revokeObjectURL(). The Blob constructor lets you set the MIME type independently of the data, and the object URL is a short reference (blob:https://…) that the anchor can navigate to.

Skip data URIs as a default. Data URIs are the wrong default for client-side file generation: Base64 encodes each 3 bytes as 4 characters, growing payload size by about one third before padding (per RFC 4648), and the entire encoded string must fit in memory as a DOM attribute value. Current data: URL size limits are 512 MB in Chromium and Firefox and 2048 MB in Safari/WebKit (MDN data: URL reference) — but the encoding overhead and in-memory string cost make Blob the better default well before those ceilings.

The anchor does not need to be attached to the DOM for click() to work in current browsers, which keeps the helper self-contained:

// 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;');

The URL.revokeObjectURL(url) call matters more than most examples suggest. An object URL’s lifetime is tied to the document that created it, so it persists in memory until you revoke it or the document unloads. In a single throwaway script that is harmless; in a component-based app where the helper runs on every button click, unreleased URLs accumulate. The synchronous revoke above is safe here because nothing in this function survives past the call — but as the framework section shows, that placement is wrong inside a component.

The HTML-only download attribute and its quiet cross-origin failure

For a file that is already hosted on your own origin, you do not need JavaScript at all — add the download attribute to a plain anchor:

<a href="/reports/q3.pdf" download="q3-report.pdf">Download report</a>

The download attribute instructs the browser to save the linked resource instead of navigating to it. It was added in HTML5 and is supported in Chrome 14+ and Firefox 20+ (caniuse: download attribute). It also accepts blob: and data: URLs, which is why it pairs with the canonical pattern above.

The failure mode is cross-origin URLs. For cross-origin downloads, the download attribute is honored only when the response also carries Content-Disposition: attachment; without it, the browser ignores the attribute and the link is not a reliable forced download (per the WHATWG HTML Standard, downloading resources). This is a frequent source of confusion: the same markup that downloads a same-origin file will navigate to or render a file served from a CDN on a different origin. If you control the server, set the header there. If you do not, fetch the file and re-serve it as a blob from your own origin:

// 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);
}

File System Access API: letting the user pick a save location

The File System Access API’s showSaveFilePicker() is the only browser-native way to let the user choose a save location and filename before writing. It opens the OS save dialog, returns a file handle, and lets you write through a FileSystemWritableFileStream. MDN marks it “Limited availability” and “Experimental”; it shipped in Chromium-family browsers from version 86 (Chrome Status) and is not supported in Firefox or Safari, which makes a feature-detect-and-fallback to the anchor pattern mandatory for production use.

showSaveFilePicker() throws a DOMException with the name AbortError if the user dismisses the picker, so handle that case explicitly rather than treating it as a failure:

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);
}

The 'showSaveFilePicker' in window check is the right gate, but it is not airtight: in some sandboxed iframe contexts the property exists yet throws on call, which is why the try/catch wraps the actual invocation and not just the detection.

Streaming large files that exceed available memory

Streaming bypasses the Blob memory ceiling by writing chunks directly to disk instead of buffering the full payload in RAM. There is no fixed byte threshold for the ceiling itself — it depends on the device, OS, and browser’s available heap. Two streaming approaches avoid buffering the whole file.

In Chromium, write incrementally through the FileSystemWritableFileStream returned by showSaveFilePicker(). Each write() adds a chunk to the writable stream, so the application does not need to hold the entire file in memory at once:

// 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();
}

For browsers without the File System Access API, StreamSaver.js is the cross-browser route. Per its README, StreamSaver.js saves large client-generated files by creating a writable stream and emulating a server-driven download using response headers plus a service worker, so the data is written to disk as it streams rather than assembled into one Blob. It builds on the 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);
}

Reach for streaming when the output is genuinely large — multi-hundred-megabyte exports, generated video, or a long-running data feed. For a CSV of a few thousand rows, the canonical Blob pattern is simpler and fine.

iOS Safari quirks and how to work around them

iOS Safari has historically had unreliable support for files generated and downloaded via the download attribute (WebKit bug 167341), so a generated file frequently opens in a new tab or renders inline instead of saving — making iOS the platform where download code most often breaks in production. The FileSaver.js README documents the practical consequence: on iOS, saveAs() must run inside a user interaction such as onClick, and setTimeout will prevent it from triggering; due to iOS restrictions, saveAs() may open a new window instead of downloading (FileSaver.js README).

The concrete workarounds:

  • Keep the download synchronous with the user gesture. Trigger the click directly in the event handler, not after an await or setTimeout that detaches it from the gesture. If you must do async work first, fetch and prepare the data before the user clicks, then trigger the save synchronously.
  • Give the user an explicit fallback. When a new tab opens with the file rendered inline, surface a “tap Share → Save to Files” hint, since the user must complete the save manually.
  • Prefer text/plain or a viewable type for text exports on iOS, accepting that the file may open in a viewer the user then saves, rather than fighting for a forced download that the platform does not reliably provide.

These failures are invisible to server logs and analytics, because no request reaches your backend and no exception is thrown. Production session replays of download flows frequently show users clicking the trigger repeatedly because no visible feedback fired — on iOS this often appears as a new tab opening and the user immediately closing it, then returning confused. Session replay is one of the few techniques that surfaces this class of silent, gesture-and-platform-bound failure.

// 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);
  };
}

MIME-type pitfalls: inline display, Excel CSV, and line endings

The MIME type you pass to the Blob constructor helps the browser determine how to interpret the file, and getting it wrong is a common cause of downloaded content opening unexpectedly or being handled incorrectly. Set a type that matches the data, and pair it with the download attribute so the browser treats the result as an attachment.

The most-reported CSV bug is accented characters showing as garbage when the file is opened in Excel. The fix is a byte-order mark. Excel opens a UTF-8 CSV file correctly when the file is saved with a BOM, so prepend one to the string (Microsoft Support: opening UTF-8 CSV in 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;');  
}

Two further details from RFC 4180, which registers text/csv as the MIME type for CSV: the spec defines CRLF (\r\n) as the record delimiter, so prefer \r\n over \n between rows for maximum spreadsheet compatibility, and fields containing commas, quotes, or line breaks must be wrapped in double quotes with internal quotes doubled.

Resist using application/octet-stream as a universal force-download switch. It is the IANA-registered type for arbitrary binary data, but it is not a reliable force-download mechanism — the FileSaver.js README notes that using application/octet-stream to force downloads can cause issues in Safari. Use the correct, specific type and rely on the download attribute (or Content-Disposition) to enforce saving.

Framework gotchas: SSR, object URL lifecycle, and memory leaks

Two component-lifecycle bugs break the canonical download pattern in React, Vue, and Svelte: calling DOM APIs during server-side rendering (where document is undefined), and revoking object URLs at the wrong point in the component lifecycle. Both stem from the same root cause — components render on the server and re-render on the client, but the vanilla pattern assumes one document and one execution.

Guard against SSR. In Next.js, Nuxt, or SvelteKit, component code can run where document is undefined; calling document.createElement there throws. Gate any download helper behind a runtime check:

function triggerDownload(data, filename, type) {
  if (typeof document === 'undefined') return; // SSR guard
  downloadBlob(data, filename, type);
}

Revoke object URLs in cleanup, not immediately. The synchronous URL.revokeObjectURL(url) from the canonical pattern is fine for a one-shot helper that finishes before anything else runs. But if you store a blob URL in state to use as an href or src, revoking it too early breaks the download, and never revoking it leaks memory across re-renders. The verified rule: an object URL’s lifetime is tied to the document that created it, so revoke it once it is no longer needed; in component-based frameworks, store the blob URL in state and revoke it in a cleanup function rather than relying on an unverified timing relationship with 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;
}

The Vue equivalent creates the URL in onMounted (or a watch) and revokes it in onUnmounted; Svelte uses onDestroy. In all three, the leak that bites is a new object URL created on every re-render or every button click with no matching revoke — memory grows for the lifetime of the document.

For legacy browsers that lack the download attribute entirely, FileSaver.js remains a one-line polyfill — but on modern targets the platform pattern above replaces it.

Conclusion

The Blob → object URL → anchor → download → revoke sequence handles the large majority of client-side download work, with showSaveFilePicker() as the Chromium-only upgrade when users need to choose a location and streaming as the escape hatch when files outgrow memory. The bugs that reach production are rarely in the happy path — they are the cross-origin attribute that silently navigates, the CSV that needs a BOM, the iOS tab that opens instead of saving, and the object URL revoked at the wrong moment. Wire the cleanup into your component lifecycle, feature-detect before you call experimental APIs, and test the flow on a real iOS device before you ship it.

FAQs

A Blob stores raw data and is referenced by a short object URL created with URL.createObjectURL(), while a data URI embeds the entire payload as a Base64 string directly in the URL. Base64 encoding grows the payload by about one third before padding, and the full encoded string must fit in memory as an attribute value. Blob lets you set a MIME type independently of the data and avoids both costs, making it the better default for client-side file generation.

The download attribute is honored for same-origin URLs but ignored for cross-origin URLs unless the response also carries a Content-Disposition: attachment header, per the WHATWG HTML Standard. A file served from a CDN on a different origin will navigate or render inline instead of downloading. The fix is to set the header on the server you control, or fetch the cross-origin file and re-serve it as a same-origin blob using URL.createObjectURL().

No. The File System Access API's showSaveFilePicker() is supported only in Chromium-family browsers from version 86 and is not implemented in Firefox or Safari. MDN marks it 'Limited availability' and 'Experimental.' Because of this, production code must feature-detect with 'showSaveFilePicker' in window and fall back to the anchor and object URL pattern. Wrap the call itself in try/catch, since the property can exist but throw in some sandboxed iframe contexts, and it throws AbortError when the user dismisses the dialog.

Revoking the object URL before the download initiates breaks it, because the browser can no longer resolve the blob reference the anchor points to. In a one-shot helper that finishes synchronously, revoking right after click() is safe. In a component that stores the blob URL in state for an href or src, revoke it in a cleanup function instead, such as the useEffect return value, Vue's onUnmounted, or Svelte's onDestroy. Never revoking it instead leaks memory across re-renders for the lifetime of the document.

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.

OpenReplay