¿Cómo manejas de forma confiable la subida de archivos grandes: uploads por partes y reanudables?
Divide el archivo en partes que el cliente puede reintentar por separado, registra cuáles ya tiene el servidor, y reanudar se vuelve una consulta en vez de empezar de cero.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
¿Cómo manejas de forma confiable la subida de archivos grandes: uploads por partes y reanudables?
Usa los ejemplos y la explicación de la lección para comprobar tu idea.
Reflexión personal. No se envía a ningún servicio de IA.#Por qué "simplemente subir el archivo" se rompe a escala
Un único PUT de un video de 4 GB sobre una conexión móvil inestable es apostar a que la conexión sobrevive toda la transferencia, sin crédito parcial por ningún byte ya enviado. Si se cae al 95%, la implementación ingenua reinicia desde 0%. La solución es dejar de tratar la subida como una operación atómica única y tratarla como una sesión con estado: dividir el archivo en partes, subir cada parte de forma independiente, y darle al cliente una forma de preguntar "¿qué ya tienes?" para que solo envíe lo que falta.
Esta es exactamente la forma de la API de multipart upload de Amazon S3, del protocolo de subida reanudable de Google, y del protocolo abierto tus — formatos de red distintos, la misma idea subyacente.
#El ciclo de vida de una sesión de subida
La propiedad crítica: el cliente nunca tiene que adivinar dónde se quedó. Le pregunta al servidor, recibe la lista autoritativa de índices de partes recibidas, y reanuda desde el primer hueco.
#Implementación
A continuación, un servicio de subida reanudable real (simplificado pero funcional) en Node/Express. Cada decisión de diseño se corresponde con un requisito de confiabilidad:
- Índice de parte + checksum, no solo bytes — un PUT reintentado para el mismo índice es idempotente: sobrescribe, no duplica.
- Una sesión que se puede consultar —
GET /uploads/:iddevuelve exactamente lo que ya se persistió, que es lo que hace posible reanudar. - El ensamblado solo ocurre cuando todas las partes están presentes — la finalización parcial se rechaza explícitamente en vez de producir silenciosamente un archivo truncado.
const express = require('express');const crypto = require('crypto');const fs = require('fs');const path = require('path');const app = express();const UPLOAD_DIR = './uploads';// uploadId -> { fileName, chunkSize, totalChunks, receivedChunks: Set<number> }const sessions = new Map();app.use(express.json());// 1. Crear una sesión antes de enviar cualquier byte.app.post('/uploads', (req, res) => {const { fileName, totalSize, chunkSize } = req.body;const uploadId = crypto.randomUUID();const totalChunks = Math.ceil(totalSize / chunkSize);fs.mkdirSync(path.join(UPLOAD_DIR, uploadId), { recursive: true });sessions.set(uploadId, {fileName,chunkSize,totalChunks,receivedChunks: new Set(),createdAt: Date.now(),});res.status(201).json({ uploadId, totalChunks });});// 2. Soporte de reanudación: decirle al cliente exactamente qué ya tenemos.app.get('/uploads/:id', (req, res) => {const session = sessions.get(req.params.id);if (!session) return res.status(404).json({ error: 'sesión de subida desconocida' });res.json({receivedChunks: [...session.receivedChunks].sort((a, b) => a - b),totalChunks: session.totalChunks,});});// 3. Subir una sola parte. Segura de reintentar: el mismo índice solo sobrescribe.app.put('/uploads/:id/chunks/:index',express.raw({ type: '*/*', limit: '20mb' }),(req, res) => {const session = sessions.get(req.params.id);if (!session) return res.status(404).json({ error: 'sesión de subida desconocida' });const index = Number(req.params.index);const expectedChecksum = req.header('X-Chunk-Checksum');const actualChecksum = crypto.createHash('sha256').update(req.body).digest('hex');if (expectedChecksum && expectedChecksum !== actualChecksum) {return res.status(409).json({ error: 'checksum no coincide, reenvía esta parte' });}const chunkPath = path.join(UPLOAD_DIR, req.params.id, String(index));fs.writeFileSync(chunkPath, req.body);session.receivedChunks.add(index);res.json({ received: index, remaining: session.totalChunks - session.receivedChunks.size });});// 4. Finalizar: solo ensamblar cuando todas las partes realmente llegaron.app.post('/uploads/:id/complete', (req, res) => {const session = sessions.get(req.params.id);if (!session) return res.status(404).json({ error: 'sesión de subida desconocida' });const missing = [];for (let i = 0; i < session.totalChunks; i++) {if (!session.receivedChunks.has(i)) missing.push(i);}if (missing.length > 0) {return res.status(400).json({ error: 'subida incompleta', missing });}const finalPath = path.join(UPLOAD_DIR, session.fileName);const output = fs.createWriteStream(finalPath);for (let i = 0; i < session.totalChunks; i++) {const chunkPath = path.join(UPLOAD_DIR, req.params.id, String(i));output.write(fs.readFileSync(chunkPath));}output.end(() => {sessions.delete(req.params.id);res.json({ status: 'complete', path: finalPath });});});app.listen(3000);
Y la lógica de reanudación del lado del cliente — la pieza que realmente hace esto confiable en la práctica:
async function uploadFile(file, chunkSize) {chunkSize = chunkSize || 5 * 1024 * 1024;const totalChunks = Math.ceil(file.size / chunkSize);let uploadId = localStorage.getItem('upload:' + file.name);let startChunk = 0;if (uploadId) {// Reanudando: preguntarle al servidor qué ya tiene.const status = await fetch('/uploads/' + uploadId).then((r) => r.json());startChunk = status.receivedChunks.length; // simplificado: asume recepción contigua} else {const session = await fetch('/uploads', {method: 'POST',headers: { 'Content-Type': 'application/json' },body: JSON.stringify({ fileName: file.name, totalSize: file.size, chunkSize: chunkSize }),}).then((r) => r.json());uploadId = session.uploadId;localStorage.setItem('upload:' + file.name, uploadId);}for (let i = startChunk; i < totalChunks; i++) {const chunk = file.slice(i * chunkSize, (i + 1) * chunkSize);const buffer = await chunk.arrayBuffer();const checksum = await sha256Hex(buffer);await fetch('/uploads/' + uploadId + '/chunks/' + i, {method: 'PUT',headers: { 'X-Chunk-Checksum': checksum },body: buffer,});}await fetch('/uploads/' + uploadId + '/complete', { method: 'POST' });localStorage.removeItem('upload:' + file.name);}
#Limpiar sesiones abandonadas
No todas las sesiones de subida terminan. Un usuario cierra la pestaña en la parte 3 de 200 y nunca vuelve. Sin atención, los directorios de partes de esas sesiones se acumulan para siempre. Dale a cada sesión un TTL (un barrendero en segundo plano elimina sesiones sin actividad durante, digamos, 24 horas) y recupera tanto las partes parciales en disco como los metadatos de la sesión. Esto refleja el mismo patrón de TTL de reserva que reaparece más adelante en este módulo para los carritos de compra abandonados.
| Enfoque | Reanudable | Maneja partes reordenadas/duplicadas | Ejemplo real |
|---|---|---|---|
| Un único PUT del archivo completo | No | N/A | Formularios de subida simples |
| Por partes con índice secuencial | Sí | Duplicados: sí (sobrescritura idempotente). Reorden: sí | Multipart upload de S3 |
| Protocolo tus (basado en offset de bytes) | Sí | Basado en offset, el servidor dicta el siguiente offset | Vimeo, varios SDKs de subida |
🧠 Check your understanding
0/2 · 0/2 answered1. ¿Qué hace que una subida por partes sea reanudable, específicamente?
2. ¿Por qué el PUT de una parte dada debería ser idempotente (seguro de reintentar)?