Optimizar subidas de archivos en línea con fragmentación y subidas en paralelo
Gestionar las subidas de archivos de forma eficiente es un aspecto crítico de las aplicaciones web modernas. Los archivos grandes pueden provocar tiempos de subida lentos, interrupciones de red y una mala experiencia de usuario. En esta publicación exploramos técnicas avanzadas, como la fragmentación y las subidas en paralelo, para optimizar las subidas de archivos y lograr un rendimiento más rápido y confiable.
Introducción a los desafíos de la subida de archivos
Subir archivos grandes por internet plantea varios desafíos. Los usuarios pueden experimentar velocidades de subida lentas debido a limitaciones de ancho de banda o a la inestabilidad de la red, y las interrupciones a menudo obligan a reiniciar la subida, lo que genera frustración. Las aplicaciones web modernas requieren sistemas de subida robustos que puedan afrontar estos desafíos manteniendo una experiencia de usuario fluida.
Qué es la fragmentación y por qué importa
La fragmentación consiste en dividir un archivo grande en partes más pequeñas, o fragmentos. Este enfoque ofrece varias ventajas:
- Reintentar fragmentos individuales en lugar de reiniciar todo el archivo
- Mejor gestión de la memoria del navegador
- Seguimiento del progreso más sencillo
- Menor impacto de las interrupciones de red
- Recuperación de errores más eficiente
El tamaño óptimo de fragmento depende del protocolo y de los límites de la red y del servidor. Este ejemplo apunta a un máximo de diez fragmentos para archivos pequeños y limita cada fragmento a 5 MiB como decisión de la aplicación, no como límite del navegador. Los archivos vacíos se rechazan explícitamente:
const calculateChunkSize = (fileSize) => {
if (!Number.isSafeInteger(fileSize) || fileSize <= 0) {
throw new Error('Select a nonempty file')
}
const MAXIMUM_CHUNK_SIZE = 1024 * 1024 * 5
return Math.min(MAXIMUM_CHUNK_SIZE, Math.ceil(fileSize / 10))
}
Configurar una subida de archivos básica con JavaScript
Coloca primero el HTML y luego combina en un solo módulo las definiciones de JavaScript que aparecen abajo. El ejemplo requiere endpoints de servidor que implementen el protocolo personalizado descrito en la siguiente sección; no es un cliente listo para usar con cualquier endpoint de subida de archivos.
<div id="upload-container">
<label for="file-input">Files to upload</label>
<input type="file" id="file-input" multiple />
<button id="upload-btn">Upload</button>
<button id="cancel-btn">Cancel</button>
<p id="progress" role="status"></p>
</div>
class FileUploader {
constructor() {
this.abortController = null
this.setupEventListeners()
}
setupEventListeners() {
const uploadBtn = document.getElementById('upload-btn')
const cancelBtn = document.getElementById('cancel-btn')
uploadBtn.addEventListener('click', () => this.handleUpload())
cancelBtn.addEventListener('click', () => this.cancelUpload())
}
async handleUpload() {
if (this.abortController) return
const fileInput = document.getElementById('file-input')
const files = fileInput.files
if (files.length === 0) {
document.getElementById('progress').textContent = 'Please select a file.'
return
}
this.abortController = new AbortController()
try {
for (const file of files) {
await this.uploadFile(file)
}
document.getElementById('progress').textContent = 'Upload complete.'
} catch (error) {
document.getElementById('progress').textContent =
error.name === 'AbortError' ? 'Upload canceled.' : 'Upload failed. Please try again.'
} finally {
this.abortController = null
}
}
async uploadFile(file) {
const upload = new SecureUploader(file)
await upload.upload(this.abortController.signal, (percent) => {
this.updateProgress(file, percent)
})
}
cancelUpload() {
if (this.abortController) {
this.abortController.abort()
}
}
updateProgress(file, percentage) {
const progress = document.getElementById('progress')
progress.textContent = `${file.name}: ${Math.round(percentage)}%`
}
}
const uploader = new FileUploader()
Implementar subidas fragmentadas
El servidor debe autenticar cada solicitud, autorizar el uploadId para ese usuario, aplicar límites de
tamaño y de número de fragmentos, y almacenar los fragmentos de forma idempotente por (uploadId, chunkNumber). La
finalización debe verificar todos los fragmentos y su orden antes de publicar el archivo. No uses fileName
como ruta del sistema de archivos. Haz que las subidas incompletas expiren en el servidor. Este
protocolo didáctico reintenta dentro de una sesión de página; no implementa la recuperación tras
recargar ni un backend completo.
Cada solicitud de abajo espera un estado HTTP correcto; no se requiere un cuerpo de respuesta JSON.
class ChunkedUploader {
constructor(file, options = {}) {
this.file = file
this.uploadId = crypto.randomUUID()
this.chunkSize = calculateChunkSize(file.size)
this.totalChunks = Math.ceil(file.size / this.chunkSize)
this.retryLimit = options.retryLimit ?? 3
this.retryDelay = options.retryDelay ?? 1000
this.concurrency = options.concurrency ?? 3
if (![this.retryLimit, this.concurrency].every((value) => Number.isInteger(value) && value > 0)
|| !Number.isFinite(this.retryDelay) || this.retryDelay < 0) {
throw new Error('Invalid upload options')
}
}
async uploadChunk(chunk, chunkNumber, signal) {
const formData = new FormData()
formData.append('chunk', chunk)
formData.append('fileName', this.file.name)
formData.append('uploadId', this.uploadId)
formData.append('chunkNumber', chunkNumber)
formData.append('totalChunks', this.totalChunks)
let attempts = 0
while (attempts < this.retryLimit) {
signal.throwIfAborted()
try {
const response = await fetch('/upload-chunk', {
method: 'POST',
body: formData,
signal,
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
return
} catch (error) {
signal.throwIfAborted()
attempts++
if (attempts === this.retryLimit) throw error
await this.waitForRetry(this.retryDelay * 2 ** (attempts - 1), signal)
}
}
}
waitForRetry(milliseconds, signal) {
signal.throwIfAborted()
return new Promise((resolve, reject) => {
const onAbort = () => {
clearTimeout(timer)
reject(signal.reason)
}
const timer = setTimeout(() => {
signal.removeEventListener('abort', onAbort)
resolve()
}, milliseconds)
signal.addEventListener('abort', onAbort, { once: true })
})
}
async upload(signal, onProgress) {
let uploadedBytes = 0
// Upload chunks with a concurrency limit
for (let i = 0; i < this.totalChunks; i += this.concurrency) {
signal.throwIfAborted()
const requests = []
for (let number = i; number < Math.min(i + this.concurrency, this.totalChunks); number++) {
const chunk = this.file.slice(number * this.chunkSize, (number + 1) * this.chunkSize)
requests.push(this.uploadChunk(chunk, number, signal).then(() => {
uploadedBytes += chunk.size
onProgress?.(uploadedBytes / this.file.size * 100)
}))
}
// Settle in-flight requests before reporting failure or allowing another upload.
const results = await Promise.allSettled(requests)
const failure = results.find((result) => result.status === 'rejected')
if (failure) throw failure.reason
}
// Notify server that all chunks are uploaded
const response = await fetch('/complete-upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fileName: this.file.name,
uploadId: this.uploadId,
totalChunks: this.totalChunks,
}),
signal,
})
if (!response.ok) throw new Error(`Finalization failed: HTTP ${response.status}`)
}
}
Subidas en paralelo: mejorar la velocidad
El enfoque anterior aprovecha las subidas en paralelo al procesar varios fragmentos de forma concurrente. Esta técnica puede mejorar el rendimiento cuando una sola solicitud no satura la conexión. Mídela con tu servidor y tu red: las solicitudes en paralelo también añaden sobrecarga y pueden alcanzar los límites de tasa. El callback de progreso cuenta los bytes confirmados, no los bytes que están en tránsito.
Gestionar errores y reintentos
Nuestra implementación incluye una gestión de errores robusta mediante reintentos automáticos, retroceso exponencial y cancelación controlada con AbortController. Esta estrategia garantiza que los problemas transitorios de red o los errores del servidor se puedan reintentar un número limitado de veces. Es recomendable ofrecer mensajes de error claros y diferenciar entre fallos de red y errores de la aplicación, de modo que los usuarios puedan reintentar las subidas cuando sea necesario.
Garantizar la seguridad durante las subidas
La siguiente subclase ejecuta comprobaciones básicas del lado del cliente antes de enviar cualquier fragmento. Solo sirven como retroalimentación temprana: que una cabecera coincida no demuestra que un archivo sea seguro, y los clientes pueden eludir este código. El servidor debe validar de forma independiente el contenido y el tamaño, autorizar el acceso y aplicar análisis de malware o Content Disarm & Reconstruction cuando corresponda.
class SecureUploader extends ChunkedUploader {
async upload(signal, onProgress) {
signal.throwIfAborted()
await this.validateFile()
return super.upload(signal, onProgress)
}
async validateFile() {
// Validate file signature using allowed types
const allowedTypes = ['image/jpeg', 'image/png', 'application/pdf']
if (!allowedTypes.includes(this.file.type)) {
throw new Error('Unsupported file type')
}
const header = new Uint8Array(await this.file.slice(0, 4).arrayBuffer())
if (!this.validateFileSignature(header)) {
throw new Error('Invalid file signature')
}
// Size validation
const maxSize = 100 * 1024 * 1024 // 100 MiB
if (this.file.size > maxSize) {
throw new Error('File too large')
}
}
validateFileSignature(header) {
const signatures = {
'image/jpeg': [0xff, 0xd8, 0xff],
'image/png': [0x89, 0x50, 0x4e, 0x47],
'application/pdf': [0x25, 0x50, 0x44, 0x46],
}
const signature = signatures[this.file.type]
return signature !== undefined && signature.every((byte, i) => header[i] === byte)
}
}
Otras medidas de seguridad incluyen:
- Implementar cabeceras de Content Security Policy (CSP).
- Utilizar Content Disarm & Reconstruction (CDR) y análisis antivirus.
- Aplicar una validación estricta del tipo y el tamaño de archivo.
Buenas prácticas y consejos de optimización
- Usa Web Workers para tareas intensivas de procesamiento de archivos.
- Implementa compresión de archivos del lado del cliente cuando corresponda.
- Para la recuperación tras recargar, guarda una URL de subida emitida por el servidor y concilia su desplazamiento confirmado; guardar solo un porcentaje de progreso no es suficiente.
- Monitorea el uso de memoria durante las subidas grandes.
- Ofrece retroalimentación visual clara sobre el estado de la subida.
- Asegura una limpieza adecuada de las subidas fallidas.
- Aprovecha las funciones modernas del navegador, como los Service Workers para subidas en segundo plano y ReadableStream para un manejo eficiente de los datos. La vida útil de un Service Worker es limitada; no garantiza que una subida continúe después de cerrar el navegador.
Conclusión: construir sistemas de subida eficientes
Construir un sistema de subida de archivos robusto exige prestar mucha atención al rendimiento, la
seguridad y la experiencia de usuario. Las técnicas comentadas ofrecen una base sólida para
implementar subidas de archivos confiables en las aplicaciones web modernas. Al aprovechar la
fragmentación, las subidas en paralelo y las API modernas de los navegadores, puedes crear sistemas
de subida eficientes y resilientes. Si buscas una solución lista para producción que ofrezca una
interfaz de subida con mantenimiento, considera Uppy. Lo que sigue es una alternativa al cliente de
subida personalizado anterior, no una capa de fragmentación adicional. XHRUpload envía archivos
completos; usa el plugin del protocolo tus de Uppy con un servidor compatible con el protocolo tus
cuando necesites subidas reanudables. Instala @uppy/core, @uppy/dashboard
y @uppy/xhr-upload en tu aplicación empaquetada y proporciona un endpoint /upload autorizado:
import { Uppy } from '@uppy/core'
import Dashboard from '@uppy/dashboard'
import XHRUpload from '@uppy/xhr-upload'
import '@uppy/core/css/style.min.css'
import '@uppy/dashboard/css/style.min.css'
const uppy = new Uppy()
.use(Dashboard, {
inline: true,
target: '#upload-container',
})
.use(XHRUpload, {
endpoint: '/upload',
formData: true,
fieldName: 'file',
})
Estos enfoques modernos, junto con una gestión de errores cuidadosa y validaciones de seguridad, te ayudarán a crear una experiencia de subida de archivos fluida.
