Stream large files in React without memory issues
For a large React download, await each write to a user-selected file instead of collecting the response in a Blob. This walkthrough mounts a complete React app, serves a known 256 MiB binary stream locally, and checks the saved file’s byte count and SHA-256. The streaming path bounds the chunks retained by your application; browser, network, and filesystem buffers still use memory.
Why traditional blob downloads fail with large files
The familiar fetch → blob → link.click() pattern waits for the complete response before handing
it to the browser. Response.blob()
reads the body to completion. The browser chooses the Blob’s backing storage, so this is not
necessarily one giant JavaScript heap allocation, but it still materializes the entire file.
Use the File System Access API when you need a destination picker and application progress in supporting desktop Chromium browsers. For a large file in Firefox or Safari, offer an ordinary link to an attachment endpoint. The browser manages that transfer outside your JavaScript chunk array. This example also includes a deliberately limited Blob fallback for small files. Saving new download destinations is the task here; storing and reopening local file handles is a separate persistent file handling workflow.
Stream data with the fetch & streams APIs
Use Bash, Node.js 24.15 or newer in the maintained 24 LTS line, and Corepack with Yarn 4.12.0. Install Corepack separately if your Node distribution omits it. The native picker path was tested on Linux with Chromium 145; the link and Blob paths were tested in Firefox. Use a new directory outside an existing project or workspace. This guarded creation block checks prerequisites and enclosing package configuration before writing anything:
(
command -v node >/dev/null && command -v corepack >/dev/null &&
node --input-type=module -e '
import { existsSync } from "node:fs"
import { dirname, join, resolve } from "node:path"
const [major, minor] = process.versions.node.split(".").map(Number)
if (!(major === 24 && minor >= 15 || major >= 26)) throw Error("Use Node 24.15+ or 26+")
for (let dir = resolve("."); ; dir = dirname(dir)) {
if (["package.json", ".yarnrc.yml", ".pnp.cjs"].some(name => existsSync(join(dir, name)))) {
throw Error("Choose a directory outside an existing project or workspace")
}
if (dirname(dir) === dir) break
}
' && COREPACK_ENABLE_AUTO_PIN=0 corepack yarn --version && mkdir react-stream-demo
) && cd react-stream-demo
An existing react-stream-demo directory is refused. Keep its files and choose another empty parent
instead of deleting it. If installation later fails, retry installation in this new project; none
of the following steps require recreating it.
Save these files in the new directory. package.json pins the demonstration dependencies:
{
"name": "react-stream-demo",
"private": true,
"type": "module",
"packageManager": "yarn@4.12.0",
"scripts": {
"typecheck": "tsc --noEmit",
"build": "yarn typecheck && vite build",
"start": "node server.ts"
},
"dependencies": { "react": "19.2.6", "react-dom": "19.2.6" },
"devDependencies": {
"@types/node": "24.10.1",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@types/wicg-file-system-access": "2023.10.7",
"typescript": "6.0.3",
"vite": "7.3.1"
}
}
Save tsconfig.json. This configuration is complete, with no inherited extends:
{
"compilerOptions": {
"target": "ES2024",
"lib": ["ES2024", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"types": ["node", "react", "react-dom", "wicg-file-system-access"]
},
"include": ["*.ts", "*.tsx"]
}
Save .yarnrc.yml so installation, builds, and startup use the same linker:
nodeLinker: node-modules
Save index.html; Vite bundles the TypeScript entry point:
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><meta name="color-scheme" content="light dark"><meta name="viewport" content="width=device-width, initial-scale=1"><title>React download demo</title></head>
<body><main id="root"></main><script type="module" src="/main.tsx"></script></body>
</html>
Browser support at a glance
| Browser | Destination picker | Large-file path here |
|---|---|---|
| Desktop Chrome and Edge | Detect showSaveFilePicker | Stream to selected file |
| Firefox and Safari | No showSaveFilePicker | Attachment link |
Check MDN’s picker documentation for current compatibility. Support for the origin-private file system does not imply a save picker. The picker needs a secure context and a user gesture; loopback HTTP is suitable for this local demo. Node 24 is maintained LTS; its native TypeScript execution is sufficient for the server and verifier, while Vite compiles TSX for the browser.
Save downloads.ts. Both paths use the same reader loop. It awaits the consumer before asking
for another chunk, cancels the response on failure, and releases the reader. A missing or compressed
Content-Length produces byte-only progress: fetch exposes decoded body bytes, so a compressed
wire length is
not a valid denominator.
export type Progress = { received: number; total: number | null }
export type DownloadResult = { bytes: number; kind: 'saved' | 'handed-off' }
const MAX_BLOB_BYTES = 50 * 1024 * 1024
async function receive(
url: string,
consume: (chunk: Uint8Array<ArrayBuffer>) => Promise<void>,
onProgress: (progress: Progress) => void,
signal: AbortSignal,
limit = Infinity,
): Promise<number> {
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 reader = response.body.getReader()
const cancelReader = () => { void reader.cancel(signal.reason).catch(() => {}) }
signal.addEventListener('abort', cancelReader, { once: true })
try {
const length = response.headers.get('Content-Length')
const parsed = length === null ? NaN : Number(length)
const encoding = response.headers.get('Content-Encoding')
const total = (!encoding || encoding === 'identity') && Number.isSafeInteger(parsed) && parsed >= 0
? parsed : null
if (total !== null && total > limit) throw new Error('Use the direct download link for this file')
let received = 0
onProgress({ received, total })
while (true) {
signal.throwIfAborted()
const { value, done } = await reader.read()
signal.throwIfAborted()
if (done) break
if (received + value.byteLength > limit) throw new Error('Use the direct download link for this file')
await consume(value)
received += value.byteLength
onProgress({ received, total })
}
if (total !== null && received !== total) throw new Error('Incomplete response')
return received
} finally {
signal.removeEventListener('abort', cancelReader)
await reader.cancel().catch(() => {})
reader.releaseLock()
}
}
export async function streamToDisk(
url: string,
filename: string,
onProgress: (progress: Progress) => void,
signal: AbortSignal,
): Promise<DownloadResult> {
signal.throwIfAborted()
const handle = await window.showSaveFilePicker({ suggestedName: filename })
signal.throwIfAborted()
const writable = await handle.createWritable()
try {
const bytes = await receive(url, (chunk) => writable.write(chunk), onProgress, signal)
signal.throwIfAborted()
await writable.close()
signal.throwIfAborted()
return { bytes, kind: 'saved' }
} catch (error) {
await writable.abort(error).catch(() => {})
throw error
}
}
export async function saveWithFallback(
url: string,
filename: string,
onProgress: (progress: Progress) => void,
signal: AbortSignal,
): Promise<DownloadResult> {
const chunks: ArrayBuffer[] = []
const bytes = await receive(url, async (chunk) => {
chunks.push(chunk.slice().buffer)
}, onProgress, signal, MAX_BLOB_BYTES)
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)
}
return { bytes, kind: 'handed-off' }
}
The Blob path limits actual decoded bytes to 50 MiB, including responses without a length. Blob creation may need extra copies, so this is an application input bound, not a browser RAM cap. The native path returns only after the writable closes. It does not validate the server’s content; the checksum step below does that independently.
Build a reusable React hook
Save useDownload.ts. Call start directly from a click handler: opening the picker before any
network await preserves user activation. The busy guard prevents overlapping starts, and unmounting
aborts active work so an old transfer cannot update a later component.
import { useEffect, useRef, useState } from 'react'
import { saveWithFallback, streamToDisk } from './downloads.ts'
import type { DownloadResult, Progress } from './downloads.ts'
type UseDownloadReturn = {
progress: Progress | null
result: DownloadResult | null
message: string
busy: boolean
start: (url: string, filename: string) => Promise<void>
cancel: () => void
}
export function useDownload(): UseDownloadReturn {
const [progress, setProgress] = useState<Progress | null>(null)
const [result, setResult] = useState<DownloadResult | null>(null)
const [message, setMessage] = useState('Ready')
const [busy, setBusy] = useState(false)
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
setBusy(true)
setProgress(null)
setResult(null)
setMessage('Choose a destination or wait for the download…')
const onProgress = (value: Progress) => {
if (active.current === controller && !controller.signal.aborted) setProgress(value)
}
try {
const download = window.isSecureContext && typeof window.showSaveFilePicker === 'function'
? streamToDisk : saveWithFallback
const completed = await download(url, filename, onProgress, controller.signal)
controller.signal.throwIfAborted()
if (active.current !== controller) return
setResult(completed)
setMessage(completed.kind === 'saved'
? `Saved ${completed.bytes.toLocaleString()} bytes. Verify the file below.`
: 'Handed to browser. Check your downloads; disk saving is not confirmed.')
} catch (error) {
if (active.current !== controller) return
setProgress(null)
setMessage(controller.signal.aborted || error instanceof DOMException && error.name === 'AbortError'
? 'Canceled. Saving is not confirmed.'
: 'Download failed. Retry or use the direct download link.')
} finally {
if (active.current === controller) {
active.current = null
setBusy(false)
}
}
}
function cancel(): void { active.current?.abort() }
return { progress, result, message, busy, start, cancel }
}
Save main.tsx. The 256 MiB option exercises the native path; select 1 MiB to try the Blob
fallback in Firefox. The ordinary link remains available for large files.
import { useState } from 'react'
import type { ReactNode } from 'react'
import { createRoot } from 'react-dom/client'
import { useDownload } from './useDownload.ts'
function App(): ReactNode {
const [mib, setMib] = useState('256')
const { progress, message, busy, start, cancel } = useDownload()
const url = `/file?mib=${mib}`
const filename = `fixture-${mib}.bin`
const percent = progress?.total !== null && progress?.total !== undefined && progress.total > 0
? Math.min(99, progress.received / progress.total * 100) : undefined
return (
<section>
<h1>React download demo</h1>
<label>File size (MiB) <select value={mib} disabled={busy} onChange={(event) => setMib(event.target.value)}>
<option value="1">1</option><option value="256">256</option><option value="512">512</option>
</select></label>
<p><button disabled={busy} onClick={() => { void start(url, filename) }}>Save with progress</button>{' '}
<button disabled={!busy} onClick={cancel}>Cancel transfer</button></p>
<p><a href={url}>Direct download</a></p>
{busy && progress !== null ? (
<p><progress aria-label="Download progress" value={percent} max={100} />{' '}
{progress.received.toLocaleString()} bytes received</p>
) : null}
<p role="status">{message}</p>
</section>
)
}
const root = document.getElementById('root')
if (!root) throw new Error('Missing root element')
createRoot(root).render(<App />)
Serve a known binary stream
Save server.ts. It serves the built app and an uncompressed attachment on the same origin,
so this local example needs neither CORS nor authentication. Each 64 KiB block contains a repeated
binary pattern, including zero bytes. Node’s pipe observes response backpressure; a disconnected
client destroys the producer. /manifest?mib=256 gives the expected bytes and SHA-256 without
sending the file itself.
import { createHash } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { createServer } from 'node:http'
import type { ServerResponse } from 'node:http'
import { join } from 'node:path'
import { Readable } from 'node:stream'
import { setTimeout as delay } from 'node:timers/promises'
const block = Buffer.from(Uint8Array.from({ length: 64 * 1024 }, (_, i) => i % 251))
function manifest(bytes: number) {
const hash = createHash('sha256')
for (let offset = 0; offset < bytes; offset += block.length) {
hash.update(block.subarray(0, Math.min(block.length, bytes - offset)))
}
return { bytes, sha256: hash.digest('hex') }
}
async function serve(url: URL, response: ServerResponse): Promise<void> {
if (url.pathname === '/file' || url.pathname === '/manifest') {
const mib = Number(url.searchParams.get('mib') ?? 256)
if (!Number.isInteger(mib) || mib < 0 || mib > 1024) {
response.writeHead(400).end('Choose an integer size from 0 to 1024 MiB')
return
}
const bytes = mib * 1024 * 1024
if (url.pathname === '/manifest') {
response.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify(manifest(bytes)))
return
}
response.setHeader('Content-Type', 'application/octet-stream')
response.setHeader('Content-Disposition', `attachment; filename="fixture-${mib}.bin"`)
response.setHeader('Cache-Control', 'no-store')
if (url.searchParams.get('length') !== 'unknown') response.setHeader('Content-Length', bytes)
async function* chunks() {
for (let offset = 0; offset < bytes; offset += block.length) {
yield block.subarray(0, Math.min(block.length, bytes - offset))
await delay(1)
}
}
const source = Readable.from(chunks())
response.on('close', () => source.destroy())
source.on('error', () => response.destroy())
source.pipe(response)
return
}
const asset = url.pathname === '/' ? 'index.html' : url.pathname.slice(1)
if (asset !== 'index.html' && !/^assets\/[a-zA-Z0-9._-]+$/.test(asset)) {
response.writeHead(404).end('Not found')
return
}
const body = await readFile(join(import.meta.dirname, 'dist', asset))
response.writeHead(200, { 'Content-Type': asset.endsWith('.js') ? 'text/javascript' : 'text/html' }).end(body)
}
async function main(): Promise<void> {
const server = createServer((request, response) => {
void serve(new URL(request.url ?? '/', 'http://localhost'), response).catch(() => {
if (response.headersSent) response.destroy()
else response.writeHead(500).end('Could not serve the demo. Build it first.')
})
})
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(Number(process.env.PORT ?? 8787), '127.0.0.1', resolve)
})
const address = server.address()
if (!address || typeof address === 'string') throw new Error('Missing listening address')
console.log(`Open http://127.0.0.1:${address.port}`)
}
main().catch((error: unknown) => { console.error(error); process.exitCode = 1 })
Install locally using Yarn’s node-modules linker, then typecheck, build, and start the server:
corepack yarn install
corepack yarn build && corepack yarn start
Open http://127.0.0.1:8787, click Save with progress, and
choose a new path named fixture-256.bin in the native save dialog. Accept any browser write
permission prompt. Wait for the status beginning Saved.
Leave the server running for verification. Ctrl+C stops it; if port 8787 is occupied, use
PORT=8788 corepack yarn start and the matching origin in the verifier.
Security, permissions, and error handling
The save picker requires user activation. Keep it in the click path; do not await an API request first. On a deployed site, use HTTPS. Choose new, disposable output paths. Approving replacement risks the existing file before success: in the tested Linux GTK dialog, a rejected final close left the approved destination empty. Aborting a failed write does not guarantee restoring that file. A canceled save to a new path can also leave an empty file. Canceling during final filesystem commit cannot guarantee undoing bytes already committed.
Try canceling the picker, canceling after progress starts, and saving again. Cancellation and an HTTP, read, write, or final-close error must never produce the saved status. The hook resets its previous result at every start. It opens a fresh picker each time and stores no handles or permission state, including across reloads or browser restarts. Stored-handle workflows need their own permission checks.
In your own API, return a meaningful status on failure. Without a trusted length or checksum,
a response that ends cleanly but early is indistinguishable from a shorter valid file.
For cross-origin fetch, configure CORS and expose any progress headers you use. A direct link cannot
add a custom Authorization header; use session authentication or an authorized download URL.
Content-Disposition: attachment matters for cross-origin downloads; the download attribute
alone is insufficient.
Check the actual disk file
Save verify.ts. It streams the file from disk and compares both size and hash with the local
fixture manifest. It checks the path you pass, not another generated file or the browser’s status.
import { createHash } from 'node:crypto'
import { createReadStream } from 'node:fs'
async function main(): Promise<void> {
const [path, mib = '256'] = process.argv.slice(2)
if (!path) throw new Error('Usage: node verify.ts /path/to/fixture-256.bin [MiB]')
const origin = process.env.DOWNLOAD_ORIGIN ?? 'http://127.0.0.1:8787'
const response = await fetch(`${origin}/manifest?mib=${encodeURIComponent(mib)}`)
if (!response.ok) throw new Error(`Manifest HTTP ${response.status}`)
const expected: unknown = await response.json()
if (typeof expected !== 'object' || expected === null || !('bytes' in expected) ||
!('sha256' in expected) || typeof expected.bytes !== 'number' || typeof expected.sha256 !== 'string') {
throw new Error('Invalid manifest')
}
const hash = createHash('sha256')
let bytes = 0
for await (const chunk of createReadStream(path)) {
bytes += chunk.length
hash.update(chunk)
}
const actual = hash.digest('hex')
if (bytes !== expected.bytes || actual !== expected.sha256) throw new Error('Size or SHA-256 mismatch')
console.log(`Verified ${bytes} bytes; SHA-256 ${actual}`)
}
main().catch((error: unknown) => { console.error(error); process.exitCode = 1 })
In a second terminal, enter the project and run the verifier with your actual saved path:
node verify.ts "/absolute/path/to/fixture-256.bin" 256
Expect Verified 268435456 bytes; SHA-256 …. A same-length different file must fail with
Size or SHA-256 mismatch. For the 1 MiB option, pass 1 as the final argument. In Firefox,
Save with progress hands a small Blob to the browser and says
Handed to browser. Check your downloads; disk saving is not confirmed.
Use Direct download for the 256 MiB file, then verify that disk
file too. Neither starting a link download nor creating an object URL confirms a save.
Memory usage compared
| Approach | Application buffering | Completion observable by this app |
|---|---|---|
response.blob() | Complete response before handoff | Blob created |
| Stream to selected file | Await one chunk write before the next read | Writable close succeeded |
| Bounded Blob fallback | Up to 50 MiB plus Blob copies | Browser handoff |
| Attachment link | Browser-managed transfer | Use browser downloads UI |
To compare application behavior, save the 256 MiB and 512 MiB options and inspect the read/write loop. It never accumulates chunks in the native path. Slow writes delay the next application read; fetch and the browser may still buffer ahead. JavaScript heap measurements alone cannot establish a cap on whole-browser RAM, a speed improvement, or filesystem durability after a power failure.
Wrap-up
Keep the checksum verification when replacing the fixture with a real export endpoint. Use an independently trusted publisher checksum for that file, and keep a browser-managed attachment link available wherever the native picker is absent. This demo performs a fresh download; it does not resume a canceled transfer or persist access to earlier destinations.
