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 from the network to the user’s disk without retaining the entire file in JavaScript.
Streaming still needs browser, network, and filesystem buffers; it does not use zero memory.
Why traditional blob downloads fail with large files
A classic download helper looks like this:
async function traditionalDownload(url) {
const res = await fetch(url)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const blob = await res.blob() // Materializes the complete response before saving
const objectUrl = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = objectUrl
a.download = 'file.zip'
a.click()
setTimeout(() => URL.revokeObjectURL(objectUrl), 60000)
}
Problems appear as soon as the file is bigger than the user’s available memory budget:
- The complete response is materialized before saving; backing storage and peak RAM depend on the browser and can grow with file size.
- Buffering adds allocation and processing overhead, although
blob()itself is asynchronous. - This helper exposes no progress feedback while the response is being read.
- The browser’s download settings control the destination and whether it prompts the user.
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, keeping application buffering bounded rather than proportional to file size.
Save the following helpers in downloads.ts in a React TypeScript project. If your DOM types do not
declare the picker API, install its declarations. Include wicg-file-system-access if your
TypeScript configuration restricts the types list. Enable allowImportingTsExtensions and noEmit
for the .ts imports used by this example; the bundler handles JavaScript output:
npm install --save-dev @types/wicg-file-system-access
Call streamToDisk directly from a click handler so the picker has user activation. It propagates
errors and cancellation to its caller, aborts incomplete writes, and releases its reader. The
server must allow the request through CORS when it is on another origin.
export async function streamToDisk(
url: string,
suggestedName: string,
onProgress: (percent: number) => void,
signal?: AbortSignal,
): Promise<void> {
if (!window.isSecureContext || typeof window.showSaveFilePicker !== 'function') {
throw new Error('File System Access API not supported in this browser')
}
signal?.throwIfAborted()
const fileHandle = await window.showSaveFilePicker({ suggestedName })
signal?.throwIfAborted()
const writable = await fileHandle.createWritable()
try {
signal?.throwIfAborted()
const response = await fetch(url, { signal })
if (!response.ok) {
await response.body?.cancel()
throw new Error(`HTTP ${response.status}`)
}
if (!response.body) throw new Error('The response has no readable body')
const total = Number(response.headers.get('Content-Length'))
let written = 0
const reader = response.body.getReader()
const cancelReader = () => { void reader.cancel(signal?.reason).catch(() => {}) }
signal?.addEventListener('abort', cancelReader, { once: true })
try {
while (true) {
signal?.throwIfAborted()
const { value, done } = await reader.read()
signal?.throwIfAborted()
if (done) break
await writable.write(value)
written += value.byteLength
if (Number.isFinite(total) && total > 0) {
onProgress(Math.min(99, (written / total) * 100))
}
}
signal?.throwIfAborted()
await writable.close()
signal?.throwIfAborted()
} finally {
signal?.removeEventListener('abort', cancelReader)
// Cleanup must not replace the original transfer error.
await reader.cancel().catch(() => {})
reader.releaseLock()
}
} catch (error) {
await writable.abort(error).catch(() => {})
throw error
}
}
Key takeaways:
- No huge
Blobsits in memory; chunks move straight to disk. - Progress requires an accurate
Content-Lengthmatching the decoded body bytes. Serve downloads without content compression for this calculation; otherwise show indeterminate progress. - Progress stays below 100% until the writable closes successfully.
- The save picker is available in supporting Chromium browsers, but not Firefox or Safari.
Browser support at a glance
| Browser | showSaveFilePicker() |
|---|---|
| Chrome desktop | 86+ |
| Edge desktop | 86+ |
| Firefox | Not supported |
| Safari | Not supported |
These are picker-specific results from MDN’s compatibility data, checked in September 2026. Support for the origin-private file system does not imply support for a save picker. Always detect the capability at runtime.
For large files in browsers without this API, prefer a normal download link to an endpoint returning
Content-Disposition: attachment. The browser manages the download without a JavaScript array of
chunks. Use same-origin session authentication or an authorized download URL when a link cannot
supply the API’s usual authorization headers. The download attribute alone is not sufficient for
arbitrary cross-origin URLs.
For small files only, a Blob fallback can be convenient. Reading in chunks still retains the entire
file. This helper enforces a 50 MiB application limit both against a declared length and against
actual received bytes; lower it for your audience’s devices. Blob creation can temporarily need
additional copies, so this is not a 50 MiB cap on browser RAM. Append this to downloads.ts:
const MAX_BLOB_BYTES = 50 * 1024 * 1024
export async function saveWithFallback(
url: string,
filename: string,
onProgress: (percent: number) => void,
signal?: AbortSignal,
): Promise<void> {
const response = await fetch(url, { signal })
if (!response.body) throw new Error('The response has no readable body')
const reader = response.body.getReader()
const cancelReader = () => { void reader.cancel(signal?.reason).catch(() => {}) }
signal?.addEventListener('abort', cancelReader, { once: true })
try {
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const total = Number(response.headers.get('Content-Length'))
if (total > MAX_BLOB_BYTES) throw new Error('Use the direct download link for this file')
const chunks: ArrayBuffer[] = []
let received = 0
while (true) {
signal?.throwIfAborted()
const { done, value } = await reader.read()
signal?.throwIfAborted()
if (done) break
received += value.byteLength
if (received > MAX_BLOB_BYTES) throw new Error('Use the direct download link for this file')
chunks.push(value.slice().buffer)
if (Number.isFinite(total) && total > 0) {
onProgress(Math.min(99, (received / total) * 100))
}
}
signal?.throwIfAborted()
const objectUrl = URL.createObjectURL(new Blob(chunks))
const link = document.createElement('a')
link.href = objectUrl
link.download = filename
try {
document.body.appendChild(link)
link.click()
} finally {
link.remove()
setTimeout(() => URL.revokeObjectURL(objectUrl), 60000)
}
} finally {
signal?.removeEventListener('abort', cancelReader)
await reader.cancel().catch(() => {})
reader.releaseLock()
}
}
The fallback resolves when it hands the Blob to the browser. JavaScript cannot confirm that the
user saved it to disk. A library such as browser-fs-access can simplify browser integration, but
its Blob fallback has the same whole-file buffering limitation.
Build a reusable React hook
Save this hook as useDownload.ts. It uses the helpers above, prevents overlapping downloads, and
aborts active work when the component unmounts. A canceled picker or transfer does not report success.
import { useEffect, useRef, useState } from 'react'
import { saveWithFallback, streamToDisk } from './downloads.ts'
type UseDownloadReturn = {
progress: number | null
isDownloading: boolean
error: string | null
start: (url: string, filename: string) => Promise<void>
cancel: () => void
}
export function useDownload(): UseDownloadReturn {
const [progress, setProgress] = useState<number | null>(null)
const [isDownloading, setIsDownloading] = useState(false)
const [error, setError] = useState<string | null>(null)
const active = useRef<AbortController | null>(null)
useEffect(() => () => {
active.current?.abort()
active.current = null
}, [])
async function start(url: string, filename: string): Promise<void> {
if (active.current) return
const controller = new AbortController()
active.current = controller
setIsDownloading(true)
setError(null)
setProgress(null)
const onProgress = (p: number) => {
if (!controller.signal.aborted) setProgress(p)
}
try {
if (typeof window.showSaveFilePicker === 'function' && window.isSecureContext) {
await streamToDisk(url, filename, onProgress, controller.signal)
} 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, controller.signal)
}
controller.signal.throwIfAborted()
setProgress(100)
} catch (error) {
if (active.current !== controller) return
setProgress(null)
if (!controller.signal.aborted && !(error instanceof DOMException && error.name === 'AbortError')) {
setError('Download failed. Try again or use the direct download link.')
}
} finally {
if (active.current === controller) {
active.current = null
setIsDownloading(false)
}
}
}
function cancel(): void {
active.current?.abort()
}
return { progress, isDownloading, error, start, cancel }
}
Now using the hook inside a component is trivial:
import type { ReactNode } from 'react'
import { useDownload } from './useDownload.ts'
interface DownloadButtonProps {
url: string
filename: string
}
export function DownloadButton({ url, filename }: DownloadButtonProps): ReactNode {
const { progress, isDownloading, error, start, cancel } = useDownload()
return (
<div>
<button onClick={() => start(url, filename)} disabled={isDownloading}>
{isDownloading ? 'Downloading…' : 'Save with progress (small files in fallback browsers)'}
</button>
<button onClick={cancel} disabled={!isDownloading}>Cancel</button>
<a href={url} download={filename}>Direct download (recommended for large files)</a>
{isDownloading ? (
<div>
<progress aria-label="Download progress" value={progress ?? undefined} max={100} />
<span>{progress == null ? 'Downloading…' : `${Math.round(progress)}%`}</span>
</div>
) : null}
{error ? <div role="alert">{error}</div> : null}
</div>
)
}
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.isSecureContextisfalse,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: Do not assume a stored handle retains permission. This helper opens the picker for each save; workflows that retain handles should query permission before reuse.
- 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...catchblocks.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.
The streamToDisk helper above handles these failures in the path actually used by the hook. It
aborts the writable on failure and cancels and releases the response reader in finally. It closes
the writable only after all bytes have been read. Canceling during the final filesystem commit
cannot guarantee undoing a save that has already completed. The Blob fallback likewise cannot
cancel a browser download after handing it off.
Memory usage compared
| Approach | Application buffering | Progress |
|---|---|---|
response.blob() | Complete response before saving; browser-dependent storage | Not exposed by this helper |
| Stream to File System Access | One chunk at a time plus browser and filesystem buffers | When an accurate length is available |
| Bounded Blob fallback | Whole file up to 50 MiB, plus temporary copies | When an accurate length is available |
| Normal download link | Managed by the browser, outside this JavaScript buffer | Browser download UI |
Wrap-up
With fetch() streams and the File System Access API, you can let users pull gigabyte-sized assets
without retaining the complete file in JavaScript. Use the bounded Blob fallback for small files
only. For large downloads outside browsers with a save picker, provide a normal download endpoint
and let the browser manage the transfer.
Need a matching solution for uploads? Check out our Robot that powers the handling uploads service—it slots perfectly into the same workflow.
