Optimización de subidas web con fragmentos y subidas paralelas
Para subir fragmentos en paralelo, asigna una posición estable a cada uno y publica el archivo solo cuando el receptor haya verificado el resultado completo. Aquí crearás ambos lados: un navegador envía tres fragmentos a la vez y un servidor Node.js local permite descargar el archivo ensamblado tras verificar su resumen SHA-256.
Elige una subida pequeña y reproducible
Este ejemplo está dirigido a desarrolladores que están aprendiendo cómo funcionan las subidas de fragmentos en paralelo. Usa Node.js 24.15.0 o una versión posterior que siga recibiendo mantenimiento, y un navegador Chromium actualizado; el ejemplo se probó en Linux con Node.js 24.15.0 y Chromium 145. Node.js 24 es una versión LTS. No hay paquetes que instalar.
La página acepta un archivo JPEG, PNG o PDF no vacío de hasta 8 MiB. Ese límite reducido es
intencional: el receptor almacena los archivos en memoria y el navegador calcula el hash de archivos
completos con crypto.subtle.digest(),
que no acepta datos de entrada en streaming. Esta es una lección sobre un protocolo local, no un
servicio de almacenamiento de archivos grandes. Al reiniciar el servidor se pierden todos los
archivos. Al recargar la página se pierde el estado de la subida del cliente.
Asigna una posición a cada fragmento
Usa fragmentos de 256 KiB, numerados desde cero. Un archivo de 524.295 bytes tiene tres fragmentos:
dos de 262.144 bytes y uno de siete bytes.
Blob.slice(start, end)
excluye la posición final, de modo que las porciones adyacentes no se superponen ni dejan huecos.
El protocolo tiene cinco operaciones:
POST /uploadsreserva un tamaño de archivo y un resumen SHA-256, y devuelve un ID generado por el servidor.PUT /uploads/:id/:indexescribe el fragmento sin procesar en su posición numerada. Un reintento idéntico tiene éxito; un cuerpo diferente en una posición ya aceptada falla con HTTP 409.POST /uploads/:id/completecomprueba que todas las posiciones estén presentes y que el resumen del archivo completo coincida. Repetir esta operación devuelve el mismo resumen.GET /uploads/:id/filedevuelve bytes solo después de la finalización. El navegador también verifica estos bytes.DELETE /uploads/:idelimina la subida, incluido un archivo completado.
El orden de llegada no determina el orden del archivo. La pérdida de una confirmación puede provocar una solicitud duplicada, por lo que la escritura de fragmentos y la finalización deben ser idempotentes. La creación no se reintenta automáticamente: si se pierde la respuesta de creación, queda un ID desconocido que el servidor termina por hacer caducar.
Crea la página
Crea un directorio nuevo y vacío, y guarda los siguientes tres archivos en él. Guarda este primer
archivo como index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<title>Parallel chunk upload</title>
</head>
<body>
<main>
<h1>Parallel chunk upload</h1>
<label for="file">JPEG, PNG, or PDF, up to 8 MiB</label>
<input id="file" type="file" accept="image/jpeg,image/png,application/pdf" />
<button id="upload" type="button">Upload</button>
<button id="cancel" type="button" disabled>Cancel</button>
<p id="status" role="status">Choose a file.</p>
<a id="download" hidden download="upload.bin">Download verified file</a>
</main>
<script type="module" src="/client.js"></script>
</body>
</html>
Recibe y publica los fragmentos
Guarda esto como server.mts. La extensión .mts
lo convierte en un módulo ES incluso dentro de un proyecto CommonJS. El servidor usa
stripTypeScriptTypes()
de Node para servir el siguiente archivo como JavaScript. En Node.js 24.15.0, esa API emite una
advertencia de función experimental.
Pueden existir cuatro subidas a la vez, incluidas las completadas. Cada una caduca 60 segundos después de su creación, incluso si siguen llegando solicitudes. Puede haber seis solicitudes activas, cada una con un plazo máximo de 10 segundos. Los cuerpos entrantes se leen antes de consultar los datos de la sesión, de modo que un cuerpo pendiente no puede mantener activa una sesión eliminada ni escribir en ella más adelante.
import { createHash, randomUUID } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { createServer } from 'node:http'
import type { IncomingMessage, ServerResponse } from 'node:http'
import { stripTypeScriptTypes } from 'node:module'
const CHUNK_SIZE = 256 * 1024
const MAX_FILE_SIZE = 8 * 1024 * 1024
const MAX_UPLOADS = 4
const MAX_REQUESTS = 6
const TTL_MS = 60_000
type Upload = {
bytes: Buffer
digest: string
seen: Set<number>
complete: boolean
expires: number
}
const uploads = new Map<string, Upload>()
class HttpError extends Error {
status: number
constructor(status: number, message: string) {
super(message)
this.status = status
}
}
function expire(): void {
for (const [id, upload] of uploads) {
if (upload.expires <= Date.now()) uploads.delete(id)
}
}
async function readBody(req: IncomingMessage, limit: number): Promise<Buffer> {
const bytes = Buffer.alloc(limit)
let length = 0
// Leave the socket open long enough to send a useful error response.
for await (const part of req.iterator({ destroyOnReturn: false })) {
if (!Buffer.isBuffer(part)) throw new HttpError(400, 'Expected bytes')
if (length + part.length > limit) throw new HttpError(413, 'Body too large')
part.copy(bytes, length)
length += part.length
}
return bytes.subarray(0, length)
}
async function main(): Promise<void> {
const html = await readFile(new URL('./index.html', import.meta.url))
const client = stripTypeScriptTypes(
await readFile(new URL('./client.ts', import.meta.url), 'utf8'),
)
const port = Number(process.argv[2] ?? 0)
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('Invalid port')
let origin = ''
let active = 0
async function handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
if (req.headers.host !== new URL(origin).host ||
(req.headers.origin !== undefined && req.headers.origin !== origin)) {
throw new HttpError(403, 'Use the printed local URL')
}
const method = req.method
if (method !== 'GET' && req.headers['x-upload-demo'] !== '1') {
throw new HttpError(403, 'Missing demo header')
}
const path = new URL(req.url ?? '/', origin).pathname
const body = await readBody(req, method === 'PUT' ? CHUNK_SIZE : 0)
expire()
res.setHeader('Cache-Control', 'no-store')
res.setHeader('X-Content-Type-Options', 'nosniff')
if (method === 'GET' && (path === '/' || path === '/client.js')) {
res.setHeader('Content-Type', path === '/' ? 'text/html; charset=utf-8' : 'text/javascript')
res.end(path === '/' ? html : client)
return
}
if (method === 'POST' && path === '/uploads') {
const length = req.headers['upload-length']
const digest = req.headers['upload-sha256']
const size = Number(length)
if (typeof length !== 'string' || !/^[1-9]\d*$/.test(length) ||
!Number.isSafeInteger(size) || size > MAX_FILE_SIZE) {
throw new HttpError(400, 'File must be between 1 byte and 8 MiB')
}
if (typeof digest !== 'string' || !/^[a-f0-9]{64}$/.test(digest)) {
throw new HttpError(400, 'Expected a SHA-256 digest')
}
if (uploads.size >= MAX_UPLOADS) throw new HttpError(503, 'Upload capacity reached')
const id = randomUUID()
uploads.set(id, {
bytes: Buffer.alloc(size), digest, seen: new Set(), complete: false,
expires: Date.now() + TTL_MS,
})
res.writeHead(201).end(id)
return
}
const match = /^\/uploads\/([a-f0-9-]{36})(?:\/(\d+|complete|file))?$/.exec(path)
if (!match) throw new HttpError(404, 'Unknown route')
const [, id, operation] = match
if (method === 'DELETE' && operation === undefined) {
uploads.delete(id)
res.writeHead(204).end()
return
}
const upload = uploads.get(id)
if (!upload) throw new HttpError(404, 'Upload missing or expired')
const count = Math.ceil(upload.bytes.length / CHUNK_SIZE)
if (method === 'PUT' && operation !== undefined && /^\d+$/.test(operation)) {
const index = Number(operation)
if (!Number.isSafeInteger(index) || index >= count) {
throw new HttpError(400, 'Invalid chunk index')
}
const start = index * CHUNK_SIZE
const target = upload.bytes.subarray(start, Math.min(start + CHUNK_SIZE, upload.bytes.length))
if (body.length !== target.length) throw new HttpError(400, 'Wrong chunk length')
if (upload.seen.has(index)) {
if (!body.equals(target)) throw new HttpError(409, 'Conflicting chunk')
} else {
body.copy(target)
upload.seen.add(index)
}
res.writeHead(204).end()
return
}
if (method === 'POST' && operation === 'complete') {
if (upload.seen.size !== count) throw new HttpError(409, 'Missing chunks')
if (createHash('sha256').update(upload.bytes).digest('hex') !== upload.digest) {
throw new HttpError(422, 'Digest mismatch')
}
upload.complete = true
res.end(upload.digest)
return
}
if (method === 'GET' && operation === 'file') {
if (!upload.complete) throw new HttpError(409, 'Upload is not complete')
res.setHeader('Content-Type', 'application/octet-stream')
res.setHeader('Content-Disposition', 'attachment; filename="upload.bin"')
res.end(upload.bytes)
return
}
throw new HttpError(405, 'Unsupported operation')
}
const server = createServer({ requestTimeout: 10_000, headersTimeout: 10_000 }, (req, res) => {
if (active >= MAX_REQUESTS) {
res.writeHead(503, { Connection: 'close' }).end('Too many requests')
return
}
active++
let handled = false
let closed = false
const deadline = setTimeout(() => { req.destroy(); res.destroy() }, 10_000)
function release(): void {
if (handled && closed) { clearTimeout(deadline); active-- }
}
res.once('close', () => { closed = true; release() })
handle(req, res).catch((error: unknown) => {
const status = error instanceof HttpError ? error.status : 500
const message = error instanceof HttpError ? error.message : 'Request failed'
if (!res.destroyed) res.writeHead(status, { Connection: 'close' }).end(message)
}).finally(() => { handled = true; release() })
})
server.maxConnections = 16
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(port, '127.0.0.1', () => resolve())
})
const address = server.address()
if (!address || typeof address === 'string') throw new Error('Missing server address')
origin = `http://127.0.0.1:${address.port}`
setInterval(expire, 1000).unref()
console.log(`Open ${origin}`)
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : 'Could not start the server')
process.exitCode = 1
})
Los cuatro búferes de sesión suman como máximo 32 MiB. Por separado, una solicitud admitida puede retener un búfer de entrada de 256 KiB o hacer referencia a un archivo de salida de 8 MiB hasta que se cierre su respuesta. La eliminación y la caducidad no liberan antes de tiempo el cupo de esa solicitud. Estos son límites de los búferes de la aplicación, no límites del uso total de memoria de Node ni de los tiempos de recolección de basura. Los tiempos de espera de los encabezados y el límite de conexiones también acotan las conexiones en espera de este servidor local; consulta la documentación HTTP de Node.js.
Envía como máximo tres fragmentos a la vez
Guarda esto como client.ts. Cada lote espera a que todas sus solicitudes terminen,
con éxito o error, antes de que comience otro lote. Por tanto, un fragmento lento retrasa su lote,
pero el límite es fácil de inspeccionar y los reintentos no pueden multiplicar el número de
solicitudes de fragmentos activas.
const CHUNK_SIZE = 256 * 1024
const MAX_FILE_SIZE = 8 * 1024 * 1024
const CONCURRENCY = 3
const input = document.getElementById('file')
const uploadButton = document.getElementById('upload')
const cancelButton = document.getElementById('cancel')
const status = document.getElementById('status')
const download = document.getElementById('download')
if (!(input instanceof HTMLInputElement) || !(uploadButton instanceof HTMLButtonElement) ||
!(cancelButton instanceof HTMLButtonElement) || !(status instanceof HTMLParagraphElement) ||
!(download instanceof HTMLAnchorElement)) throw new Error('Missing upload controls')
class HttpError extends Error {
status: number
constructor(status: number) { super(`HTTP ${status}`); this.status = status }
}
async function validateFile(file: File): Promise<void> {
if (file.size === 0 || file.size > MAX_FILE_SIZE) throw new Error('Choose a file between 1 byte and 8 MiB.')
const signatures: Record<string, number[]> = {
'image/jpeg': [0xff, 0xd8, 0xff],
'image/png': [0x89, 0x50, 0x4e, 0x47],
'application/pdf': [0x25, 0x50, 0x44, 0x46],
}
const expected = signatures[file.type]
if (!expected) throw new Error('Choose a JPEG, PNG, or PDF.')
const header = new Uint8Array(await file.slice(0, 4).arrayBuffer())
if (!expected.every((byte, index) => header[index] === byte)) throw new Error('Invalid file signature.')
}
async function sha256(blob: Blob): Promise<string> {
const hash = await crypto.subtle.digest('SHA-256', await blob.arrayBuffer())
return Array.from(new Uint8Array(hash), (byte) => byte.toString(16).padStart(2, '0')).join('')
}
function waitForRetry(ms: number, signal: AbortSignal): Promise<void> {
signal.throwIfAborted()
return new Promise((resolve, reject) => {
const onAbort = () => { clearTimeout(timer); reject(signal.reason) }
const timer = setTimeout(() => {
signal.removeEventListener('abort', onAbort)
resolve()
}, ms)
signal.addEventListener('abort', onAbort, { once: true })
})
}
async function request(
path: string, options: RequestInit, signal: AbortSignal, retry = true,
): Promise<Blob> {
for (let attempt = 0; ; attempt++) {
signal.throwIfAborted()
try {
const response = await fetch(path, {
...options,
signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]),
headers: { ...options.headers, 'X-Upload-Demo': '1' },
})
if (!response.ok) throw new HttpError(response.status)
return await response.blob()
} catch (error) {
signal.throwIfAborted()
if (!retry || attempt === 2 ||
(error instanceof HttpError && ![408, 429, 500, 502, 503, 504].includes(error.status))) {
throw error
}
await waitForRetry(250 * 2 ** attempt, signal)
}
}
}
let running: AbortController | null = null
cancelButton.addEventListener('click', () => running?.abort())
input.addEventListener('change', () => {
if (running) return
download.hidden = true
status.textContent = 'Ready to upload.'
})
uploadButton.addEventListener('click', async () => {
if (running) return
const file = input.files?.[0]
if (!file) { status.textContent = 'Choose a file.'; return }
const controller = new AbortController()
const signal = controller.signal
running = controller
input.disabled = uploadButton.disabled = true
cancelButton.disabled = false
download.hidden = true
let id: string | undefined
let verified = false
try {
status.textContent = 'Checking file…'
await validateFile(file)
const digest = await sha256(file)
signal.throwIfAborted()
// Finish creation so cancellation can learn the ID and delete it.
id = await (await request('/uploads', {
method: 'POST', headers: { 'Upload-Length': String(file.size), 'Upload-SHA256': digest },
}, new AbortController().signal, false)).text()
signal.throwIfAborted()
const count = Math.ceil(file.size / CHUNK_SIZE)
let acknowledged = 0
for (let first = 0; first < count; first += CONCURRENCY) {
signal.throwIfAborted()
const batch = []
for (let index = first; index < Math.min(first + CONCURRENCY, count); index++) {
const chunk = file.slice(index * CHUNK_SIZE, (index + 1) * CHUNK_SIZE)
batch.push(request(`/uploads/${id}/${index}`, { method: 'PUT', body: chunk }, signal).then(() => {
signal.throwIfAborted()
acknowledged += chunk.size
status.textContent = `${Math.round(100 * acknowledged / file.size)}% of bytes acknowledged.`
}))
}
const results = await Promise.allSettled(batch)
const failure = results.find((result) => result.status === 'rejected')
if (failure) throw failure.reason
}
status.textContent = 'All chunks acknowledged. Verifying…'
const confirmation = await request(`/uploads/${id}/complete`, { method: 'POST' }, signal)
if (await confirmation.text() !== digest) throw new Error('Unexpected confirmation.')
const result = await request(`/uploads/${id}/file`, {}, signal)
if (result.size !== file.size || await sha256(result) !== digest) throw new Error('Downloaded bytes differ.')
signal.throwIfAborted()
verified = true
download.href = `/uploads/${id}/file`
download.hidden = false
status.textContent = 'Upload verified. Download is available until the upload expires.'
} catch (error) {
status.textContent = signal.aborted ? 'Upload canceled.' :
`Upload failed: ${error instanceof Error ? error.message : 'Please try again.'}`
} finally {
if (id && !verified) {
try {
await request(`/uploads/${id}`, { method: 'DELETE' }, new AbortController().signal)
} catch {
status.textContent += ' Cleanup could not be confirmed; the server will expire the upload.'
}
}
running = null
input.disabled = uploadButton.disabled = false
cancelButton.disabled = true
}
})
El porcentaje cuenta los bytes confirmados, incluido un último fragmento más corto. No mide los bytes que están en tránsito en ese momento. Ni siquiera el 100 % significa éxito: la finalización y la posterior verificación de la descarga deben completarse correctamente antes de que aparezca el enlace. Más solicitudes paralelas pueden mejorar el rendimiento cuando una solicitud deja capacidad sin usar, pero también añaden sobrecarga. Haz mediciones con tu propio receptor y tu red; esta demo no promete ninguna velocidad.
Ejecuta e interrumpe una subida
Desde el directorio que contiene los tres archivos guardados, ejecuta:
node server.mts
Abre la URL http://127.0.0.1:PORT que se muestra. No abras
index.html directamente. Selecciona un archivo y haz clic en
Upload. Después de
All chunks acknowledged. Verifying…, aparece el enlace
Download verified file. La página ha obtenido y verificado el
archivo del servidor; al hacer clic en el enlace se inicia una descarga independiente, cuya ubicación
de guardado controla tu navegador. El servidor siempre sugiere upload.bin como
nombre del archivo descargado.
Mientras haya trabajo pendiente, el campo de selección de archivos y el botón de subida permanecen deshabilitados. Haz clic en Cancel para abortar las solicitudes y las esperas entre reintentos. La creación y el cálculo local del hash terminan antes de que se detecte la cancelación; la limpieza intenta entonces eliminar el ID conocido. Los controles permanecen deshabilitados hasta que la limpieza termine, con éxito o error. Si la limpieza falla o se pierde una respuesta de creación, los datos pueden permanecer hasta que caduquen. Cancelar una solicitud no puede deshacer una finalización ya procesada por el servidor, por lo que la limpieza también elimina las subidas completadas.
AbortSignal.any() y AbortSignal.timeout()
combinan la cancelación del usuario con un tiempo de espera de cinco segundos por intento de
solicitud. Los fallos de red y los estados HTTP temporales enumerados permiten como máximo tres
intentos, con esperas de 250 ms y 500 ms entre reintentos. Los demás errores HTTP fallan de inmediato.
Estos tiempos de espera del navegador usan tiempo activo y pueden pausarse mientras la página está
suspendida; el plazo máximo y la caducidad del servidor son independientes.
Una respuesta 409 significa que faltan fragmentos o que un duplicado entra en conflicto. Una 422
significa que el resumen del archivo ensamblado es incorrecto. Una 503 puede significar que los cuatro
cupos de subida están ocupados, incluidos los archivos completados; espera a que caduquen y vuelve a
empezar. Los IDs inexistentes o caducados devuelven 404. Detén el servidor con Ctrl+C cuando termines.
Para usar un puerto libre específico, pasa su número después de server.mts;
si el puerto está ocupado, el proceso termina con un error en lugar de mostrar una URL lista para usar.
Mantén explícito el límite local
El receptor solo escucha en 127.0.0.1, verifica el host y el origen del navegador,
exige un encabezado personalizado para las mutaciones y nunca usa un nombre de archivo proporcionado
como ruta. Sirve bytes opacos como archivos adjuntos. La comprobación de la firma en el cliente
detecta una selección de archivo equivocada; ni un prefijo coincidente ni un resumen coincidente
demuestran que un archivo sea seguro. El receptor exige de forma independiente que se respeten las
longitudes, las posiciones, la capacidad y el resumen, pero no valida la estructura de las imágenes
o los PDF ni realiza un análisis en busca de malware.
No expongas este servidor como un servicio público de subida de archivos. Un receptor desplegado necesita autenticación, autorización y cuotas por usuario, HTTPS, almacenamiento duradero y una validación de contenido adecuada para quienes lo consumen. Los IDs de este ejemplo aíslan las subidas locales; no son un sistema de permisos de cuentas.
Elige el siguiente paso
Prueba con un archivo ligeramente mayor que dos fragmentos y observa las solicitudes en el panel de red de tu navegador. El último fragmento debería ser más pequeño y la URL del archivo solo debería poder usarse después de la finalización. Ese límite es lo que debes conservar al sustituir la memoria por almacenamiento duradero.
Para recuperar el estado tras una recarga, usa un protocolo que siga recibiendo mantenimiento y
conserva suficiente estado de forma persistente para reconciliarlo con el receptor. El protocolo
tus define la consulta del desplazamiento con
HEAD y la reanudación con PATCH; las subidas parciales
en paralelo usan su extensión opcional de concatenación. Esta demo de fragmentos numerados es un
protocolo independiente, no un cliente del protocolo tus. El
plugin Tus de Uppy es un siguiente paso práctico si quieres un cliente
para el navegador que siga recibiendo mantenimiento y sea compatible con un servidor del protocolo tus.
