Optimizar la subida de archivos en aplicaciones web
El rendimiento de una subida incluye el tiempo de preparación del archivo, su transferencia, los reintentos de solicitudes perdidas y la espera de la validación del servidor. En este DevTip crearás un worker con límites para preparar imágenes y una integración de subidas reanudables en el navegador, con Uppy como interfaz alternativa. Mide estas etapas en tus propios dispositivos y tu red antes de elegir los ajustes de compresión o aumentar la concurrencia.
Problemas habituales al subir archivos
Los archivos grandes pueden agotar la memoria, las redes lentas pueden provocar tiempos de espera agotados y las interrupciones pueden dejar al navegador sin saber si una solicitud tuvo éxito. Una interfaz útil distingue los bytes transferidos de la aceptación del servidor. La seguridad requiere autenticar al propietario y validar en el servidor, incluso si el navegador rechaza primero los archivos claramente inadecuados.
Buenas prácticas para optimizar el rendimiento de la subida de archivos
1. Usa subidas por fragmentos
Usa un protocolo reanudable con posiciones explícitas en lugar de enviar fragmentos de archivos sin identificar. La integración del protocolo tus que aparece a continuación usa fragmentos de 1 MiB y reintentos limitados. Esto limita el tamaño de las retransmisiones; no garantiza una mayor velocidad de transferencia en todas las conexiones. Los fallos HTTP deben hacer que la operación se rechace, incluidos los que ocurran después de que el último byte llegue al servidor.
2. Implementa la compresión del lado del cliente
Redimensionar imágenes puede reducir los bytes a costa de CPU, calidad y metadatos. El worker opcional que aparece a continuación acepta archivos JPEG o PNG de hasta 10 MiB, limita las imágenes decodificadas a 16 millones de píxeles, ajusta ambas dimensiones a un máximo de 1024 × 1024 y genera un JPEG. La transparencia se convierte en blanco y los metadatos no se conservan. Conserva el original cuando esos cambios sean inaceptables. La decodificación puede asignar memoria antes de conocer las dimensiones; esta herramienta con límites facilita el procesamiento de fotos comunes de los usuarios, pero no protege frente a decodificadores de imágenes maliciosos.
Crea image-worker.js:
self.onmessage = async ({ data: file }) => {
let bitmap
try {
if (
!(file instanceof Blob) ||
file.size === 0 ||
file.size > 10 * 1024 * 1024 ||
!['image/jpeg', 'image/png'].includes(file.type)
) {
throw new Error('Unsupported image')
}
bitmap = await createImageBitmap(file)
if (bitmap.width * bitmap.height > 16_000_000) throw new Error('Image too large')
const scale = Math.min(1, 1024 / bitmap.width, 1024 / bitmap.height)
const width = Math.max(1, Math.round(bitmap.width * scale))
const height = Math.max(1, Math.round(bitmap.height * scale))
const canvas = new OffscreenCanvas(width, height)
const context = canvas.getContext('2d')
if (!context) throw new Error('Canvas unavailable')
context.fillStyle = 'white'
context.fillRect(0, 0, width, height)
context.drawImage(bitmap, 0, 0, width, height)
const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality: 0.8 })
if (blob.type !== 'image/jpeg' || blob.size === 0) throw new Error('Encoding failed')
self.postMessage({ blob })
} catch {
self.postMessage({ error: 'Image preparation failed.' })
} finally {
bitmap?.close()
}
}
3. Usa Web Workers para el procesamiento en segundo plano
Los workers son útiles aquí para decodificar y codificar. Fetch y la división de Blob en fragmentos
no necesitan un worker por solicitud. Este contenedor prepare-image.js termina su único
worker cuando la operación tiene éxito, ocurre un error de ejecución o de decodificación de mensajes,
se cancela, se agota el tiempo de espera o falla postMessage:
export function prepareImage(file, signal) {
signal.throwIfAborted()
return new Promise((resolve, reject) => {
const worker = new Worker(new URL('./image-worker.js', import.meta.url), { type: 'module' })
let timer
const finish = (error, blob) => {
clearTimeout(timer)
signal.removeEventListener('abort', abort)
worker.terminate()
if (error) reject(error)
else resolve(new File([blob], 'upload.jpg', { type: 'image/jpeg' }))
}
const abort = () => finish(new Error('Image preparation canceled.'))
signal.addEventListener('abort', abort, { once: true })
timer = setTimeout(() => finish(new Error('Image preparation timed out.')), 30_000)
worker.onerror = () => finish(new Error('Image worker failed.'))
worker.onmessageerror = () => finish(new Error('Invalid worker message.'))
worker.onmessage = ({ data }) => {
if (
!(data?.blob instanceof Blob) ||
data.blob.size === 0 ||
data.blob.type !== 'image/jpeg'
) {
finish(new Error('Image preparation failed.'))
return
}
finish(null, data.blob)
}
try {
worker.postMessage(file)
} catch {
finish(new Error('Could not start image preparation.'))
}
})
}
Consulta la documentación de la plataforma sobre OffscreenCanvas.convertToBlob y Worker.terminate.
Garantizar la seguridad durante la subida de archivos
1. Valida los tipos y tamaños de archivo
Las funciones de subida aceptan archivos de hasta 10 MiB. Los tipos MIME, nombres y tamaños proporcionados por el cliente son metadatos que no son de confianza. El servidor debe limitar los bytes de forma independiente, detectar el contenido compatible y aplicar una política de análisis o decodificación antes de poner a disposición un archivo subido.
2. Usa almacenamiento seguro para los archivos
Los siguientes ejemplos para el navegador requieren una aplicación existente con autenticación,
del mismo origen, con un endpoint del protocolo tus en /api/tus/files/. Este tutorial
se centra en el cliente; no configura ese servidor. Configura la puerta de enlace para validar la
sesión y X-CSRF-TOKEN en las operaciones de modificación y comprobar
X-Upload-Owner con respecto a la sesión en cada solicitud del protocolo tus. Aplica un
límite total de 10 MiB, fragmentos de 1 MiB, límites de almacenamiento y de frecuencia por usuario,
caducidad y almacenamiento provisional privado. Comprueba la propiedad al crear la subida y en las
solicitudes HEAD, PATCH y de terminación. Rechaza las redirecciones y emite únicamente URL de Location
del mismo origen bajo /api/tus/files/.
Cuando el protocolo tus indique que la subida ha finalizado, POST /api/upload-publications acepta
{uploadUrl}. El servidor debe resolver esa URL únicamente contra sus propios
registros de subidas con propietario identificado, sin obtener nunca una URL arbitraria. Verifica
que la subida esté completa y valida el contenido; luego publica de forma atómica una sola vez.
Las solicitudes duplicadas devuelven el mismo comprobante con HTTP 200 o 201. Una respuesta de error
o un código 202 no confirman la publicación. Devuelve {id} con un ID de
comprobante opaco y no vacío. El acceso de descarga debe autorizar por separado al usuario que lo
solicita. Los nombres de archivo aleatorios por sí solos no ofrecen control de acceso.
Implementar subidas reanudables con el protocolo tus
El nombre del protocolo es tus. Instala la versión probada del cliente en tu proyecto para el navegador:
corepack yarn add --exact tus-js-client@4.3.1
corepack yarn add --dev --exact esbuild@0.27.3
Crea upload.js. Pasa el ID del propietario actual y el token CSRF desde tu página
con autenticación; ninguno de ellos es un Auth Secret de Transloadit. Esta función reintenta las
subidas interrumpidas dentro de una misma sesión de página. No asocia automáticamente un archivo
recién seleccionado con una subida anterior basándose únicamente en su nombre. Una subida cancelada
o fallida caduca en el servidor; una nueva llamada crea una nueva subida.
import { Upload } from 'tus-js-client'
export async function uploadFile(file, { owner, csrf, signal, onProgress }) {
signal.throwIfAborted()
if (
!(file instanceof Blob) ||
file.size === 0 ||
file.size > 10 * 1024 * 1024 ||
!owner ||
!csrf
) {
throw new Error('Choose a file between 1 byte and 10 MiB while signed in.')
}
const headers = { 'X-CSRF-TOKEN': csrf, 'X-Upload-Owner': owner }
const uploadUrl = await new Promise((resolve, reject) => {
let settled = false
let timer
const finish = (error, url) => {
if (settled) return
settled = true
clearTimeout(timer)
signal.removeEventListener('abort', abort)
if (error) {
void upload.abort().catch(() => {})
reject(error)
} else resolve(url)
}
const abort = () => finish(new Error('Upload canceled.'))
const upload = new Upload(file, {
endpoint: '/api/tus/files/',
headers,
chunkSize: 1024 * 1024,
storeFingerprintForResuming: false,
retryDelays: [0, 1000, 3000],
onShouldRetry(error) {
const status = error.originalResponse?.getStatus() ?? 0
return status === 0 || status === 409 || status === 423 || status === 429 || status >= 500
},
onBeforeRequest(request) {
const url = new URL(request.getURL(), location.href)
if (url.origin !== location.origin || !url.pathname.startsWith('/api/tus/files/')) {
throw new Error('Unexpected upload URL')
}
},
onProgress(loaded, total) {
try {
onProgress(Math.min(99, Math.floor((100 * loaded) / total)))
} catch {
finish(new Error('Progress display failed.'))
}
},
onError() {
finish(new Error('Upload failed.'))
},
onSuccess() {
if (!upload.url) finish(new Error('Missing upload URL.'))
else finish(null, upload.url)
},
})
signal.addEventListener('abort', abort, { once: true })
timer = setTimeout(() => finish(new Error('Upload timed out.')), 120_000)
try {
upload.start()
} catch {
finish(new Error('Could not start upload.'))
}
})
signal.throwIfAborted()
const response = await fetch('/api/upload-publications', {
method: 'POST',
credentials: 'same-origin',
redirect: 'error',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ uploadUrl }),
signal: AbortSignal.any([signal, AbortSignal.timeout(30_000)]),
})
if (!response.ok || ![200, 201].includes(response.status)) {
throw new Error(`Publication not confirmed (HTTP ${response.status}).`)
}
const receipt = await response.json()
if (typeof receipt.id !== 'string' || !receipt.id) throw new Error('Invalid publication receipt.')
onProgress(100)
return receipt.id
}
Esto utiliza las opciones y callbacks reales de tus-js-client. El plazo de dos minutos limita toda la transferencia, incluidos los reintentos. Si se pierde la respuesta de publicación, el resultado es ambiguo: consulta la lista de subidas de tu servidor antes de iniciar otra subida.
Para una integración completa en la página, genera los atributos data-upload-owner y
data-csrf con sus valores escapados en <html> desde tu
página respaldada por una sesión y añade:
<label>File <input id="file" type="file" /></label>
<label><input id="resize" type="checkbox" /> Prepare JPEG or PNG as JPEG</label>
<button id="upload" type="button">Upload</button>
<button id="cancel" type="button">Cancel</button>
<progress id="progress" aria-label="Upload progress" max="100" value="0"></progress>
<p id="status" role="status"></p>
<script type="module" src="/main.js"></script>
Crea main.js:
import { prepareImage } from './prepare-image.js'
import { uploadFile } from './upload.js'
const input = document.getElementById('file')
const button = document.getElementById('upload')
const status = document.getElementById('status')
let controller = null
button.onclick = async () => {
if (controller || !input.files?.[0]) return
controller = new AbortController()
button.disabled = true
input.disabled = true
status.textContent = 'Preparing upload…'
try {
let file = input.files[0]
if (document.getElementById('resize').checked) {
file = await prepareImage(file, controller.signal)
}
await uploadFile(file, {
owner: document.documentElement.dataset.uploadOwner,
csrf: document.documentElement.dataset.csrf,
signal: controller.signal,
onProgress(value) {
document.getElementById('progress').value = value
},
})
status.textContent = 'Upload published.'
} catch {
status.textContent = 'Upload failed or canceled. Check your uploads before retrying.'
} finally {
controller = null
button.disabled = false
input.disabled = false
}
}
document.getElementById('cancel').onclick = () => controller?.abort()
Empaqueta el punto de entrada principal y sirve el worker junto a él en el mismo origen HTTPS:
corepack yarn esbuild main.js --bundle --format=esm --outfile=public/main.js
cp image-worker.js public/image-worker.js
Usar Uppy para una experiencia de usuario fluida
Uppy proporciona una interfaz para la selección, las restricciones, el progreso y la cancelación.
Para esta puerta de enlace, usa la interfaz de subida personalizada de Uppy para llamar a la
misma función uploadFile. Así, la publicación sigue siendo un requisito para
considerar exitosa la operación y se usan el mismo plazo, los mismos encabezados de autenticación y
el mismo manejo de fallos. Este es un punto de entrada alternativo, no un componente de subida
adicional para montar junto a la página anterior.
Instala paquetes compatibles:
corepack yarn add --exact @uppy/core@5.2.0 @uppy/dashboard@5.1.1
Crea uppy-main.js y móntalo en una página con <div id="drag-drop-area"></div> y los mismos
atributos de sesión. La aplicación debe llamar a la función de limpieza devuelta al eliminar esta
interfaz.
import Uppy from '@uppy/core'
import Dashboard from '@uppy/dashboard'
import '@uppy/core/css/style.min.css'
import '@uppy/dashboard/css/style.min.css'
import { uploadFile } from './upload.js'
export function mountUploader() {
const uppy = new Uppy({
restrictions: { maxNumberOfFiles: 1, maxFileSize: 10 * 1024 * 1024, minFileSize: 1 },
}).use(Dashboard, { inline: true, target: '#drag-drop-area' })
let controller = null
uppy.on('cancel-all', () => controller?.abort())
uppy.on('file-removed', () => controller?.abort())
uppy.addUploader(async (ids) => {
const file = uppy.getFile(ids[0])
if (!file || !(file.data instanceof Blob)) throw new Error('Select a local file.')
controller = new AbortController()
uppy.emit('upload-start', [file])
try {
await uploadFile(file.data, {
owner: document.documentElement.dataset.uploadOwner,
csrf: document.documentElement.dataset.csrf,
signal: controller.signal,
onProgress(value) {
uppy.emit('upload-progress', file, {
uploadStarted: Date.now(),
bytesUploaded: Math.floor((file.data.size * value) / 100),
bytesTotal: file.data.size,
})
},
})
uppy.emit('upload-success', file, { status: 200, body: {} })
} catch {
uppy.emit('upload-error', file, new Error('Upload not confirmed. Check your uploads.'))
throw new Error('Upload not confirmed.')
} finally {
controller = null
}
})
return () => {
controller?.abort()
uppy.destroy()
}
}
let cleanup = mountUploader()
window.addEventListener('pagehide', () => cleanup())
window.addEventListener('pageshow', (event) => {
if (event.persisted) cleanup = mountUploader()
})
Empaqueta uppy-main.js con esbuild como antes e incluye en tu página tanto el
JavaScript como el CSS generados. Las subidas desde proveedores remotos necesitan una integración
independiente con Companion y autenticación; este ejemplo solo acepta objetos Blob locales.
Consulta la API de subida de Uppy.
Conclusión
Empieza por limitar los tamaños de archivo, enviar fragmentos secuenciales, gestionar los fallos explícitamente y confirmar la publicación desde el servidor. Añade la preparación de imágenes cuando la pérdida de calidad sea aceptable. Mide por separado el tiempo de transferencia, el tiempo de preparación, los reintentos y la finalización antes de afinar los ajustes.
Recursos adicionales
Respuestas a preguntas frecuentes
¿Cómo confirmas una subida? El recuento de bytes de una transferencia no basta. Confirma un comprobante autenticado del servidor después de la validación y la publicación. El análisis de malware específico de cada proveedor es una integración independiente; no existe un comando de CLI universal para verificar subidas.
¿Qué es una subida de archivos sin restricciones? Es un endpoint de subida que acepta contenido sin las restricciones adecuadas, lo que puede permitir almacenar o ejecutar archivos peligrosos. Valida en el servidor y aísla el almacenamiento del contenido web ejecutable.
¿Qué codificación de formularios HTML permite subir archivos? Un formulario tradicional usa
method="post" y enctype="multipart/form-data", con un campo de archivo que tenga nombre.
Al enviar FormData con Fetch o Axios en el navegador, deja que el navegador
establezca el delimitador multipart; no inventes el encabezado por tu cuenta.
