Télécharger de gros fichiers en flux dans React sans souci de RAM
Pour un téléchargement volumineux dans React, attendez chaque écriture dans un fichier choisi par l’utilisateur plutôt que de collecter la réponse dans un Blob. Ce guide monte une application React complète, sert localement un flux binaire connu de 256 MiB et vérifie le nombre d’octets et le SHA-256 du fichier enregistré. Le traitement en flux borne les blocs conservés par votre application ; les tampons du navigateur, du réseau et du système de fichiers utilisent toujours de la mémoire.
Pourquoi les téléchargements classiques via Blob échouent avec de gros fichiers
Le schéma habituel fetch → blob → link.click() attend la réponse complète avant de la transmettre
au navigateur. Response.blob()
lit le corps jusqu’à la fin. Le navigateur choisit le stockage sous-jacent du Blob : il ne s’agit
donc pas nécessairement d’une allocation géante dans le tas JavaScript, mais le fichier entier est
tout de même matérialisé.
Utilisez la File System Access API si vous avez besoin d’un sélecteur de destination et d’un suivi de progression dans l’application, sur les navigateurs Chromium de bureau compatibles. Pour un gros fichier dans Firefox ou Safari, proposez un lien ordinaire vers un endpoint servant une pièce jointe. Le navigateur gère ce transfert en dehors de votre tableau JavaScript de blocs de données. Cet exemple comprend aussi une solution de repli via Blob, volontairement limitée aux petits fichiers. Il s’agit ici d’enregistrer de nouvelles destinations de téléchargement ; stocker et rouvrir des handles de fichiers locaux relève d’une procédure de gestion persistante des fichiers distincte.
Transférer les données en flux avec les API fetch et de flux
Utilisez Bash, Node.js 24.15 ou une version plus récente de la branche 24 LTS maintenue, ainsi que Corepack avec Yarn 4.12.0. Installez Corepack séparément si votre distribution Node ne l’inclut pas. Le parcours utilisant le sélecteur natif a été testé sous Linux avec Chromium 145 ; les parcours utilisant un lien et un Blob ont été testés dans Firefox. Utilisez un nouveau répertoire en dehors d’un projet ou d’un espace de travail existant. Ce bloc de création avec garde-fous vérifie les prérequis et la configuration des packages parents avant toute écriture :
(
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
Un répertoire react-stream-demo existant est refusé. Conservez ses fichiers et choisissez
un autre répertoire parent vide plutôt que de le supprimer. Si l’installation échoue ensuite,
réessayez dans ce nouveau projet ; aucune des étapes suivantes ne nécessite de le recréer.
Enregistrez ces fichiers dans le nouveau répertoire. package.json fixe les versions
des dépendances de la démonstration :
{
"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"
}
}
Enregistrez tsconfig.json. Cette configuration est complète, sans
extends hérité :
{
"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"]
}
Enregistrez .yarnrc.yml pour que l’installation, les builds et le démarrage
utilisent le même mécanisme de liaison :
nodeLinker: node-modules
Enregistrez index.html ; Vite regroupe le point d’entrée TypeScript :
<!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>
Compatibilité des navigateurs en bref
| Navigateur | Sélecteur de destination | Parcours pour les gros fichiers ici |
|---|---|---|
| Chrome et Edge de bureau | Détecter showSaveFilePicker | Transfert en flux vers le fichier choisi |
| Firefox et Safari | Pas de showSaveFilePicker | Lien vers une pièce jointe |
Consultez la documentation MDN du sélecteur pour connaître la compatibilité actuelle. La prise en charge du système de fichiers privé de l’origine n’implique pas celle d’un sélecteur d’enregistrement. Le sélecteur nécessite un contexte sécurisé et une action de l’utilisateur ; HTTP sur l’interface de bouclage convient à cette démo locale. Node 24 est une version LTS maintenue ; son exécution native de TypeScript suffit pour le serveur et le vérificateur, tandis que Vite compile le TSX pour le navigateur.
Enregistrez downloads.ts. Les deux parcours utilisent la même boucle de lecture.
Elle attend que le consommateur ait terminé avant de demander un autre bloc, annule la réponse en
cas d’échec et libère le lecteur. Un Content-Length absent ou correspondant à un
contenu compressé produit une progression en octets uniquement : fetch expose les octets décodés du
corps, donc une longueur sur le réseau correspondant à un contenu
compressé n’est pas un dénominateur valide.
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' }
}
Le parcours via Blob limite les octets réellement décodés à 50 MiB, y compris pour les réponses sans longueur. La création du Blob peut nécessiter des copies supplémentaires : il s’agit donc d’une limite d’entrée de l’application, pas d’un plafond de RAM pour le navigateur. Le parcours natif ne se termine qu’après la fermeture du flux d’écriture. Il ne valide pas le contenu du serveur ; l’étape de vérification de la somme de contrôle ci-dessous s’en charge indépendamment.
Construire un hook React réutilisable
Enregistrez useDownload.ts. Appelez start directement depuis
un gestionnaire de clic : ouvrir le sélecteur avant toute attente réseau préserve l’activation
utilisateur. Le garde-fou d’occupation empêche les démarrages simultanés, et le démontage interrompt
le travail en cours afin qu’un ancien transfert ne puisse pas mettre à jour un composant ultérieur.
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 }
}
Enregistrez main.tsx. L’option 256 MiB utilise le parcours natif ; sélectionnez
1 MiB pour essayer la solution de repli via Blob dans Firefox. Le lien ordinaire reste disponible
pour les gros fichiers.
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 />)
Servir un flux binaire connu
Enregistrez server.ts. Il sert l’application compilée et une pièce jointe non
compressée depuis la même origine : cet exemple local ne nécessite donc ni CORS ni authentification.
Chaque bloc de 64 KiB contient un motif binaire répété, comprenant des octets nuls. Le pipe de Node
respecte la contre-pression de la réponse ; la déconnexion d’un client détruit le producteur.
/manifest?mib=256 fournit le nombre d’octets attendu et le SHA-256 sans envoyer le
fichier lui-même.
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 })
Installez localement avec le mécanisme de liaison node-modules de Yarn, puis
vérifiez les types, compilez et démarrez le serveur :
corepack yarn install
corepack yarn build && corepack yarn start
Ouvrez http://127.0.0.1:8787, cliquez sur Save with progress et
choisissez un nouveau chemin nommé fixture-256.bin dans la boîte de dialogue native
d’enregistrement. Acceptez toute demande d’autorisation d’écriture du navigateur. Attendez le
statut commençant par Saved.
Laissez le serveur actif pour la vérification. Ctrl+C l’arrête ; si le port 8787 est occupé, utilisez
PORT=8788 corepack yarn start et l’origine correspondante dans le vérificateur.
Sécurité, autorisations et gestion des erreurs
Le sélecteur d’enregistrement nécessite une activation utilisateur. Gardez son ouverture dans le traitement du clic ; n’attendez pas d’abord une requête API. Sur un site déployé, utilisez HTTPS. Choisissez de nouveaux chemins de sortie dont le contenu pourra être supprimé. Autoriser le remplacement met le fichier existant en danger avant la réussite de l’opération : dans la boîte de dialogue GTK testée sous Linux, une fermeture finale rejetée a laissé vide la destination dont le remplacement avait été autorisé. Interrompre une écriture qui a échoué ne garantit pas la restauration de ce fichier. Un enregistrement annulé vers un nouveau chemin peut aussi laisser un fichier vide. Une annulation pendant la validation finale des écritures par le système de fichiers ne peut pas garantir la suppression des octets déjà écrits définitivement.
Essayez d’annuler le sélecteur, d’annuler après le début de la progression, puis d’enregistrer à nouveau. Une annulation ou une erreur HTTP, de lecture, d’écriture ou de fermeture finale ne doit jamais produire le statut d’enregistrement réussi. Le hook réinitialise son résultat précédent à chaque démarrage. Il ouvre un nouveau sélecteur à chaque fois et ne stocke aucun handle ni état d’autorisation, y compris entre les rechargements ou les redémarrages du navigateur. Les procédures utilisant des handles stockés nécessitent leurs propres vérifications d’autorisation.
Dans votre propre API, renvoyez un statut explicite en cas d’échec. Sans longueur ni somme de
contrôle fiables, une réponse qui se termine sans erreur mais prématurément est impossible à
distinguer d’un fichier valide plus court. Pour un fetch entre origines, configurez CORS et exposez
les en-têtes de progression que vous utilisez. Un lien direct ne peut pas ajouter d’en-tête
Authorization personnalisé ; utilisez une authentification par session ou une URL de téléchargement
autorisée. Content-Disposition: attachment joue un rôle dans les
téléchargements entre origines ; l’attribut download seul ne suffit pas.
Vérifier le fichier réellement enregistré sur disque
Enregistrez verify.ts. Il lit le fichier sur disque en flux et compare à la
fois sa taille et son empreinte au manifeste des données de test locales. Il vérifie le chemin
que vous lui transmettez, et non un autre fichier généré ou le statut du navigateur.
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 })
Dans un second terminal, placez-vous dans le projet et exécutez le vérificateur avec le chemin réel de votre fichier enregistré :
node verify.ts "/absolute/path/to/fixture-256.bin" 256
Le résultat attendu est Verified 268435456 bytes; SHA-256 …. Un fichier différent de même longueur doit
échouer avec Size or SHA-256 mismatch. Pour l’option 1 MiB, passez
1 comme dernier argument. Dans Firefox,
Save with progress transmet un petit Blob au navigateur et affiche
Handed to browser. Check your downloads; disk saving is not confirmed.
Utilisez Direct download pour le fichier de 256 MiB, puis
vérifiez aussi ce fichier sur disque. Ni le démarrage d’un téléchargement par lien ni la création
d’une URL d’objet ne confirment un enregistrement.
Comparaison de l’utilisation de la mémoire
| Approche | Mise en tampon par l’application | Fin observable par cette application |
|---|---|---|
response.blob() | Réponse complète avant transmission | Blob créé |
| Transfert en flux vers le fichier choisi | Attendre l’écriture d’un bloc avant la lecture suivante | Fermeture du flux d’écriture réussie |
| Solution de repli via Blob avec limite | Jusqu’à 50 MiB, plus les copies du Blob | Transmission au navigateur |
| Lien vers une pièce jointe | Transfert géré par le navigateur | Consulter l’interface des téléchargements du navigateur |
Pour comparer le comportement de l’application, enregistrez les options 256 MiB et 512 MiB et inspectez la boucle de lecture et d’écriture. Elle n’accumule jamais de blocs dans le parcours natif. Les écritures lentes retardent la lecture suivante de l’application ; fetch et le navigateur peuvent néanmoins mettre des données en tampon à l’avance. Les seules mesures du tas JavaScript ne permettent pas d’établir un plafond de RAM pour l’ensemble du navigateur, un gain de vitesse ou la persistance des données du système de fichiers après une panne de courant.
Conclusion
Conservez la vérification de la somme de contrôle lorsque vous remplacez les données de test par un véritable endpoint d’export. Utilisez pour ce fichier une somme de contrôle de l’éditeur dont la fiabilité a été établie indépendamment, et gardez un lien vers une pièce jointe géré par le navigateur partout où le sélecteur natif est absent. Cette démo effectue un nouveau téléchargement ; elle ne reprend pas un transfert annulé et ne conserve pas l’accès aux destinations précédentes.
