Last updated: May 29, 2025

<span aria-hidden="true" id="stream-large-files-in-react-without-memory-issues"></span>

# Stream large files in React without memory issues

![Tim Koschützki](/assets/images/teammates/avatar-tim-kos-1.jpg?dpl=dpl_AkBKiiEcd5MPUBn4NARw15mr6FeE)

**Tim Koschützki**

Co-founder · Berlin, Germany · Show bio

[](https://x.com/tim%5Fkos)[](https://github.com/tim-kos)

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.

<span aria-hidden="true" id="why-traditional-blob-downloads-fail-with-large-files"></span>

## Why traditional blob downloads fail with large files

A classic download helper looks like this:

```javascript
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.

<span aria-hidden="true" id="stream-data-with-the-fetch--streams-apis"></span>

## 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.

```javascript
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).

<span aria-hidden="true" id="browser-support-at-a-glance"></span>

### 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.

```bash
npm install browser-fs-access

```

```javascript
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.

<span aria-hidden="true" id="build-a-reusable-react-hook"></span>

## Build a reusable React hook

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

```tsx
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:

```tsx
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

```

<span aria-hidden="true" id="security-permissions-and-error-handling"></span>

## 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:

```javascript
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`.

<span aria-hidden="true" id="memory-usage-compared"></span>

## 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|✅|

<span aria-hidden="true" id="wrap-up"></span>

## 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](/services/handling-uploads.md)—it slots perfectly into the same workflow.

\#react#streams-api#file-system-access-api#memory-efficient-file-download#streaming-file-download-react#writablestream-download#handling-uploads-service

### 👩‍💻 Join 20k+ developers

Sign up for our [monthly newsletter](/newsletters.md) to receive direct links to 3 exclusive tech — and 2 product updates. No less, no more.

Your email:

Get access

## File uploading and encoding. Made simple.

Transloadit streamlines file handling for developers, trusted by brands like Coursera and The New York Times. We’re known for a reliable API, top-notch support, and a strong commitment to open source, with projects like [Uppy⁠](https://uppy.io) and [Tus⁠](https://tus.io) setting standards in file processing.

[Sign up](/c/)[Book a Demo](https://survey.typeform.com/to/kRg47Xi5)

No credit card needed

Cancel anytime
