Subida resiliente de archivos con sincronización en segundo plano
Un reintento fiable empieza por datos persistentes. Este ejemplo guarda archivos pequeños en IndexedDB antes de intentar subirlos y luego vuelve a procesar la cola desde una página o un service worker. Conserva la explicación del artículo original sobre la fragmentación y los workers, pero separa esas técnicas de los reintentos basados en datos persistentes.
Background Sync funciona sin garantías: los navegadores pueden detener un worker, eliminar los datos del sitio o negarse a programar otro intento. Requiere un contexto seguro y no está disponible en todos los navegadores. Detecta su disponibilidad y conserva una opción explícita de reintento en primer plano, como se describe en la guía de sincronización en segundo plano de MDN.
Comprende las subidas por fragmentos
La fragmentación reduce la cantidad de datos que se reenvían tras una interrupción. La concurrencia puede mejorar una conexión concreta, pero las solicitudes adicionales también pueden aumentar la competencia por los recursos. Ni dividir un Blob ni crear un Worker hace que una cola sea persistente. Un objeto File que solo se conserva en la memoria de la página desaparece al recargarla.
Implementa subidas por fragmentos
El flujo ejecutable de este ejemplo acepta deliberadamente un máximo de diez archivos de 10 MiB cada uno y envía cada archivo en una sola solicitud. Los reintentos abarcan archivos completos. Para archivos más grandes, usa un protocolo reanudable y guarda de forma persistente su URL de subida y su desplazamiento junto al Blob; no concatenes fragmentos anónimos en un servidor.
Los archivos completos para el navegador que aparecen a continuación requieren una aplicación existente con autenticación, del mismo origen, cuyo servidor cumpla este contrato. No implementan la autenticación, el almacenamiento ni un backend de subida:
| Endpoint | Contrato requerido |
|---|---|
GET /api/upload-session | Devuelve {owner, csrf} para la sesión actual basada en cookies con Cache-Control: no-store; nunca permitas lecturas desde otros orígenes. Devuelve 401 cuando no haya una sesión iniciada. |
POST /api/queued-uploads | Exige la sesión, un X-CSRF-TOKEN válido y un X-Upload-Owner que corresponda a esa sesión. Acepta el campo multipart file y un UUID Idempotency-Key. |
El servidor debe vincular (owner, key) al resumen criptográfico del contenido y a su
tamaño en un almacenamiento persistente, serializar los intentos concurrentes, rechazar el contenido
modificado con 409 y devolver el mismo {id} cuando un reintento tenga éxito.
Tanto en el primer envío exitoso como en cada reintento exitoso, id debe ser
exactamente igual al Idempotency-Key enviado (entry.id en
queue.js), no a un ID de objeto generado por el servidor.
Aplica allí el límite de archivos, la política de contenido multimedia, el tiempo de espera de las
solicitudes, la cuota por usuario y el límite de frecuencia.
Almacena temporalmente los bytes de forma privada; valídalos y analízalos según sea necesario, y luego
publica el objeto y su comprobante de forma atómica. Devuelve 200 o 201 solo después de confirmar esta
operación, nunca 202. Conserva los comprobantes al menos durante las 24 horas de vigencia de la cola
más un periodo de gracia para reintentos. Rechaza las claves vencidas después de ese plazo, en lugar
de tratar un reintento antiguo como una nueva subida. Conserva un registro persistente del vencimiento
de cada (owner, key) tras eliminar los comprobantes y consúltalo antes de aceptar una
subida, para que una clave vencida nunca pueda volver a considerarse nueva. No se envía al navegador
ningún Auth Secret de terceros.
Subidas en paralelo con Web Workers
Fetch ya realiza operaciones asíncronas de entrada y salida de red. Este ejemplo usa una solicitud a la vez y un Web Lock que abarca todo el origen, incluidas las pestañas y el service worker. Así se evita crear una cantidad ilimitada de workers, uno por fragmento, y no quedan workers dedicados que haya que terminar ante un fallo. Aun así, un service worker puede detenerse entre la confirmación en el servidor y la eliminación local; la clave de idempotencia estable hace que ese reintento sea seguro.
La página requiere IndexedDB y Web Locks. Si alguno no está disponible, muestra un mensaje claro de incompatibilidad y usa el flujo de subida directa de tu aplicación. Background Sync es opcional.
Usa el protocolo tus para subidas reanudables
El protocolo tus permite negociar el desplazamiento para subidas parciales. Su servidor sigue necesitando controles de propiedad, cuotas y almacenamiento temporal privado. Una URL de subida del protocolo tus no es una URL de descarga pública, y guardar la URL localmente no conserva los bytes del archivo original. Consulta el ejemplo de cliente con subidas reanudables como alternativa a volver a subir archivos pequeños completos.
Integra la sincronización en segundo plano
Instala las versiones fijadas de la biblioteca que adapta IndexedDB para usar promesas y del empaquetador en un proyecto de cliente independiente:
corepack yarn add --exact idb@8.0.3
corepack yarn add --dev --exact esbuild@0.27.3
Crea queue.js. Dentro de una transacción solo se espera a que terminen las
solicitudes de IndexedDB; la solicitud de red termina antes de que comience una nueva transacción
de eliminación. Esperar a tx.done garantiza que las escrituras se hayan confirmado.
Esta distinción se explica en la
documentación sobre transacciones de idb.
import { openDB } from 'idb'
const maxSize = 10 * 1024 * 1024
const lifetime = 24 * 60 * 60 * 1000
const lockName = 'durable-file-uploads'
async function database() {
return openDB('durable-file-uploads', 1, {
upgrade(db) {
db.createObjectStore('uploads', { keyPath: 'id' })
},
})
}
export async function enqueue(file, owner) {
if (!owner || !(file instanceof File) || file.size === 0 || file.size > maxSize) {
throw new Error('Choose a file between 1 byte and 10 MiB while signed in.')
}
const db = await database()
try {
const tx = db.transaction('uploads', 'readwrite')
const id = crypto.randomUUID()
// Observe request and transaction failures together, including quota-induced aborts.
await Promise.all([
(async () => {
// Counting and inserting in one transaction also bounds concurrent tabs.
if ((await tx.store.count()) >= 10) {
throw new Error('The queue is full. Retry or discard queued files first.')
}
await tx.store.add({ id, owner, file, name: file.name, created: Date.now() })
})(),
tx.done,
])
return id
} finally {
db.close()
}
}
export async function drain(signal = new AbortController().signal) {
return navigator.locks.request(lockName, { signal }, async () => {
const db = await database()
try {
// The count is bounded at ten. No readwrite transaction spans a fetch.
const entries = await db.getAll('uploads')
if (entries.length === 0) return
const sessionResponse = await fetch('/api/upload-session', {
credentials: 'same-origin',
cache: 'no-store',
redirect: 'error',
signal: AbortSignal.any([signal, AbortSignal.timeout(30_000)]),
})
if (!sessionResponse.ok) throw new Error('Sign in again before retrying.')
const session = await sessionResponse.json()
if (
typeof session.owner !== 'string' ||
!session.owner ||
typeof session.csrf !== 'string' ||
!session.csrf
) {
throw new Error('Invalid upload session.')
}
for (const entry of entries) {
signal.throwIfAborted()
if (entry.owner !== session.owner) continue
if (Date.now() - entry.created >= lifetime) {
throw new Error('A queued file expired. Discard it before retrying.')
}
const body = new FormData()
body.append('file', entry.file, entry.name)
const response = await fetch('/api/queued-uploads', {
method: 'POST',
credentials: 'same-origin',
redirect: 'error',
body,
headers: {
'X-CSRF-TOKEN': session.csrf,
'X-Upload-Owner': entry.owner,
'Idempotency-Key': entry.id,
},
signal: AbortSignal.any([signal, AbortSignal.timeout(30_000)]),
})
if (!response.ok || ![200, 201].includes(response.status)) {
throw new Error(`Upload not confirmed (HTTP ${response.status}).`)
}
const receipt = await response.json()
if (receipt.id !== entry.id) throw new Error('Invalid upload receipt.')
const tx = db.transaction('uploads', 'readwrite')
await Promise.all([tx.store.delete(entry.id), tx.done])
}
} finally {
db.close()
}
})
}
export async function discard(owner) {
// Serialize with uploads so a deletion cannot race a foreground or background replay.
await navigator.locks.request(lockName, async () => {
const db = await database()
try {
const tx = db.transaction('uploads', 'readwrite')
await Promise.all([
(async () => {
for (const entry of await tx.store.getAll()) {
if (entry.owner === owner) await tx.store.delete(entry.id)
}
})(),
tx.done,
])
} finally {
db.close()
}
})
}
Crea upload-sw.js. Si se rechaza waitUntil, la cola permanece intacta
y el navegador recibe la indicación de que el intento de sincronización falló. Un reintento posterior
obtiene un token CSRF nuevo en lugar de guardar las credenciales de forma persistente.
import { drain } from './queue.js'
self.addEventListener('install', (event) => event.waitUntil(self.skipWaiting()))
self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim()))
self.addEventListener('sync', (event) => {
if (event.tag === 'file-upload-sync') event.waitUntil(drain())
})
En tu página con autenticación renderizada en el servidor, incluye el ID opaco del usuario actual en
el atributo data-upload-owner de <html>, con los caracteres escapados.
Incluye estos controles y el módulo compilado:
<label>File <input id="file" type="file" /></label>
<button id="queue" type="button">Queue file</button>
<button id="retry" type="button">Retry queued files</button>
<button id="stop" type="button">Stop foreground retry</button>
<button id="discard" type="button">Discard my queued files</button>
<p id="status" role="status"></p>
<script type="module" src="/upload-page.js"></script>
Crea upload-page.js. Los datos se guardan correctamente incluso si se rechaza el registro
o la programación de la sincronización; el reintento explícito usa la misma cola sin Background Sync.
import { discard, drain, enqueue } from './queue.js'
const owner = document.documentElement.dataset.uploadOwner
const input = document.getElementById('file')
const status = document.getElementById('status')
let controller = null
async function retry() {
if (controller) return
controller = new AbortController()
try {
await drain(controller.signal)
status.textContent = 'Retry finished. Other accounts’ files remain queued.'
} catch {
status.textContent =
'Retry stopped or failed. Sign in as the original owner and retry, or discard expired files.'
} finally {
controller = null
}
}
document.getElementById('queue').onclick = async () => {
const file = input.files?.[0]
if (!file) return
try {
await enqueue(file, owner)
input.value = ''
status.textContent = 'Saved locally. Use Retry queued files to upload now.'
} catch {
status.textContent =
'Could not save. Check the 10 MiB file limit, ten-file queue limit, and available storage.'
return
}
try {
if ('serviceWorker' in navigator) {
await navigator.serviceWorker.register('/upload-sw.js')
const registration = await navigator.serviceWorker.ready
if ('sync' in registration) await registration.sync.register('file-upload-sync')
}
} catch {
status.textContent = 'Saved locally. Background retry unavailable; use Retry queued files.'
}
}
document.getElementById('retry').onclick = retry
document.getElementById('stop').onclick = () => controller?.abort()
document.getElementById('discard').onclick = async () => {
controller?.abort()
try {
await discard(owner)
status.textContent = 'Local queue discarded. Already accepted uploads remain on the server.'
} catch {
status.textContent = 'Could not discard queued files.'
}
}
if (!owner || !('indexedDB' in globalThis) || !navigator.locks) {
document.getElementById('queue').disabled = true
document.getElementById('retry').disabled = true
document.getElementById('discard').disabled = true
status.textContent = 'Durable uploads require sign-in, IndexedDB, and Web Locks.'
}
Empaqueta ambos puntos de entrada y luego sirve public/ desde el origen HTTPS
de tu aplicación con autenticación:
corepack yarn esbuild upload-page.js --bundle --format=esm --outfile=public/upload-page.js
corepack yarn esbuild upload-sw.js --bundle --format=iife --outfile=public/upload-sw.js
Buenas prácticas
Trata IndexedDB como una copia local sujeta a cuotas y a la eliminación de datos por parte del navegador. Consulta a los usuarios antes de conservar archivos sensibles en dispositivos compartidos. Descarta la cola de la cuenta que se cierra durante el cierre de sesión, antes de cambiar de cuenta; el servidor debe seguir rechazando un encabezado de propietario desactualizado. Detener el reintento en primer plano no detiene un intento en segundo plano programado por separado ni revierte una subida confirmada. La cancelación del lado del servidor requiere su propia operación autenticada y serializada.
Verifica el guardado sin conexión, la recarga, los fallos por cuota, las sesiones vencidas, el cambio de cuenta, las respuestas HTTP 429/500, los comprobantes malformados y la detención de un worker justo después de que el servidor confirme la operación. Ante errores de validación permanentes, el usuario debe descartar la cola y corregir el archivo; los reintentos no corrigen datos de entrada incorrectos.
Conclusión
La persistencia depende de las escrituras confirmadas en IndexedDB y de la publicación idempotente en el servidor. Background Sync ofrece una oportunidad adicional de reintento; el control en primer plano sigue siendo esencial. Para subidas más grandes, combina una fuente de archivos persistente con un protocolo de servidor reanudable, en lugar de aumentar la cantidad de workers sin medir el efecto.
