Implementa subida fragmentada de archivos en JavaScript
Implementa subidas fragmentadas y reanudables en JavaScript: gestiona archivos grandes, rastrea el progreso y recupérate ante cortes de red.
La subida fragmentada en JavaScript divide un archivo grande en piezas de tamaño fijo, normalmente de 5 a 10 MB, sube cada una como una petición HTTP independiente y las reensambla en el servidor. INCIBE señala en sus guías de desarrollo seguro que las transferencias de archivos grandes sin mecanismo de reanudación son un punto de fallo crítico en aplicaciones web. La solución: File.slice() para cortar fragmentos, fetch con un AbortSignal por fragmento, ensamblado en el servidor vía S3 multipart o un combinador propio, y un índice local en IndexedDB para que la reanudación sobreviva a recargas del navegador.
Por qué los fragmentos superan las subidas de un solo envío
Un archivo de 4 GB subido como una sola petición falla por razones predecibles: el client_max_body_size predeterminado de Nginx es 1 MB, Cloudflare limita las subidas gratuitas a 100 MB por petición, AWS API Gateway corta a los 10 MB, y Safari móvil mata las pestañas que mantienen un ArrayBuffer de 4 GB en memoria. Las subidas fragmentadas esquivan todos esos límites. También obtienes barras de progreso que realmente avanzan, reintentos que no empiezan de cero y la capacidad de pausar y reanudar. El coste es más estado en el servidor y más peticiones: aproximadamente una petición HTTP por cada 5 MB, lo que en un archivo de 10 GB significa 2.000 peticiones.
Elegir el tamaño del fragmento
El tamaño del fragmento es un equilibrio entre rendimiento y resiliencia. Demasiado pequeño (menos de 1 MB) y pasas más tiempo en handshakes TLS que en datos. Demasiado grande (más de 100 MB) y una conexión cortada desperdicia minutos de subida. El punto óptimo para la mayoría de redes es 5-10 MB, que coincide con el mínimo de 5 MB de S3 multipart y se alinea bien con los tamaños de ventana TCP típicos tras el slow-start.
Mide primero la red del usuario:
const downlink = navigator.connection?.downlink ?? 10;
const chunkSize = downlink > 20 ? 10 * 1024 * 1024 : 5 * 1024 * 1024;
En una conexión de 100 Mbit, los fragmentos de 10 MB terminan en aproximadamente un segundo cada uno. En 4G, los fragmentos de 5 MB ofrecen mejor recuperación cuando entras en un túnel.
Dividir y calcular el hash del archivo
File.slice() devuelve un Blob que referencia los mismos bytes en disco sin copiarlos, así que dividir un archivo de 20 GB no tiene coste:
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)
};
}
}
Calcula un hash SHA-256 de cada fragmento antes de subirlo para que el servidor pueda verificar la integridad:
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('');
Para 10 GB de datos, el hash añade unos 20 segundos en un portátil moderno, tiempo que vale la pena para detectar corrupción silenciosa en enlaces celulares inestables.
Subir con concurrencia controlada
Las subidas secuenciales desperdician ancho de banda; el paralelismo ilimitado bloquea el navegador. Un límite de 3-4 fragmentos en vuelo simultáneo equilibra ambas cosas:
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);
}
Cada llamada a uploadChunk es un PUT /upload/:sessionId/:index con el blob como cuerpo y el hash en una cabecera. Usa AbortController por fragmento para poder cancelar peticiones individuales sin matar todo el lote.
Reintentos sin saturar el servidor
Los errores de red necesitan retroceso exponencial, no bucles de reintento cerrados. Una política razonable: 3 intentos, retraso base 500 ms, aleatoriedad hasta el 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);
}
}
Trata las respuestas 5xx como reintentables y las 4xx como fatales (excepto 408 y 429). Con 429, respeta la cabecera Retry-After en lugar de tu retroceso local.
Reanudar tras recargar la pestaña
Persiste el estado de subida en IndexedDB tras cada fragmento completado:
await db.put('uploads', {
sessionId, fileName: file.name, fileSize: file.size,
completedChunks: [...completedSet], updatedAt: Date.now()
}, sessionId);
Cuando el usuario vuelve a abrir la página con el mismo selector de archivos, compara el size, lastModified y nombre del archivo con las sesiones almacenadas. Si hay coincidencia, pregunta al servidor qué fragmentos ya recibió (un simple GET /upload/:sessionId/status que devuelve un mapa de bits funciona), y sube solo los que faltan. El protocolo tus.io formaliza exactamente este patrón con la cabecera Upload-Offset, y la biblioteca tus-js-client ofrece una implementación sólida si no quieres construirlo desde cero.
Ensamblar fragmentos en el servidor
Dos opciones serias: S3 multipart, donde cada fragmento se convierte en un PartNumber y un CompleteMultipartUpload final los une, o un ensamblador propio que escribe cada fragmento en un archivo temporal y los concatena al final. S3 multipart es más económico a escala porque nunca pagas egreso durante el ensamblado y R2 ofrece lecturas sin egreso. El enfoque propio es más simple de depurar y permite cifrar en streaming durante el ensamblado.
const upload = await s3.createMultipartUpload({ Bucket, Key });
// por fragmento: s3.uploadPart({ UploadId, PartNumber, Body })
await s3.completeMultipartUpload({ UploadId, MultipartUpload: { Parts } });
Vigila el límite de 10.000 partes: para archivos superiores a 50 GB necesitas fragmentos de más de 5 MB para mantenerte por debajo.
Progreso en el que los usuarios confían
Las barras de progreso que saltan parecen rotas. Calcula el progreso como bytes subidos sobre bytes totales, no como fragmentos completados, y suavízalo con una media móvil de 2 segundos para ocultar la inestabilidad. Usa fetch con un ReadableStream y un Transform para contar bytes, ya que XMLHttpRequest.upload.onprogress no siempre se dispara correctamente en HTTP/3. Muestra un tiempo estimado dividiendo los bytes restantes entre el rendimiento reciente, pero limita el mínimo a 5 segundos para evitar el famoso "2 segundos restantes... durante 10 minutos".
HexaTransfer usa una canalización fragmentada y reanudable como esta para sus subidas de 10 GB, con AES-256-GCM en el lado del cliente añadido a cada fragmento antes del PUT. Pruébalo en https://hexatransfer.com — gratuito, sin cuenta, hasta 10 GB.
Errores frecuentes en producción
Tres errores matan las subidas fragmentadas en producción: olvidar establecer Content-Length por fragmento (rompe algunos proxies), reutilizar el mismo ID de sesión para archivos distintos (corrompe el ensamblado), y permitir que el usuario cambie el archivo durante la subida sin versionar la sesión. Siempre calcula el hash del primer MB del archivo más su tamaño y lastModified para identificar sesiones. Y nunca confíes solo en lastModified: macOS Finder lo actualiza cuando cambian los metadatos, aunque el contenido no haya cambiado.
Envía archivos grandes de forma segura con cifrado de extremo a extremo
Transfiere archivos de hasta 10 GB gratis con cifrado de extremo a extremo. Sin necesidad de cuenta. Tus archivos se cifran en tu navegador antes de subirlos: nadie más puede leerlos.
Enviar un archivo