Große Dateien in React ohne Speicherprobleme streamen
Das Herunterladen großer Dateien in React-Anwendungen wird heikel, sobald die Dateien über einige
hundert Megabyte hinauswachsen. Das naive Muster fetch → blob → link.click() hält die gesamte
Datei im Speicher – ein schneller Weg zu Tab-Abstürzen und verärgerten Nutzern. Glücklicherweise
erlauben moderne Browser-APIs, Daten aus dem Netzwerk direkt auf die Festplatte des Nutzers zu
streamen, ohne die gesamte Datei in JavaScript vorzuhalten. Streaming benötigt weiterhin Puffer in
Browser, Netzwerk und Dateisystem; es kommt nicht ohne Speicher aus.
Warum klassische Blob-Downloads großer Dateien scheitern
Ein klassischer Download-Helfer sieht so aus:
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)
}
Probleme treten auf, sobald die Datei größer ist als das verfügbare Speicherbudget des Nutzers:
- Die vollständige Antwort wird vor dem Speichern materialisiert; der zugrunde liegende Speicher und der RAM-Spitzenbedarf hängen vom Browser ab und können mit der Dateigröße wachsen.
- Das Puffern verursacht zusätzlichen Allokations- und Verarbeitungsaufwand, obwohl
blob()selbst asynchron ist. - Dieser Helfer bietet keine Fortschrittsrückmeldung, während die Antwort gelesen wird.
- Die Download-Einstellungen des Browsers bestimmen den Speicherort und ob der Nutzer gefragt wird.
Daten mit den fetch- und Streams-APIs streamen
fetch() liefert uns einen ReadableStream in response.body. Statt Chunks in einem Array zu puffern
(das erneut mit der Dateigröße wachsen würde), können wir jeden Chunk direkt an einen WritableStream weiterleiten.
Ist die File System Access API verfügbar, verweist dieser Writable auf die vom Nutzer ausgewählte
Datei auf der Festplatte, sodass die Pufferung in der Anwendung begrenzt bleibt, statt proportional
zur Dateigröße zu wachsen.
Speichern Sie die folgenden Helfer in downloads.ts in einem React-TypeScript-Projekt. Wenn Ihre
DOM-Typen die Picker-API nicht deklarieren, installieren Sie deren Deklarationen. Binden Sie wicg-file-system-access ein,
falls Ihre TypeScript-Konfiguration die Liste types einschränkt. Aktivieren Sie allowImportingTsExtensions und noEmit
für die in diesem Beispiel verwendeten Importe von .ts; der Bundler kümmert sich um die
JavaScript-Ausgabe:
npm install --save-dev @types/wicg-file-system-access
Rufen Sie streamToDisk direkt aus einem Click-Handler auf, damit der Picker über eine
Nutzeraktivierung verfügt. Die Funktion gibt Fehler und Abbrüche an die aufrufende Stelle weiter,
bricht unvollständige Schreibvorgänge ab und gibt ihren Reader frei. Der Server muss die Anfrage per
CORS zulassen, wenn er auf einem anderen Ursprung liegt.
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
}
}
Die wichtigsten Erkenntnisse:
- Es liegt kein riesiger
Blobim Speicher; die Chunks wandern direkt auf die Festplatte. - Für den Fortschritt ist ein korrekter Wert in
Content-Lengtherforderlich, der mit den decodierten Body-Bytes übereinstimmt. Liefern Sie Downloads für diese Berechnung ohne Inhaltskomprimierung aus; zeigen Sie andernfalls einen unbestimmten Fortschritt an. - Der Fortschritt bleibt unter 100 %, bis der Writable erfolgreich geschlossen wird.
- Der Save-Picker steht in unterstützenden Chromium-Browsern zur Verfügung, nicht jedoch in Firefox oder Safari.
Browser-Unterstützung auf einen Blick
| Browser | showSaveFilePicker() |
|---|---|
| Chrome Desktop | 86+ |
| Edge Desktop | 86+ |
| Firefox | Nicht unterstützt |
| Safari | Nicht unterstützt |
Dies sind Picker-spezifische Ergebnisse aus MDNs Kompatibilitätsdaten, geprüft im September 2026. Unterstützung für das ursprungsprivate Dateisystem bedeutet nicht, dass auch ein Save-Picker unterstützt wird. Prüfen Sie die Verfügbarkeit stets zur Laufzeit.
Bevorzugen Sie für große Dateien in Browsern ohne diese API einen normalen Download-Link zu einem
Endpunkt, der Content-Disposition: attachment zurückgibt. Der Browser verwaltet den Download ohne ein
JavaScript-Array aus Chunks. Verwenden Sie eine Session-Authentifizierung mit gleichem Ursprung oder
eine autorisierte Download-URL, wenn ein Link die üblichen Autorisierungs-Header der API nicht
mitliefern kann. Das Attribut download allein reicht für beliebige ursprungsübergreifende
URLs nicht aus.
Nur für kleine Dateien kann ein Blob-Fallback praktisch sein. Auch das Lesen in Chunks behält die
gesamte Datei im Speicher. Dieser Helfer erzwingt ein Anwendungslimit von 50 MiB, sowohl gegenüber
einer deklarierten Länge als auch gegenüber den tatsächlich empfangenen Bytes; senken Sie es für die
Geräte Ihrer Zielgruppe. Beim Erstellen eines Blobs können vorübergehend zusätzliche Kopien nötig
sein, es handelt sich also nicht um eine Obergrenze von 50 MiB für den Browser-RAM. Hängen Sie dies
an downloads.ts an:
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()
}
}
Der Fallback wird aufgelöst, sobald er den Blob an den Browser übergibt. JavaScript kann nicht
bestätigen, dass der Nutzer ihn auf der Festplatte gespeichert hat. Eine Bibliothek wie browser-fs-access kann
die Browser-Integration vereinfachen, ihr Blob-Fallback hat jedoch dieselbe Einschränkung, dass die
gesamte Datei gepuffert wird.
Einen wiederverwendbaren React-Hook erstellen
Speichern Sie diesen Hook als useDownload.ts. Er verwendet die obigen Helfer, verhindert sich
überschneidende Downloads und bricht laufende Arbeit ab, wenn die Komponente unmountet wird. Ein
abgebrochener Picker oder Transfer meldet keinen Erfolg.
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 }
}
Die Verwendung des Hooks in einer Komponente ist nun 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>
)
}
Sicherheit, Berechtigungen und Fehlerbehandlung
Die File System Access API ist mächtig und daher durch mehrere Schutzmechanismen abgesichert. Diese zu verstehen ist entscheidend für eine reibungslose Nutzererfahrung und eine robuste Fehlerbehandlung.
- Sicherer Kontext: Die Seite muss über HTTPS oder von
localhostausgeliefert werden. Istwindow.isSecureContextgleichfalse, stehtshowSaveFilePicker()nicht zur Verfügung. Ihr Code sollte dies prüfen und gegebenenfalls den Nutzer informieren oder den Fallback verwenden. - Nutzergeste: Der Dateiauswahldialog lässt sich nur als direkte Reaktion auf eine Nutzerinteraktion öffnen, etwa einen Klick oder einen Tastendruck. Programmatische Aufrufe ohne vorangehende Nutzergeste schlagen fehl.
- Berechtigungsumfang: Der Zugriff wird nur auf die vom Nutzer ausgewählte Datei gewährt. Ihre Anwendung kann nicht in andere Dateien oder an andere Speicherorte schreiben, ohne dass der Nutzer dies jeweils explizit erlaubt.
- Dauerhaftigkeit der Berechtigung: Gehen Sie nicht davon aus, dass ein gespeicherter Handle die Berechtigung behält. Dieser Helfer öffnet den Picker bei jedem Speichervorgang; Workflows, die Handles aufbewahren, sollten die Berechtigung vor der Wiederverwendung abfragen.
- Eingeschränkte Ordner: Browser verhindern den Zugriff auf sensible Systemverzeichnisse. Der Dateiauswahldialog filtert diese heraus, sodass Nutzer sie nicht versehentlich (oder in böswilliger Absicht) auswählen können.
- Fehlerbehandlung: Es ist entscheidend, Aufrufe von
showSaveFilePicker()und nachfolgende Stream-Operationen in Blöcke mittry...catcheinzuschließen.AbortError: Dieser Fehler wird ausgelöst, wenn der Nutzer den Dateiauswahldialog schließt (z. B. durch Klicken auf „Abbrechen“). Das ist ein häufiges Szenario und sollte elegant behandelt werden, etwa indem der UI-Zustand zurückgesetzt wird, ohne eine aggressive Fehlermeldung anzuzeigen.- Andere Fehler: Auch Netzwerkprobleme, begrenzter Speicherplatz oder unerwartetes API-Verhalten können zu Fehlern führen. Protokollieren Sie diese für die Fehlersuche und geben Sie eine nutzerfreundliche Meldung aus.
Der oben gezeigte Helfer streamToDisk behandelt diese Fehler in dem Pfad, den der Hook
tatsächlich verwendet. Er bricht den Writable bei einem Fehler ab und bricht in finally den
Response-Reader ab und gibt ihn frei. Er schließt den Writable erst, nachdem alle Bytes gelesen
wurden. Ein Abbruch während des finalen Commits im Dateisystem kann nicht garantieren, dass ein
bereits abgeschlossener Speichervorgang rückgängig gemacht wird. Ebenso kann der Blob-Fallback einen
Browser-Download nicht mehr abbrechen, nachdem er ihn übergeben hat.
Speicherverbrauch im Vergleich
| Ansatz | Anwendungspufferung | Fortschritt |
|---|---|---|
response.blob() | Vollständige Antwort vor dem Speichern; browserabhängiger Speicher | Von diesem Helfer nicht bereitgestellt |
| Streaming zu File System Access | Jeweils ein Chunk plus Browser- und Dateisystempuffer | Wenn eine korrekte Länge verfügbar ist |
| Begrenzter Blob-Fallback | Gesamte Datei bis 50 MiB, plus temporäre Kopien | Wenn eine korrekte Länge verfügbar ist |
| Normaler Download-Link | Vom Browser verwaltet, außerhalb dieses JavaScript-Puffers | Download-UI des Browsers |
Fazit
Mit Streams aus fetch() und der File System Access API können Sie Nutzer gigabytegroße Assets
abrufen lassen, ohne die vollständige Datei in JavaScript vorzuhalten. Verwenden Sie den begrenzten
Blob-Fallback nur für kleine Dateien. Stellen Sie für große Downloads in Browsern ohne Save-Picker
einen normalen Download-Endpunkt bereit und überlassen Sie dem Browser die Übertragung.
Brauchen Sie eine passende Lösung für Uploads? Werfen Sie einen Blick auf unseren Robot, der den Service für Datei-Uploads antreibt – er fügt sich perfekt in denselben Workflow ein.
