Stream large files in React without memory issues

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
. Ifwindow.isSecureContext
isfalse
,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 offersrequestPermission()
andqueryPermission()
, 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 intry...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.