Downloading large files in React applications becomes tricky once the files grow beyond a few hundred megabytes. The naïve fetch → blob → link.click() pattern keeps the whole file in memory—which is a fast path to tab crashes and angry users. Thankfully, modern browser APIs let us stream data straight from the network to the user’s disk, keeping memory use close to zero.

Why traditional blob downloads fail with large files

A classic download helper looks like this:

async function traditionalDownload(url) {
  const res = await fetch(url)
  const blob = await res.blob() // 🔴 entire file in RAM
  const objectUrl = URL.createObjectURL(blob)

  const a = document.createElement('a')
  a.href = objectUrl
  a.download = 'file.zip'
  a.click()

  URL.revokeObjectURL(objectUrl)
}

Problems appear as soon as the file is bigger than the user’s available memory budget:

  • Memory usage equals file size.
  • The browser blocks the main thread while materializing the Blob.
  • Progress feedback is impossible until the download is finished.
  • Users cannot choose where the file ends up.

Stream data with the fetch & streams APIs

fetch() gives us a ReadableStream in response.body. Instead of buffering chunks in an array (which would again grow with the file size), we can pipe each chunk directly to a WritableStream. When the File System Access API is available, that writable points to the user-selected file on disk—so memory usage stays tiny.

async function streamToDisk(url, suggestedName, onProgress) {
  if (!('showSaveFilePicker' in window)) {
    throw new Error('File System Access API not supported in this browser')
  }

  // 1️⃣ Ask the user where to store the file
  const fileHandle = await window.showSaveFilePicker({
    suggestedName,
    types: [
      {
        description: 'Any file',
        accept: { '*/*': [] },
      },
    ],
  })

  // 2️⃣ Create a writable stream to that location
  const writable = await fileHandle.createWritable()

  // 3️⃣ Start streaming from the network into the file
  const response = await fetch(url)
  if (!response.ok) throw new Error(`HTTP ${response.status}`)

  const total = +response.headers.get('Content-Length') || 0
  let written = 0

  const reader = response.body.getReader()
  while (true) {
    const { value, done } = await reader.read()
    if (done) break

    await writable.write(value)
    written += value.length

    if (total && typeof onProgress === 'function') {
      onProgress((written / total) * 100)
    }
  }

  await writable.close()
}

Key takeaways:

  • No huge Blob sits in memory; chunks move straight to disk.
  • Progress is calculated as soon as we know Content-Length.
  • The code works in Chrome 86+, Edge 86+, Firefox 111 (partial), and Safari 15.2 (partial).

Browser support at a glance

Browser File System Access API
Chrome 86+
Edge 86+
Firefox 111+ (partial)
Safari 15.2+ (partial)
Brave Behind flag

When the API is missing, we can fall back to a small helper library under 1 kB, browser-fs-access, which recreates the same dialog on supported browsers and silently drops back to a Blob download everywhere else.

npm install browser-fs-access
import { fileSave } from 'browser-fs-access'

async function saveWithFallback(url, filename, onProgress) {
  const res = await fetch(url)
  if (!res.ok) throw new Error(`HTTP ${res.status}`)

  const total = +res.headers.get('Content-Length') || 0
  const chunks = []
  let received = 0
  const reader = res.body.getReader()

  while (true) {
    const { done, value } = await reader.read()
    if (done) break
    chunks.push(value)
    received += value.length
    if (total && onProgress) onProgress((received / total) * 100)
  }

  const blob = new Blob(chunks)
  await fileSave(blob, { fileName: filename })
}

The fallback still buffers, but only on browsers that offer no better option.

Build a reusable React hook

Let’s wrap the logic in a hook so components stay tidy.

import { useState, useCallback } from 'react'
import { fileSave } from 'browser-fs-access'

type UseDownloadReturn = {
  progress: number
  isDownloading: boolean
  error: string | null
  start: (url: string, filename: string) => Promise<void>
}

export function useDownload(): UseDownloadReturn {
  const [progress, setProgress] = useState(0)
  const [isDownloading, setIsDownloading] = useState(false)
  const [error, setError] = useState<string | null>(null)

  const start = useCallback(async (url: string, filename: string) => {
    setIsDownloading(true)
    setError(null)
    setProgress(0)

    const onProgress = (p: number) => setProgress(p)

    try {
      if ('showSaveFilePicker' in window && window.isSecureContext) {
        await streamToDisk(url, filename, onProgress)
      } 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)
      }
      setProgress(100)
    } catch (e: any) {
      setError(e.message)
    } finally {
      setIsDownloading(false)
    }
  }, [])

  return { progress, isDownloading, error, start }
}

Now using the hook inside a component is trivial:

import React from 'react'
import { useDownload } from './useDownload'

function DownloadButton({ url, filename }: { url: string; filename: string }) {
  const { progress, isDownloading, error, start } = useDownload()

  return (
    <div>
      <button onClick={() => start(url, filename)} disabled={isDownloading}>
        {isDownloading ? 'Downloading…' : 'Download'}
      </button>

      {isDownloading && (
        <div>
          <progress value={progress} max={100} />
          <span>{Math.round(progress)}%</span>
        </div>
      )}

      {error && (
        <div style={{ color: 'red' }}>{error}</div>
      )}
    </div>
  )
}

export default DownloadButton

Security, permissions, and error handling

The File System Access API is powerful and therefore gated by several safeguards. Understanding these is key to a smooth user experience and robust error handling.

  • Secure context: The page must be served over HTTPS or from localhost. If window.isSecureContext is false, showSaveFilePicker() will not be available. Your code should check for this and potentially inform the user or use the fallback.
  • User gesture: The file picker can only be opened in direct response to a user interaction, like a click or key press. Programmatic calls without a preceding user gesture will fail.
  • Permission scope: Access is granted only to the file selected by the user. Your application cannot write to other files or locations without explicit user permission for each instance.
  • Permission persistence: Permissions are generally not persisted across sessions for files selected via showSaveFilePicker(). The user will typically be prompted each time they download a file. For more persistent access, the API offers requestPermission() and queryPermission(), but these have their own complexities and user experience considerations.
  • Restricted folders: Browsers prevent access to sensitive system directories. The file picker will filter these out, so users cannot accidentally (or maliciously) select them.
  • Error handling: It's crucial to wrap calls to showSaveFilePicker() and subsequent stream operations in try...catch blocks.
    • AbortError: This error is thrown if the user dismisses the file picker (e.g., by clicking "Cancel"). This is a common scenario and should be handled gracefully, perhaps by resetting the UI state without displaying an aggressive error message.
    • Other errors: Network issues, disk space limitations, or unexpected API behavior can also lead to errors. Log these for debugging and provide a user-friendly message.

Here's an example of more robust error handling:

async function streamToDiskWithRobustErrorHandling(url, suggestedName, onProgress) {
  if (!window.isSecureContext) {
    console.warn('File System Access API requires a secure context (HTTPS). Falling back.')
    // Implement or call your fallback download mechanism here
    // e.g., await saveWithFallback(url, suggestedName, onProgress);
    return
  }

  if (!('showSaveFilePicker' in window)) {
    console.warn('File System Access API not supported. Falling back.')
    // Implement or call your fallback download mechanism here
    // e.g., await saveWithFallback(url, suggestedName, onProgress);
    return
  }

  let fileHandle
  try {
    fileHandle = await window.showSaveFilePicker({
      suggestedName,
      types: [{ description: 'Any file', accept: { '*/*': [] } }],
    })
  } catch (err) {
    if (err.name === 'AbortError') {
      console.log('User cancelled the save dialog.')
      // Reset UI, no need for an error message to the user
      return
    }
    console.error('Error picking file:', err)
    throw new Error('Could not select file: ' + err.message) // Re-throw or handle
  }

  let writable
  try {
    writable = await fileHandle.createWritable()
  } catch (err) {
    console.error('Error creating writable stream:', err)
    throw new Error('Could not write to file: ' + err.message) // Re-throw or handle
  }

  try {
    const response = await fetch(url)
    if (!response.ok) throw new Error(`HTTP error ${response.status}`)

    const total = +response.headers.get('Content-Length') || 0
    let written = 0
    const reader = response.body.getReader()

    while (true) {
      const { value, done } = await reader.read()
      if (done) break
      await writable.write(value)
      written += value.length
      if (total && typeof onProgress === 'function') {
        onProgress((written / total) * 100)
      }
    }
    await writable.close()
  } catch (err) {
    console.error('Download or write error:', err)
    // Attempt to close the writable if it was opened, to prevent file locking
    if (writable) {
      try {
        await writable.abort() // Or writable.close() if appropriate
      } catch (closeErr) {
        console.error('Error closing/aborting writable stream:', closeErr)
      }
    }
    throw new Error('Download failed: ' + err.message) // Re-throw or handle
  }
}

This more detailed example isn't used directly in the useDownload hook for brevity in the main article, but illustrates the considerations for a production application. The hook already catches errors from streamToDisk and saveWithFallback.

Memory usage compared

Approach Peak RAM UX Progress?
Blob download = file size Basic
Stream → File System Access ~64 KiB Great
Stream → browser-fs-access ~file size (fallback only) OK

Wrap-up

With fetch() streams and the File System Access API, you can let users pull gigabyte-sized assets without melting their laptops. Whenever support is missing, browser-fs-access offers a reasonable fallback. Combine the two behind a tiny React hook and you get reliable, memory-safe downloads in a few lines of code.

Need a matching solution for uploads? Check out our Robot that powers the handling uploads service—it slots perfectly into the same workflow.