Acelera las subidas de archivos en JS con Web Workers y streams
Subir archivos de forma eficiente es crucial para las aplicaciones web modernas. Los enfoques tradicionales suelen leer los archivos completos en memoria, lo que puede congelar la interfaz, consumir demasiada RAM y provocar una mala experiencia de usuario, sobre todo con archivos grandes. Al delegar el procesamiento pesado en Web Workers y transmitir los datos en fragmentos manejables con los Streams de JavaScript, puedes mantener el hilo principal receptivo, incluso cuando los usuarios suben archivos de varios gigabytes.
Desafíos de las subidas de archivos tradicionales
Un enfoque común, aunque ineficiente, consiste en:
- Leer todo el objeto
Fileen memoria usandoFileReader. - Construir un objeto
FormDatacon los datos del archivo. - Enviar el
FormDatamediante una solicitud POST confetchoXMLHttpRequest.
Leer archivos completos en búferes de la aplicación puede provocar grandes picos de memoria. Añadir un File
directamente a FormData no requiere esa lectura: las subidas normales ya son asíncronas.
Los workers ayudan cuando también hace falta procesamiento intensivo de CPU, no porque aumenten el ancho de banda de la red.
Conoce los Web Workers y los streams
Web Workers
Los Web Workers te permiten ejecutar código JavaScript en hilos en segundo plano, separados del hilo de ejecución principal que gestiona la interfaz. Esto significa que las tareas con mucha carga computacional, como el hashing, la compresión o la división de datos en fragmentos para las subidas, no bloquearán el renderizado, el desplazamiento ni la entrada del usuario, lo que se traduce en una experiencia más fluida durante las subidas de archivos.
Streams de JavaScript
La API de Streams permite procesar los datos de forma incremental en fragmentos. En lugar de cargar
un archivo completo en memoria, puedes leer y procesar piezas pequeñas (normalmente Uint8Array) a medida que
están disponibles. Esto reduce drásticamente el uso de memoria y permite enviar los datos por la red
casi inmediatamente después de leerlos, lo que hace que los streams de JavaScript sean útiles para subidas grandes.
Descripción general de la arquitectura
Un sistema de subidas en paralelo que usa estas tecnologías suele incluir:
- Hilo principal: gestiona las interacciones de la interfaz (como arrastrar y soltar), la
selección de archivos y la visualización del progreso. Pasa los objetos
Fileal pool de workers. - Pool de workers: un conjunto de Web Workers gestiona las tareas de procesamiento de
archivos. Cada worker disponible recibe una referencia a
File. - Worker individual: usa la API
Blob.stream()oFile.stream()para leer el archivo fragmento a fragmento. Después, cada fragmento se envía por POST al endpoint de subida del back-end. Los mensajes de progreso (porcentaje completado) y las actualizaciones de estado (finalización, errores) se devuelven al hilo principal. - Back-end: recibe los fragmentos y los vuelve a ensamblar en el archivo completo. Esto suele implicar protocolos como el protocolo tus, subidas multiparte a almacenamiento en la nube (por ejemplo, la subida multiparte de S3) o lógica personalizada del lado del servidor.
Transmitir un archivo sin congelar la interfaz
Los siguientes ejemplos muestran un patrón mínimo pero práctico para subidas por fragmentos con un pool de workers. Fíjate en que incluyen mecanismos de manejo de errores y de limpieza.
Hilo principal (main.js)
Este script configura el pool de workers y gestiona los eventos del campo de selección de archivos,
y delega el procesamiento de cada archivo en el pool. Coloca la definición de WorkerPool que aparece más adelante en este artículo antes de este código en
main.js, y carga ese archivo después de este HTML. Sirve ambos scripts desde el mismo origen a través de
localhost o HTTPS. Las devoluciones de llamada siguientes registran el progreso; una interfaz de producción debería mostrarlo de forma visible.
<label for="file-input">Files to upload</label>
<input type="file" id="file-input" multiple />
<button type="button" id="cancel-uploads">Cancel uploads</button>
<script src="main.js"></script>
// Assumes WorkerPool class is defined elsewhere (see below)
let pool = new WorkerPool('upload-worker.js')
const fileInput = document.querySelector('#file-input')
fileInput.addEventListener('change', (evt) => {
if (pool.closed) pool = new WorkerPool('upload-worker.js')
const files = Array.from(evt.target.files)
files.forEach((file) => {
console.log(`Queueing ${file.name} for upload...`)
pool.processFile(file, {
onProgress: (pct, msg) => updateProgressUI(file.name, pct, msg),
onComplete: (msg) => showSuccess(file.name, msg),
onError: (err) => showError(file.name, err),
})
})
fileInput.value = ''
})
function updateProgressUI(filename, pct, message) {
// Update your progress bar or UI element here
console.log(`${filename}: ${pct.toFixed(1)}% – ${message}`)
}
function showSuccess(filename, message) {
// Update UI to show completion
console.info(`${filename}: ${message}`)
}
function showError(filename, error) {
// Update UI to show error state
console.error(`${filename}: Upload failed - ${error}`)
}
document.getElementById('cancel-uploads').addEventListener('click', () => pool.terminate())
window.addEventListener('pagehide', () => {
pool.terminate()
})
Implementación del worker (upload-worker.js)
Este worker agrupa los fragmentos de stream de tamaño variable del navegador en solicitudes de 1 MiB. El back-end
debe autenticar y autorizar cada uploadId, aceptar los fragmentos de forma idempotente por índice, aplicar límites
y finalizar solo después de validar todos los fragmentos. /upload acepta los campos multiparte que se indican a continuación;
/complete-upload acepta JSON con uploadId y totalChunks. Ambos deben devolver un
estado HTTP correcto. Este tutorial no proporciona estos endpoints personalizados, y los nombres de
archivo nunca deben tratarse como rutas de almacenamiento sin validar. Este ejemplo tiene tiempos de
espera para las solicitudes, pero no reintentos ni recuperación tras recargar la página; usa un
protocolo reanudable con mantenimiento activo cuando necesites esas garantías.
const CHUNK_SIZE = 1024 * 1024
async function* readChunks(file) {
const reader = file.stream().getReader()
let buffer = new Uint8Array(CHUNK_SIZE)
let used = 0
let finished = false
try {
while (true) {
const { done, value } = await reader.read()
if (done) {
finished = true
break
}
let offset = 0
while (offset < value.length) {
const length = Math.min(CHUNK_SIZE - used, value.length - offset)
buffer.set(value.subarray(offset, offset + length), used)
used += length
offset += length
if (used === CHUNK_SIZE) {
yield buffer
buffer = new Uint8Array(CHUNK_SIZE)
used = 0
}
}
}
if (used > 0) yield buffer.subarray(0, used)
} finally {
if (!finished) await reader.cancel().catch(() => {})
reader.releaseLock()
}
}
async function uploadChunk(chunk, filename, index, uploadId, totalChunks) {
const formData = new FormData()
// Send chunk index for server-side reassembly
formData.append('chunkIndex', index.toString())
// Send the actual chunk data as a Blob
formData.append('fileChunk', new Blob([chunk]), `${filename}.part${index}`)
formData.append('uploadId', uploadId)
formData.append('totalChunks', String(totalChunks))
// Replace '/upload' with your actual back-end endpoint
const res = await fetch('/upload', {
method: 'POST',
body: formData,
signal: AbortSignal.timeout(60_000),
})
if (!res.ok) {
throw new Error(`Chunk rejected: HTTP ${res.status}`)
}
}
self.onmessage = async (event) => {
const file = event.data
try {
if (!(file instanceof File) || file.size === 0) throw new Error('Select a nonempty file')
const uploadId = crypto.randomUUID()
const totalChunks = Math.ceil(file.size / CHUNK_SIZE)
let index = 0
let uploaded = 0
for await (const chunk of readChunks(file)) {
await uploadChunk(chunk, file.name, index++, uploadId, totalChunks)
uploaded += chunk.length
self.postMessage({ type: 'progress', progress: uploaded / file.size * 100, message: 'Uploading.' })
}
const response = await fetch('/complete-upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uploadId, totalChunks }),
signal: AbortSignal.timeout(60_000),
})
if (!response.ok) throw new Error('Finalization failed')
self.postMessage({ type: 'complete', message: 'Upload finished successfully.' })
} catch {
self.postMessage({ type: 'error', message: 'Upload failed. Please try again.' })
}
}
¿Por qué no leer todo el archivo de una vez en el worker?
Aunque leer el archivo completo dentro del worker evita bloquear el hilo principal, sigue consumiendo bastante memoria en el propio hilo del worker. Transmitir el archivo fragmento a fragmento dentro del worker ofrece varias ventajas:
- Menor consumo de memoria: solo hay fragmentos pequeños en memoria en cada momento.
- Contrapresión: el siguiente fragmento de la aplicación solo se solicita cuando termina la subida actual. La compatibilidad con reintentos y reanudación sigue requiriendo un protocolo de servidor y guardar el estado confirmado.
- Subida de fragmentos en paralelo: las implementaciones avanzadas podrían subir varios fragmentos de forma concurrente (aunque esto añade complejidad en el ordenamiento y en la gestión del lado del servidor).
Crear un pool de workers mínimo
Usar un solo worker puede seguir siendo un cuello de botella si necesitas procesar muchos archivos a
la vez. Un WorkerPool limita las tareas concurrentes y pone en cola los archivos pendientes. Empieza con un
límite pequeño y mide; el número de CPU no es una recomendación de ancho de banda de subida. Este
pool cancela todo el trabajo cuando falla el script del worker o la serialización de un mensaje, en
lugar de reutilizar un worker defectuoso.
class WorkerPool {
constructor(script, size = Math.min(navigator.hardwareConcurrency || 2, 4)) {
if (!Number.isInteger(size) || size < 1 || size > 4) throw new Error('Use 1 to 4 workers')
this.closed = false
this.workers = []
this.idleWorkers = []
this.taskQueue = []
this.taskCallbacks = new Map() // Map task ID to callbacks
console.log(`Initializing WorkerPool with size ${size}`)
for (let i = 0; i < size; i++) {
const worker = new Worker(script)
worker.id = `worker_${i}`
// Handle messages from the worker
worker.onmessage = (e) => this.handleWorkerMessage(worker, e.data)
// Handle errors occurring within the worker itself
worker.onerror = (e) => this.handleWorkerError(worker, e)
worker.onmessageerror = (e) => this.handleWorkerError(worker, e)
this.workers.push(worker)
this.idleWorkers.push(worker)
}
}
generateTaskId() {
return crypto.randomUUID()
}
processFile(file, callbacks) {
if (this.closed) {
callbacks.onError?.('The upload pool is closed.')
return
}
const taskId = this.generateTaskId()
const task = { id: taskId, file }
this.taskCallbacks.set(taskId, callbacks)
const idleWorker = this.idleWorkers.pop()
if (idleWorker) {
// An idle worker is available, run the task immediately
this.runTask(idleWorker, task)
} else {
// All workers are busy, add task to the queue
this.taskQueue.push(task)
console.log(
`Worker pool busy. Queued task ${taskId} for ${file.name}. Queue size: ${this.taskQueue.length}`,
)
}
}
runTask(worker, task) {
console.log(`Assigning task ${task.id} (${task.file.name}) to ${worker.id}`)
worker.currentTask = task // Associate task metadata with the worker
// Send the file object to the worker to start processing
// For large files, consider if Transferable Objects are applicable/needed
try {
worker.postMessage(task.file)
} catch (error) {
this.handleWorkerError(worker, error)
}
}
handleWorkerMessage(worker, data) {
const task = worker.currentTask
if (!task) {
console.warn(`Received message from worker ${worker.id} without an assigned task.`)
return
}
const callbacks = this.taskCallbacks.get(task.id)
if (!callbacks) {
console.warn(`Received message for unknown or completed task ${task.id}`)
return // Task might have been cancelled or already completed/failed
}
// Process messages based on their type
switch (data.type) {
case 'progress':
if (callbacks.onProgress) callbacks.onProgress(data.progress, data.message)
break
case 'complete':
this.finishTask(worker, task.id, () => callbacks.onComplete?.(data.message))
break
case 'error':
this.finishTask(worker, task.id, () => callbacks.onError?.(data.message))
break
default:
console.warn(`Received unknown message type from worker ${worker.id}:`, data.type)
}
}
handleWorkerError(worker, errorEvent) {
errorEvent.preventDefault?.()
this.terminate('An upload worker failed. Select your files to try again.')
}
finishTask(worker, taskId, notify) {
// Detach completed work before user callbacks can cancel or enqueue more work.
worker.currentTask = null
this.taskCallbacks.delete(taskId)
try {
notify()
} finally {
// A callback may terminate the pool; never return a dead worker to it.
if (!this.closed) {
const nextTask = this.taskQueue.shift()
if (nextTask) this.runTask(worker, nextTask)
else this.idleWorkers.push(worker)
}
}
}
terminate(message = 'Upload canceled.') {
if (this.closed) return
this.closed = true
console.log('Terminating worker pool...')
this.workers.forEach((worker) => {
console.log(`Terminating worker ${worker.id}`)
worker.terminate()
})
// Clear internal state
this.workers = []
this.idleWorkers = []
this.taskQueue = []
const callbacks = [...this.taskCallbacks.values()]
this.taskCallbacks.clear()
const errors = []
for (const callback of callbacks) {
try {
callback.onError?.(message)
} catch (error) {
errors.push(error)
}
}
if (errors.length > 0) throw new AggregateError(errors, 'Upload cancellation callbacks failed.')
}
}
Compatibilidad con navegadores
Las API web evolucionan, así que verifica siempre la compatibilidad de los navegadores con las funciones de las que dependes.
El método Blob.stream() lo hereda
File, por lo que no son requisitos de compatibilidad independientes. Comprueba también Worker
y AbortSignal.timeout()
en los navegadores que admites. Este ejemplo envía un File mediante clonación estructurada; no requiere
compatibilidad con streams transferibles.
En los navegadores que no admiten File.stream(), puede que tengas que recurrir a un enfoque basado en
FileReader (posiblemente dentro del worker para evitar bloquear el hilo principal, aunque
seguirá usando más memoria) o usar bibliotecas consolidadas como tus-js-client o Uppy, que se
encargan de la compatibilidad y ofrecen funciones como la reanudación.
Buenas prácticas de gestión de memoria
- Limita el número de workers: empieza con un límite pequeño y mide el comportamiento de la CPU, la memoria y la red. Crear demasiados workers puede provocar un cambio de contexto excesivo y una sobrecarga de memoria.
- Termina los workers: llama explícitamente a
worker.terminate()opool.terminate()cuando ya no se necesiten los workers (por ejemplo, después de que terminen todas las subidas o cuando el usuario abandone la página) para liberar recursos. Usa bloquestry...finallyen la lógica de tu aplicación para asegurarte de que la terminación ocurra incluso si se producen errores durante el proceso de subida. - Libera las referencias: tanto en el hilo principal como en los workers, anula las referencias a objetos grandes
(como los objetos
File, losBlob, losArrayBuffero los lectores de streams) cuando ya no se necesiten (reader = null,file = null,chunk = null) para permitir la recolección de basura. Asegúrate de liberar los lectores de streams conreader.releaseLock(). Cancela también los streams sin terminar; liberar un bloqueo no cierra el stream ni cancela su productor. - Tamaño de los fragmentos: elige un tamaño de fragmento razonable (por ejemplo, 1-10 MiB). Los fragmentos muy pequeños aumentan la sobrecarga de red (más solicitudes HTTP por archivo), mientras que los muy grandes anulan parte del ahorro de memoria que aporta el streaming.
- Monitorea la memoria: usa las herramientas para desarrolladores del navegador (como el panel Memory de Chrome o la herramienta Memory de Firefox) durante el desarrollo y las pruebas para vigilar el uso de memoria bajo carga e identificar posibles fugas.
El ejemplo del hilo principal termina el pool al pulsar Cancel uploads o con pagehide, y después crea un pool nuevo cuando
el usuario vuelve a seleccionar archivos. No termines el pool inmediatamente después de encolar
trabajo asíncrono, salvo que quieras cancelarlo. La expiración del lado del servidor debe limpiar los bytes ya recibidos.
Seguridad y resiliencia
- CORS: configura con cuidado en el servidor la política de Cross-Origin Resource Sharing
(CORS) de tu endpoint de subida. Permite solo los métodos HTTP necesarios (POST y, posiblemente,
OPTIONS para las solicitudes preflight), las cabeceras que realmente usa el protocolo que elijas,
y restringe los orígenes (
Access-Control-Allow-Origin) al dominio de tu aplicación. - Autenticación/autorización: protege tu endpoint de subida. En las subidas por fragmentos,
asegúrate de que cada solicitud de fragmento esté autenticada y autorizada. Entre los métodos
están usar cookies de sesión seguras con HTTP-only, tokens de portador (JWT) enviados en la
cabecera
Authorization, o generar URL prefirmadas para cada fragmento o para toda la sesión de subida (habitual con el almacenamiento en la nube). - Reintentos: los problemas de red son habituales. Implementa un mecanismo de reintentos en tu
función
uploadChunkpara las subidas de fragmentos fallidas. Usa una espera exponencial (esperando cada vez más entre reintentos: por ejemplo, 1 s, 2 s, 4 s) para no saturar el servidor ni la red. Deja de reintentar después de un número razonable de intentos (por ejemplo, 3-5). - Cancelación: ofrece a los usuarios una forma de cancelar las subidas en curso. Usa la API
AbortController. Crea una instancia deAbortControllerantes de iniciar la subida, pasa susignala cada solicitudfetchy llama acontroller.abort()cuando el usuario cancele. Asegúrate de que tu manejo de errores capture elAbortError.
Un AbortController debe estar en el mismo worker que la solicitud. Un controlador del hilo principal no se
comparte automáticamente con ese worker. En su lugar, este ejemplo detiene todos los workers activos al pulsar Cancel uploads;
la cancelación por archivo necesitaría un mensaje explícito al worker y un contrato para eliminarlo de la cola.
Depurar Web Workers
Depurar workers puede ser algo distinto de depurar el hilo principal:
- DevTools del navegador: en el panel Sources de Chrome, selecciona el
worker en la sección Threads para
cambiar el contexto de depuración.
En el Debugger de Firefox, abre el archivo fuente de un worker activo.
Ambos navegadores admiten puntos de interrupción, inspección de variables y la salida de
console.logde los workers; consulta la depuración de hilos de worker. - Manejo de errores: una comunicación robusta de errores mediante
postMessage(como se muestra en los ejemplos) es crucial para entender los problemas que ocurren dentro del worker, ya que untry...catchdirecto desde el hilo principal no capturará los errores del worker. Asegúrate de capturar explícitamente los errores del worker y de enviarlos de vuelta.
Errores comunes
- Demasiados workers: crear un worker nuevo para cada archivo en lugar de usar un pool puede saturar los recursos del sistema (CPU y memoria).
- Bloqueos de stream: olvidar llamar a
reader.releaseLock()en unReadableStreamDefaultReaderdespués de terminar la lectura o de encontrar un error. Usa siempre un bloquefinallyparareleaseLock()y cancela un stream sin terminar antes de liberarlo. - Cargas de mensaje grandes: evita enviar objetos de datos muy grandes entre el hilo principal y
los workers con
postMessage, ya que esto implica una sobrecarga de serialización y deserialización (o de clonación estructurada). Para datos binarios grandes, estudia el uso de objetosTransferable(comoArrayBuffer) para lograr transferencias sin copia más eficientes allí donde sea compatible y adecuado. - Orden de los fragmentos: dar por hecho que el servidor recibirá los fragmentos exactamente en el orden en que se enviaron. La latencia de red y las solicitudes concurrentes pueden alterar el orden. Incluye siempre un índice o un desplazamiento en bytes con cada fragmento para que el servidor pueda volver a ensamblar el archivo correctamente.
- Errores sin gestionar: la falta de bloques
try...catchadecuados dentro del worker, sobre todo alrededor de operaciones asíncronas como la lectura del stream (reader.read()) y las solicitudes de red (fetch), puede provocar fallos silenciosos o rechazos de promesas sin gestionar dentro del worker.
Conclusiones clave
- Los Web Workers pueden sacar del hilo principal el procesamiento de archivos intensivo en CPU, y mantener la interfaz receptiva durante las subidas.
- Los streams de JavaScript permiten manejar archivos grandes de forma eficiente al procesar los datos en fragmentos, lo que reduce el almacenamiento en búfer de la aplicación. La reanudación es un asunto aparte, propio del protocolo.
- Un pool de workers acota el procesamiento concurrente y ayuda a controlar el uso de recursos cuando se gestionan varias subidas simultáneas.
- Un manejo de errores robusto (incluidos los reintentos de red y el manejo de errores de stream),
una limpieza adecuada de recursos (
terminate,releaseLock), las consideraciones de seguridad (CORS, autenticación) y la atención a la compatibilidad con navegadores son vitales para implementaciones listas para producción.
Para una solución lista para producción que gestione de serie la división en fragmentos, la reanudación, los reintentos y las subidas en paralelo, plantéate usar bibliotecas como Uppy con sus distintos plugins de subida, o explora servicios diseñados para un manejo robusto de archivos. El servicio de gestión de subida de archivos de Transloadit integra estos conceptos para subidas fiables de archivos grandes. ¡Feliz subida!
