Implémenter l'upload de fichiers par chunks en JavaScript
Implémentez des uploads chunkés reprenables en JavaScript : gérez les gros fichiers, suivez la progression et récupérez des coupures réseau.
L'upload de fichiers par chunks en JavaScript découpe un grand fichier en morceaux de taille fixe (typiquement 5 à 10 Mo), uploade chacun comme une requête HTTP séparée, et les réassemble sur le serveur. Le schéma résout trois problèmes réels : les navigateurs et les proxies tuent les requêtes au-delà de 2 Go, les réseaux mobiles coupent les connexions en cours d'upload, et les utilisateurs veulent un retour de progression. Une implémentation fonctionnelle utilise File.slice() pour découper les chunks, fetch avec un AbortSignal par chunk, l'assemblage côté serveur via S3 multipart ou un assembleur personnalisé, et un index local dans IndexedDB pour que la reprise survive aux rechargements d'onglet. Le protocole tus.io formalise exactement ce schéma avec l'en-tête Upload-Offset, une référence que l'ANSSI cite parmi les bonnes pratiques pour les transferts sécurisés et reprenables.
Pourquoi les chunks battent les uploads en une seule requête
Un fichier de 4 Go uploadé en une seule requête échoue pour des raisons prévisibles : le client_max_body_size par défaut de Nginx est de 1 Mo, Cloudflare plafonne les uploads du niveau gratuit à 100 Mo par requête, AWS API Gateway s'arrête dur à 10 Mo, et Mobile Safari tue les onglets qui maintiennent un ArrayBuffer de 4 Go en mémoire. Les uploads chunkés contournent chacun de ces plafonds. Vous obtenez aussi des barres de progression qui bougent réellement, des reprises qui ne repartent pas de zéro, et la possibilité de mettre en pause et reprendre. Le compromis est plus d'état côté serveur et plus d'aller-retours — environ une requête HTTP par 5 Mo, ce qui pour un fichier de 10 Go représente 2 000 requêtes.
Choisir la taille des chunks
La taille des chunks est un compromis entre débit et résilience. Trop petite (sous 1 Mo) et vous passez plus de temps sur les handshakes TLS que sur les données. Trop grande (au-dessus de 100 Mo) et une connexion coupée gaspille des minutes d'upload. Le point idéal pour la plupart des réseaux est 5 à 10 Mo, ce qui correspond au minimum de 5 Mo de S3 multipart et s'aligne bien avec les tailles de fenêtre TCP typiques après le slow-start.
Mesurez d'abord le réseau de l'utilisateur :
const downlink = navigator.connection?.downlink ?? 10;
const chunkSize = downlink > 20 ? 10 * 1024 * 1024 : 5 * 1024 * 1024;
Sur une connexion à 100 Mbit, des chunks de 10 Mo se terminent en environ une seconde chacun. Sur 4G, des chunks de 5 Mo offrent une meilleure récupération quand vous passez dans un tunnel.
Découper et hacher le fichier
File.slice() retourne un Blob qui référence les mêmes octets du disque sous-jacent sans copie, donc découper un fichier de 20 Go ne coûte rien :
function* sliceFile(file, chunkSize) {
for (let offset = 0; offset < file.size; offset += chunkSize) {
yield {
index: Math.floor(offset / chunkSize),
blob: file.slice(offset, offset + chunkSize),
start: offset,
end: Math.min(offset + chunkSize, file.size)
};
}
}
Calculez un hachage SHA-256 de chaque chunk avant l'upload pour que le serveur puisse vérifier l'intégrité :
const buffer = await chunk.blob.arrayBuffer();
const digest = await crypto.subtle.digest('SHA-256', buffer);
const hash = Array.from(new Uint8Array(digest))
.map(b => b.toString(16).padStart(2, '0')).join('');
Pour 10 Go de données, le hachage ajoute peut-être 20 secondes sur un ordinateur portable moderne — ça vaut la peine pour détecter les corruptions silencieuses sur les liaisons cellulaires instables.
Uploader avec une concurrence contrôlée
Les uploads séquentiels gaspillent la bande passante ; le parallélisme illimité fait planter le navigateur. Une limite de concurrence de 3 à 4 chunks en vol équilibre les deux :
async function uploadAll(file, sessionId) {
const queue = [...sliceFile(file, 5 * 1024 * 1024)];
const workers = Array.from({ length: 4 }, async () => {
while (queue.length) {
const chunk = queue.shift();
await uploadChunk(chunk, sessionId);
emitProgress(chunk.index);
}
});
await Promise.all(workers);
}
Chaque appel uploadChunk est un PUT /upload/:sessionId/:index avec le blob comme corps et le hachage dans un en-tête. Utilisez AbortController par chunk pour pouvoir annuler des requêtes individuelles sans tuer tout le lot.
Réessayer sans surcharger le serveur
Les erreurs réseau nécessitent un backoff exponentiel, pas des boucles de réessai serrées. Une politique raisonnable : 3 tentatives, délai de base 500 ms, jitter jusqu'à 50 % :
async function uploadChunk(chunk, sessionId, attempt = 0) {
try {
const res = await fetch(`/upload/${sessionId}/${chunk.index}`, {
method: 'PUT', body: chunk.blob, headers: { 'X-Hash': chunk.hash }
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
} catch (e) {
if (attempt >= 3) throw e;
const delay = 500 * 2 ** attempt + Math.random() * 250;
await new Promise(r => setTimeout(r, delay));
return uploadChunk(chunk, sessionId, attempt + 1);
}
}
Traitez les réponses 5xx comme récupérables, les 4xx comme fatales (sauf 408 et 429). Sur 429, respectez l'en-tête Retry-After plutôt que votre backoff local.
Reprendre après un rechargement d'onglet
Persistez l'état d'upload dans IndexedDB après chaque chunk réussi :
await db.put('uploads', {
sessionId, fileName: file.name, fileSize: file.size,
completedChunks: [...completedSet], updatedAt: Date.now()
}, sessionId);
Quand l'utilisateur rouvre la page avec le même sélecteur de fichiers, comparez la size, lastModified et le nom du fichier avec les sessions stockées. S'il y a une correspondance, demandez au serveur quels chunks il a déjà reçus (un simple GET /upload/:sessionId/status retournant un bitmap fonctionne), puis uploadez seulement les manquants. Le protocole tus.io formalise exactement ce schéma avec l'en-tête Upload-Offset, et la bibliothèque tus-js-client livre une implémentation solide si vous ne voulez pas la développer vous-même.
Assembler les chunks sur le serveur
Deux options sérieuses : l'upload multipart S3, où chaque chunk devient un PartNumber et un CompleteMultipartUpload final les assemble, ou un assembleur personnalisé qui écrit chaque chunk dans un fichier temporaire et les concatène à la fin. Le multipart S3 est moins coûteux à grande échelle car vous ne payez jamais l'egress pendant l'assemblage, et R2 offre des lectures sans egress. L'approche personnalisée est plus simple à déboguer et vous permet de stream-chiffrer pendant l'assemblage.
Pour le style S3 :
const upload = await s3.createMultipartUpload({ Bucket, Key });
// par chunk : s3.uploadPart({ UploadId, PartNumber, Body })
await s3.completeMultipartUpload({ UploadId, MultipartUpload: { Parts } });
Attention à la limite de 10 000 parties — pour les fichiers de plus de 50 Go, vous avez besoin de chunks de 5 Mo+ pour rester en dessous.
Suivre la progression que les utilisateurs font confiance
Les barres de progression qui sautent semblent cassées. Calculez la progression en octets uploadés sur le total des octets, pas en chunks complétés, et lissez avec une moyenne mobile sur 2 secondes pour cacher la gigue. Utilisez fetch avec un ReadableStream et un Transform pour compter les octets, car XMLHttpRequest.upload.onprogress ne se déclenche pas toujours de manière fiable sur HTTP/3. Affichez un ETA en divisant les octets restants par le débit en cours, mais clampez à au moins 5 secondes pour éviter l'infâme expérience « 2 secondes restantes... pendant 10 minutes ».
Éviter les pièges classiques
Trois erreurs tuent les uploads chunkés en production : oublier de définir Content-Length par chunk (casse certains proxies edge), réutiliser le même ID de session pour des fichiers différents (corrompt l'assemblage), et laisser l'utilisateur changer le fichier en cours d'upload sans versionner la session. Hachez toujours le premier 1 Mo du fichier plus sa taille et lastModified pour prendre les sessions en empreinte digitale. Et ne faites jamais confiance au lastModified seul — macOS Finder le met à jour lors des changements de métadonnées.
HexaTransfer utilise un pipeline chunké + reprenabled comme celui-ci sous le capot pour ses uploads de 10 Go, avec AES-256-GCM côté client ajouté à chaque chunk avant le PUT.
Vue d'ensemble
Un uploader chunké de niveau production représente environ 300 lignes de JavaScript : découpez avec File.slice, hachez avec SubtleCrypto, uploadez 3 à 4 chunks en parallèle avec backoff exponentiel, persistez l'état de session dans IndexedDB, et laissez le serveur assembler les parties via S3 multipart ou un assembleur personnalisé. Testez-le contre les basculements en mode avion, les rechargements d'onglet, et un fichier de 15 Go sur 4G avant de lui faire confiance.
Essayez-le sur https://hexatransfer.com — gratuit, sans compte, 10 Go maximum.
Envoyez vos fichiers volumineux en toute sécurité avec le chiffrement de bout en bout
Transférez des fichiers jusqu'à 10 Go gratuitement avec le chiffrement de bout en bout. Aucun compte requis. Vos fichiers sont chiffrés dans votre navigateur avant l'envoi — personne d'autre ne peut les lire.
Envoyer un fichier