Optimización de la subida de archivos en aplicaciones web
El rendimiento de las subidas incluye el tiempo dedicado a preparar un archivo, transferirlo, reintentar las solicitudes perdidas y esperar la validación del servidor. Este DevTip crea 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 red antes de elegir los ajustes de compresión o aumentar la concurrencia.
Estos ejemplos se integran en una aplicación existente basada en sesiones que cumple el contrato
del gateway descrito a continuación. Ejecuta la configuración y las compilaciones desde la raíz
del proyecto Yarn 4 de esa aplicación, con un package.json y un directorio
public/. Los comandos actualizan las dependencias y reemplazan los activos
compilados indicados; no crean ni inician un servidor de aplicaciones. Node 26.11.1 y Yarn 4.18.1
son las versiones de compilación probadas, no versiones mínimas intrínsecas para las subidas desde
el navegador. Instala Yarn por separado siguiendo su
guía de instalación. El navegador necesita workers de módulo,
createImageBitmap, OffscreenCanvas y AbortSignal.any()/timeout().
Problemas comunes con las subidas de archivos
Los archivos grandes pueden agotar la memoria, las redes lentas pueden causar tiempos de espera agotados y las interrupciones pueden dejar al navegador sin saber si una solicitud se completó correctamente. Una interfaz útil distingue los bytes transferidos de la aceptación del servidor. La seguridad depende de la titularidad autenticada y de la validación del servidor, incluso cuando el navegador rechaza primero los archivos claramente inadecuados.
Buenas prácticas para optimizar el rendimiento de las subidas de archivos
1. Usa subidas por fragmentos
Usa un protocolo reanudable con posiciones explícitas en lugar de enviar fragmentos de archivos anónimos. La integración del protocolo tus que se muestra a continuación usa fragmentos de 1 MiB y reintentos limitados. Esto limita el tamaño de las retransmisiones; no promete una mayor velocidad de transferencia en todas las conexiones. Los errores HTTP que no se resuelven provocan el rechazo de la operación, incluidos los errores posteriores a la llegada del último byte 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 se muestra 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 1024 × 1024 y genera un JPEG. La transparencia se convierte en blanco y los metadatos no se copian del archivo de entrada. El codificador JPEG puede añadir sus propios metadatos, como la resolución. La conversión de color del navegador puede cambiar los colores, incluso en los JPEG CMYK; inspecciona el resultado y conserva el original cuando esos cambios sean inaceptables. La decodificación puede asignar memoria antes de conocer las dimensiones; esta función con límites facilita el procesamiento de fotos normales de usuarios, pero no protege frente a decodificadores de imágenes maliciosos.
La decodificación del navegador tampoco demuestra que un archivo de entrada dañado esté intacto. La validación de contenido del lado del servidor sigue aplicándose al JPEG preparado. La preparación tiene un plazo de 30 segundos; un dispositivo lento puede agotar ese plazo con una imagen que, por lo demás, cumple los requisitos.
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 procesar en segundo plano
Los workers resultan útiles aquí para la decodificación y el encoding. Fetch y la división de
objetos Blob 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 settled = false
let timer
const finish = (error, blob) => {
if (settled) return
settled = true
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 las subidas de archivos
1. Valida los tipos y tamaños de los archivos
Las funciones de subida aceptan archivos de hasta 10 MiB. Los tipos MIME, nombres y tamaños proporcionados por el cliente son metadatos no confiables. El servidor debe limitar los bytes de forma independiente, detectar el contenido compatible y aplicar una política de análisis de seguridad o de decodificación antes de permitir el acceso a una subida.
2. Usa almacenamiento seguro para archivos
Los siguientes ejemplos para el navegador requieren una aplicación existente con autenticación y
del mismo origen, con un endpoint del protocolo tus en /api/tus/files/. Este es un
tutorial del lado del cliente; no aprovisiona ese servidor. El
servidor Node.js del protocolo tus, que cuenta con mantenimiento,
proporciona implementaciones del protocolo y del almacenamiento; el gateway de tu aplicación debe
proporcionar la sesión y la política de publicación. Configura el gateway para validar la sesión y
X-CSRF-TOKEN en las operaciones de modificación y comprobar
X-Upload-Owner frente 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 temporal privado. Comprueba la titularidad al crear la subida y en las
solicitudes HEAD, PATCH y cualquier solicitud de terminación. Rechaza las redirecciones y emite
únicamente URL Location del mismo origen bajo
/api/tus/files/.
Después de que el protocolo tus informe que la subida se completó, POST /api/upload-publications
acepta {uploadUrl}. El servidor debe resolver esa URL únicamente mediante sus
propios registros de subidas con titularidad, sin obtener nunca una URL arbitraria. Verifica que
la subida esté completa y valida el contenido; después, publica de forma atómica una sola vez.
Las solicitudes duplicadas devuelven el mismo recibo con HTTP 200 o 201. Una respuesta que no
indique éxito o una respuesta 202 no confirma la publicación. Devuelve
{id} con un ID de recibo opaco y no vacío. El acceso de descarga debe
autorizar por separado al usuario solicitante. Los nombres de archivo aleatorios por sí solos no
proporcionan control de acceso.
Implementar subidas reanudables con el protocolo tus
El protocolo se llama tus. Instala la versión probada del cliente en tu proyecto para el navegador:
(command -v node >/dev/null && command -v yarn >/dev/null &&
yarn add --exact tus-js-client@4.3.1 &&
yarn add --dev --exact esbuild@0.28.2)
Crea upload.js. Pasa el ID del titular actual y el token CSRF desde tu página
autenticada; ninguno de los dos es un Auth Secret de Transloadit. Esta función reintenta las
transferencias 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 solo en su nombre. Una subida
abortada 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,
httpStack: {
getName() { return 'SameOriginFetch' },
createRequest(method, url) {
const controller = new AbortController()
const requestHeaders = new Headers()
return {
getMethod() { return method },
getURL() { return url },
getUnderlyingObject() { return controller },
setHeader(name, value) { requestHeaders.set(name, value) },
getHeader(name) { return requestHeaders.get(name) },
setProgressHandler() {},
async abort() { controller.abort() },
async send(body) {
const response = await fetch(url, {
method, body, headers: requestHeaders,
credentials: 'same-origin', redirect: 'error', signal: controller.signal,
})
const text = response.ok ? await response.text() : ''
return {
getStatus() { return response.status },
getHeader(name) { return response.headers.get(name) },
getBody() { return text },
getUnderlyingObject() { return response },
}
},
}
},
},
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 usa las opciones y funciones de devolución de llamada reales de tus-js-client. Su pequeño transporte Fetch implementa la interfaz de stack HTTP del cliente para rechazar las redirecciones antes de seguirlas. El progreso avanza en los límites de los fragmentos del protocolo tus en lugar de registrar los bytes en tránsito. Tras perder una respuesta, un navegador puede reenviar una solicitud antes de que el protocolo tus concilie la posición confirmada; el servidor debe rechazar una posición desactualizada sin añadir bytes duplicados. El plazo de dos minutos limita toda la transferencia, incluidos los reintentos. Una respuesta de publicación perdida deja el resultado incierto: consulta la lista de subidas de tu servidor antes de iniciar otra subida. Una conexión lenta puede alcanzar este plazo incluso con menos de 10 MiB. La publicación final tiene un plazo independiente de 30 segundos. La cancelación detiene el trabajo del cliente, pero no demuestra que el servidor haya eliminado o retenido una subida; el almacenamiento temporal abandonado debe caducar en el servidor.
Para una integración completa en la página, genera los atributos escapados
data-upload-owner y data-csrf en
<html> desde tu página basada en sesiones 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')
const resize = document.getElementById('resize')
let controller = null
button.onclick = async () => {
if (controller || !input.files?.[0]) return
controller = new AbortController()
button.disabled = true
input.disabled = true
resize.disabled = true
document.getElementById('progress').value = 0
status.textContent = 'Preparing upload…'
try {
let file = input.files[0]
if (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
resize.disabled = false
}
}
document.getElementById('cancel').onclick = () => controller?.abort()
Empaqueta el punto de entrada principal y sirve el worker junto a él desde el mismo origen HTTPS:
(command -v node >/dev/null && command -v yarn >/dev/null && command -v cp >/dev/null &&
yarn esbuild main.js --bundle --format=esm --tsconfig-raw='{}' --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 este gateway, usa la interfaz de subida personalizada de Uppy para llamar a la misma
función uploadFile. Esto incluye la publicación como parte del éxito y usa el
mismo plazo, los mismos encabezados de autenticación y el mismo manejo de errores. Es un punto de
entrada alternativo, no una interfaz de subida adicional para montar junto a la página anterior.
Instala paquetes compatibles:
(command -v node >/dev/null && command -v yarn >/dev/null &&
yarn add --exact @uppy/core@6.2.0 @uppy/dashboard@6.0.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 cuando
retire 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 active = null
let destroyed = false
const cancel = () => {
const previous = active
active = null
previous?.controller.abort()
}
uppy.on('cancel-all', cancel)
uppy.on('file-removed', (file) => {
if (active?.fileId === file.id) cancel()
})
uppy.addUploader(async (ids) => {
if (active) throw new Error('An upload is already running.')
const file = uppy.getFile(ids[0])
if (!file || !(file.data instanceof Blob)) throw new Error('Select a local file.')
const operation = { fileId: file.id, controller: new AbortController() }
const uploadStarted = Date.now()
active = operation
const current = () => !destroyed && active === operation && Boolean(uppy.getFile(file.id))
uppy.emit('upload-start', [file])
try {
const id = await uploadFile(file.data, {
owner: document.documentElement.dataset.uploadOwner,
csrf: document.documentElement.dataset.csrf,
signal: operation.controller.signal,
onProgress(value) {
if (!current()) return
uppy.emit('upload-progress', file, {
uploadStarted,
bytesUploaded: Math.floor((file.data.size * value) / 100),
bytesTotal: file.data.size,
})
},
})
if (current()) uppy.emit('upload-success', file, { status: 200, body: { id } })
} catch {
if (!current()) return
uppy.emit('upload-error', file, new Error('Upload not confirmed. Check your uploads.'))
throw new Error('Upload not confirmed.')
} finally {
if (active === operation) active = null
}
})
return () => {
destroyed = true
cancel()
uppy.destroy()
}
}
let cleanup = mountUploader()
window.addEventListener('pagehide', () => cleanup())
window.addEventListener('pageshow', (event) => {
if (event.persisted) cleanup = mountUploader()
})
Usa este marcado en lugar de los controles y el script de la página básica, con los mismos
atributos de sesión en <html>:
<link rel="stylesheet" href="/uppy-main.css" />
<div id="drag-drop-area"></div>
<script type="module" src="/uppy-main.js"></script>
Compila el punto de entrada alternativo:
(command -v node >/dev/null && command -v yarn >/dev/null &&
yarn esbuild uppy-main.js --bundle --format=esm --tsconfig-raw='{}' --outfile=public/uppy-main.js)
La cancelación retira la operación actual antes de admitir otro archivo, de modo que las funciones
de devolución de llamada tardías no puedan marcar como fallida la operación que la reemplaza ni
borrar su manejador de cancelación. Al producirse pagehide, esta integración
destruye el Dashboard y cancela el trabajo activo del cliente; una restauración del estado
persistido monta un Dashboard vacío. Vuelve a seleccionar el archivo después de regresar. Las
subidas desde proveedores remotos necesitan una integración de Companion autenticada e
independiente; este ejemplo solo acepta objetos Blob locales. Consulta la
API de subida de Uppy.
Confirma el resultado publicado
En la página básica, elige un archivo, activa opcionalmente la preparación de imágenes y selecciona Upload. La página espera un recibo válido del servidor antes de mostrar Upload published.
El estado correspondiente de Uppy es Complete. Una respuesta 202, una validación fallida o un tiempo de espera agotado dejan la subida sin confirmar, incluso cuando se enviaron todos los bytes. Consulta la lista de subidas autenticada antes de reintentar una publicación de resultado incierto; una nueva llamada crea nuevo almacenamiento temporal.
Para un archivo sin preparar, compara el número de bytes almacenados y el SHA-256 con los del archivo seleccionado mediante las herramientas de inspección autorizadas de tu aplicación. Para una imagen preparada, inspecciona el JPEG resultante: ninguna dimensión supera 1024, su relación de aspecto se conserva dentro del margen de redondeo y las regiones transparentes tienen un fondo blanco. El redimensionamiento y el encoding JPEG pueden aumentar el tamaño de un PNG pequeño, así que mide el tamaño del archivo preparado y su calidad visual antes de activar la preparación de forma predeterminada.
Recursos adicionales
Respuestas a preguntas frecuentes
¿Cómo confirmas una subida? El número de bytes de una transferencia no basta. Confirma un recibo 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 CLI universal para verificar subidas.
¿Qué es una subida de archivos sin restricciones? Es un endpoint de subida que acepta contenido sin 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 selección de
archivos que tiene nombre. Al enviar FormData con Fetch o Axios en el
navegador, deja que el navegador establezca el delimitador multipart; no construyas el encabezado
por tu cuenta.
