Acelera las subidas de archivos en JS con Web Workers
El procesamiento de archivos puede dejar sin respuesta una interfaz de subida. Los Web Workers sacan el trabajo de JavaScript del hilo principal y lo dejan libre para la entrada del usuario y el renderizado. No aumentan el ancho de banda de la conexión: las subidas por red ya son asíncronas. Mide el costo de arranque y de mensajería de los workers frente al trabajo de CPU que necesitas realizar.
Introducción a los Web Workers y sus beneficios
Los workers son útiles para tareas como transformaciones de imágenes o análisis de archivos. No tienen acceso al DOM de la página, así que un pequeño protocolo de mensajes conecta el procesamiento con la interfaz visible. Mantén un número acotado de workers y libéralos cuando una operación termina o se cancela.
Este ejemplo usa TypeScript con un empaquetador como Vite. Las anotaciones de tipos se eliminan durante la compilación; los navegadores ejecutan los módulos de JavaScript resultantes. Mantenemos una sola implementación del worker en lugar de mantener copias separadas de JavaScript y TypeScript. El cálculo de SHA-256 ilustra un paso de procesamiento para archivos pequeños, pero Web Crypto es asíncrono en sí mismo y no demuestra que mover una subida a un worker la haga más rápida.
Parte de un proyecto de Vite vanilla con TypeScript o instala las herramientas de compilación en un proyecto existente:
yarn add --dev typescript vite
Configurar una subida de archivos básica en JavaScript
Agrega este formulario a index.html en tu proyecto de Vite. Sírvelo desde localhost en desarrollo o por HTTPS
en producción; no abras los ejemplos con workers a través de una URL file:.
<label for="fileInput">File to upload</label>
<input type="file" id="fileInput" />
<button type="button" id="uploadBtn">Upload</button>
<button type="button" id="cancelBtn" disabled>Cancel</button>
<label for="uploadProgress">Upload progress</label>
<progress id="uploadProgress" value="0" max="100"></progress>
<p id="status" role="status"></p>
<script type="module" src="/src/main.ts"></script>
Los endpoints del servidor de este tutorial son contratos de la aplicación, no rutas integradas de
Vite. /upload acepta un file multipart. /upload-chunk acepta chunk, uploadId, chunkIndex, totalChunks
y fileName; /complete-upload acepta JSON con uploadId y totalChunks. Cada uno devuelve un estado
HTTP de éxito solo después de aceptar la operación correspondiente. Los cuerpos de las respuestas no
se usan.
Autentica las solicitudes y autoriza cada ID de subida, aplica límites, almacena los fragmentos de forma idempotente y verifica que estén completos antes de finalizar. Nunca conviertas el nombre de archivo recibido en una ruta de almacenamiento sin validar. Haz que las subidas abandonadas expiren. Si necesitas un protocolo reanudable con mantenimiento en lugar de este contrato didáctico, usa tus con un servidor compatible.
Integrar Web Workers para el procesamiento de archivos
Crea src/upload.worker.ts. Cada worker atiende una sola operación. Los archivos pequeños se hashean antes
de subirse; los archivos más grandes omiten el hash del archivo completo y usan fragmentos
secuenciales de 5 MiB. Los errores se convierten en mensajes saneados, y el hilo principal termina el
worker tras completarse o fallar.
import type { WorkerMessage, WorkerResponse } from './worker-types.ts'
declare const self: DedicatedWorkerGlobalScope
const CHUNK_SIZE = 5 * 1024 * 1024
function send(message: WorkerResponse): void {
self.postMessage(message)
}
function uploadRequest(body: FormData, endpoint: string, onProgress: (ratio: number) => void): Promise<void> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open('POST', endpoint)
xhr.timeout = 60_000
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) onProgress(event.loaded / event.total)
}
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) resolve()
else reject(new Error('Upload request rejected'))
}
xhr.onerror = () => reject(new Error('Upload network error'))
xhr.ontimeout = () => reject(new Error('Upload timed out'))
xhr.onabort = () => reject(new Error('Upload canceled'))
xhr.send(body)
})
}
async function run({ file }: WorkerMessage): Promise<void> {
if (file.size === 0) throw new Error('Empty file')
if (file.size <= CHUNK_SIZE) {
const hash = await crypto.subtle.digest('SHA-256', await file.arrayBuffer())
const sha256 = Array.from(new Uint8Array(hash), (byte) => byte.toString(16).padStart(2, '0')).join('')
send({ type: 'processed', sha256 })
const body = new FormData()
body.append('file', file)
await uploadRequest(body, '/upload', (ratio) => send({ type: 'progress', percent: ratio * 100 }))
} else {
const uploadId = crypto.randomUUID()
const totalChunks = Math.ceil(file.size / CHUNK_SIZE)
for (let index = 0; index < totalChunks; index++) {
const start = index * CHUNK_SIZE
const chunk = file.slice(start, start + CHUNK_SIZE)
const body = new FormData()
body.append('chunk', chunk)
body.append('uploadId', uploadId)
body.append('chunkIndex', String(index))
body.append('totalChunks', String(totalChunks))
body.append('fileName', file.name)
await uploadRequest(body, '/upload-chunk', (ratio) => {
send({ type: 'progress', percent: (start + ratio * chunk.size) / file.size * 100 })
})
}
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')
}
send({ type: 'complete' })
}
self.onmessage = (event: MessageEvent<WorkerMessage>) => {
run(event.data).catch(() => send({ type: 'error', message: 'Upload failed. Please try again.' }))
}
Compatibilidad de TypeScript con los Web Workers
Crea src/worker-types.ts para ambos lados del contrato de mensajes:
export interface WorkerMessage {
file: File
}
export type WorkerResponse =
| { type: 'processed'; sha256: string }
| { type: 'progress'; percent: number }
| { type: 'complete' }
| { type: 'error'; message: string }
Revisa por separado los archivos del hilo principal y los del worker para que TypeScript no combine
globales de DOM y de worker en conflicto. Usa este tsconfig.json para la página:
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler",
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": [],
"strict": true,
"verbatimModuleSyntax": true,
"allowImportingTsExtensions": true,
"noEmit": true
},
"include": ["src/main.ts", "src/worker-types.ts"]
}
Luego agrega tsconfig.worker.json:
{
"extends": "./tsconfig.json",
"compilerOptions": { "lib": ["ES2022", "WebWorker"] },
"include": ["src/upload.worker.ts", "src/worker-types.ts"]
}
Ejecuta ambas comprobaciones. Vite gestiona la compilación del worker de módulo por separado de la verificación de tipos:
yarn tsc --project tsconfig.json
yarn tsc --project tsconfig.worker.json
yarn vite
Manejar subidas de archivos grandes de forma eficiente
La rama por fragmentos del worker corta un fragmento a la vez en lugar de materializar el archivo
completo en memoria. El worker elige esta rama para los archivos que superan su límite CHUNK_SIZE. El
progreso usa conteos de bytes, así que un último fragmento corto no pesa lo mismo que un fragmento
completo.
Este ejemplo no tiene lógica de reintento ni de reanudación tras recargar la página. Una solicitud fallida detiene la operación y un nuevo intento obtiene un nuevo ID de subida. No afirmes que hay reanudación solo porque un archivo se divide en fragmentos. La cancelación detiene la actividad del cliente, pero no puede deshacer los bytes que el servidor ya aceptó; la expiración en el backend o un endpoint de cancelación explícito y autorizado debe encargarse de limpiarlos.
Implementar indicadores de progreso robustos y manejo de errores
Crea src/main.ts. El File seleccionado se captura una vez por clic, no se vuelve a leer después del procesamiento.
Los controles evitan ejecuciones superpuestas, mientras que el éxito, el fallo y la cancelación
liberan el worker.
import type { WorkerMessage, WorkerResponse } from './worker-types.ts'
const fileInput = document.getElementById('fileInput')
const uploadBtn = document.getElementById('uploadBtn')
const cancelBtn = document.getElementById('cancelBtn')
const uploadProgress = document.getElementById('uploadProgress')
const statusElement = document.getElementById('status')
if (!(fileInput instanceof HTMLInputElement) || !(uploadBtn instanceof HTMLButtonElement)
|| !(cancelBtn instanceof HTMLButtonElement) || !(uploadProgress instanceof HTMLProgressElement)
|| !(statusElement instanceof HTMLElement)) {
throw new Error('Missing upload controls')
}
let worker: Worker | null = null
const finish = (message: string): void => {
worker?.terminate()
worker = null
uploadBtn.disabled = false
fileInput.disabled = false
cancelBtn.disabled = true
statusElement.textContent = message
}
uploadBtn.addEventListener('click', () => {
const file = fileInput.files?.[0]
if (!file || file.size === 0) {
statusElement.textContent = 'Please select a nonempty file.'
return
}
if (worker) return
uploadProgress.value = 0
uploadBtn.disabled = true
fileInput.disabled = true
cancelBtn.disabled = false
statusElement.textContent = 'Preparing upload.'
try {
worker = new Worker(new URL('./upload.worker.ts', import.meta.url), { type: 'module' })
worker.onmessage = (event: MessageEvent<WorkerResponse>) => {
const response = event.data
switch (response.type) {
case 'processed':
statusElement.textContent = 'File processed. Uploading.'
break
case 'progress':
uploadProgress.value = response.percent
statusElement.textContent = `Uploading: ${Math.round(response.percent)}%`
break
case 'complete':
uploadProgress.value = 100
finish('Upload complete.')
break
case 'error':
finish(response.message)
break
}
}
worker.onerror = (event) => {
event.preventDefault()
finish('The upload worker failed. Please try again.')
}
worker.onmessageerror = () => finish('Could not read the upload worker response.')
worker.postMessage({ file } satisfies WorkerMessage)
} catch {
finish('Could not start the upload worker.')
}
})
cancelBtn.addEventListener('click', () => finish('Upload canceled.'))
window.addEventListener('pagehide', () => finish('Upload stopped.'))
Prueba con un archivo pequeño, un archivo más grande que un fragmento, una finalización fallida, una cancelación y una segunda subida. Un mensaje de éxito del worker significa que el servidor aceptó el contrato de subida, no que el archivo haya pasado el antivirus u otro procesamiento. Mantén esos estados diferenciados en una interfaz de producción.
Si necesitas un cargador de archivos con mantenimiento, con progreso y transferencias reanudables, Uppy puede encargarse del flujo de trabajo de subida mientras reservas los workers para el procesamiento que de verdad se beneficia de ellos.
