Envío de Formularios sin Conexión con Background Sync
Cola envíos de formularios en IndexedDB, reprodúcelos con Background Sync y un fallback online, y evita pedidos duplicados con Idempotency-Key.
Para garantizar que el envío de un formulario sobreviva a una conexión intermitente o sin conexión, escribe el envío en IndexedDB antes de tocar la red, registra una etiqueta de Background Sync y deja que el service worker reproduzca la solicitud en cola desde su evento sync cuando se restaure la conectividad. Luego, añade un fallback con el evento online para los navegadores que no admiten Background Sync. Esta última parte no es opcional: la API de Background Sync solo está disponible en navegadores basados en Chromium, por lo que la cola en IndexedDB más un listener del evento online constituyen la línea base que funciona en todos los navegadores, mientras que Background Sync es la mejora que se añade encima.
Este artículo desarrolla un ejemplo completo — un formulario de contacto/pedido — a través del patrón completo: cola duradera, registro de sincronización, reproducción en el service worker, el fallback universal, prevención de envíos duplicados y el problema de confirmación del que nadie te advierte. Se asume que ya has registrado un service worker y que te sientes cómodo con promesas y fetch. El almacenamiento en caché genérico (cache-first, network-first) es un prerrequisito, no el tema central aquí — consulta las estrategias de caché de MDN y los módulos de estrategia de Workbox para eso.
Puntos Clave
- Escribe el envío en IndexedDB con un ID de solicitud generado por el cliente, un timestamp
queuedAty un contador de reintentos antes de la llamada de red, de modo que un POST interrumpido ya sea duradero en lugar de perderse. - El Background Sync de un solo uso (
SyncManager) solo está disponible en navegadores Chromium — Chrome, Edge, Opera y Samsung Internet — y está ausente en Firefox, Safari e iOS, razón por la cual un fallback con el eventoonlinees obligatorio en 2026. - En el manejador
syncdel service worker, realiza un POST de cada elemento en cola, elimínalo en caso de éxito y lanza un error en caso de fallo — lanzar un error le indica al navegador que mantenga el registro y reintente con un retroceso exponencial gestionado por el navegador. - Envía el ID de solicitud como encabezado
Idempotency-Keypara que el servidor desduplique un envío que ya aceptó cuando llegue la reproducción, evitando pedidos duplicados. - El
BackgroundSyncPluginde Workbox gestiona la cola y la reproducción por ti, pero solo reintenta ante fallos de red reales — una respuesta 4xx o 5xx se trata como entregada y no se reproducirá.
Por qué un fetch simple al enviar falla sin conexión
Un fetch básico al enviar el formulario falla silenciosamente cuando el dispositivo está sin conexión: la promesa es rechazada, la solicitud nunca llega al servidor y, a menos que hayas escrito un manejo explícito, el usuario no recibe ninguna señal de que algo salió mal. Los service workers resuelven el almacenamiento en caché de recursos, pero no reintentan automáticamente las nuevas solicitudes de datos que realiza un formulario — un POST fallido simplemente desaparece.
Un POST offline fallido en silencio es el clásico error de “el usuario cree que tuvo éxito; los datos nunca llegaron”. También es invisible para el monitoreo del backend, porque la solicitud nunca llegó al backend — no hay nada que registrar. La reproducción de sesión es la técnica que cierra esa brecha de visibilidad: una reproducción reconstruye la realidad del lado del cliente — el toque en Enviar, la pantalla optimista de “¡Gracias!”, la navegación posterior — que luego puedes correlacionar con si alguna vez apareció un registro en el servidor. Esa correlación es exactamente cómo detectas la brecha de confianza que este patrón existe para prevenir.
“Enviar y olvidar” debería sentirse instantáneo para el usuario y duradero para ti: el envío se captura localmente en el momento en que toca Enviar, y la entrega es responsabilidad del sistema, no del usuario.
Paso 1: Encolar el envío en IndexedDB antes de la red
Discover how at OpenReplay.com.
Persiste el envío en IndexedDB primero, luego intenta la entrega. Almacenar antes de cualquier llamada de red significa que un POST fallido, interrumpido o sin conexión ya es duradero — reproduces desde el almacén, nunca desde el estado volátil de la página. Adjunta tres campos a cada elemento en cola: un ID de solicitud generado por el cliente (para deduplicación posterior), un timestamp queuedAt y un retryCount.
// db.js — a thin promise wrapper around IndexedDB
const DB_NAME = 'outbox';
const STORE = 'submissions';
function openDB() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 1);
req.onupgradeneeded = () => {
req.result.createObjectStore(STORE, { keyPath: 'id' });
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
export async function enqueue(payload) {
const db = await openDB();
const item = {
id: crypto.randomUUID(), // request ID for idempotency
payload,
queuedAt: Date.now(),
retryCount: 0,
};
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, 'readwrite');
tx.objectStore(STORE).put(item);
tx.oncomplete = () => resolve(item);
tx.onerror = () => reject(tx.error);
});
}
crypto.randomUUID() está disponible en todos los navegadores modernos y en el ámbito del service worker, según la referencia de Crypto.randomUUID() en MDN. El manejador del formulario llama a enqueue, muestra un estado optimista de “En cola” y luego solicita una sincronización.
Paso 2: Registrar Background Sync y manejar el evento sync
Background Sync te permite registrar una etiqueta con nombre desde la página; el navegador dispara un evento sync en tu service worker cuando considera que la conectividad ha vuelto, y puede entregar ese evento incluso después de que el usuario haya navegado a otra página o cerrado la pestaña. Ese comportamiento de entrega diferida tras la navegación es lo que lo hace más robusto que un listener online, que solo se dispara mientras la página está abierta.
Detecta la presencia de serviceWorker y SyncManager antes de depender de ellos, y recurre al fallback inmediatamente si alguno está ausente:
// form handler, after enqueue()
async function requestSync() {
if ('serviceWorker' in navigator && 'SyncManager' in window) {
const reg = await navigator.serviceWorker.ready;
try {
await reg.sync.register('sync-forms');
return;
} catch {
// registration failed — fall through to the fallback
}
}
flushQueue(); // the everywhere fallback (Step 3)
}
La llamada register('sync-forms') y la interfaz SyncManager están documentadas en la API de Background Synchronization de MDN. En el service worker, verifica la etiqueta, envuelve el trabajo en event.waitUntil() para que el worker permanezca activo, realiza un POST de cada elemento, elimínalo en caso de éxito y lanza un error en caso de fallo para que el navegador mantenga el registro y reintente:
// service-worker.js
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-forms') {
event.waitUntil(replayQueue(event));
}
});
async function replayQueue(event) {
const items = await getAll(); // read from IndexedDB
for (const item of items) {
const res = await fetch('/api/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': item.id, // dedupe on the server
},
body: JSON.stringify(item.payload),
});
if (res.ok) {
await remove(item.id); // delete on success
} else if (res.status >= 500) {
if (event.lastChance) await notifyFailure(item);
throw new Error('server error, retry'); // keep the registration
}
// 4xx: the payload is bad — don't retry blindly; surface it instead
}
}
Lanzar un error dentro de waitUntil es la señal que mantiene la sincronización pendiente. Los navegadores que admiten la API reproducen las solicitudes fallidas en tu nombre a un intervalo gestionado por el navegador, probablemente usando retroceso exponencial entre intentos de reproducción. El número de intentos y el intervalo exacto son gestionados por el navegador y no están documentados contractualmente, así que no codifiques de forma rígida una suposición como “tres reintentos”. En cambio, verifica event.lastChance — documentado en la referencia de SyncEvent en MDN — para detectar el último intento e informar al usuario que el envío finalmente falló, en lugar de dejar que desaparezca en silencio.
Paso 3: El fallback que funciona en todos los navegadores
Dado que Background Sync es exclusivo de Chromium, el fallback con el evento online no es una nota al pie — es la línea base que ejecuta cada navegador. Dos disparadores cubren la ruta para navegadores que no son Chromium: vuelve a vaciar la cola cada vez que se dispare el evento online, y vacíala una vez en cada carga de página para capturar los envíos encolados en una sesión anterior.
// runs on the page, everywhere
export async function flushQueue() {
if (!navigator.onLine) return;
const items = await getAll();
for (const item of items) {
try {
const res = await fetch('/api/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': item.id,
},
body: JSON.stringify(item.payload),
});
if (res.ok) await remove(item.id);
} catch {
await bumpRetryCount(item.id); // stays queued for the next trigger
}
}
}
window.addEventListener('online', flushQueue);
window.addEventListener('load', flushQueue);
La limitación honesta del fallback: solo se ejecuta mientras una página de tu origen está abierta, ya que depende de eventos a nivel de página. No puede despertar una pestaña cerrada como sí puede hacerlo Background Sync. Ese es el compromiso preciso — alcance universal, garantías de entrega más débiles — y es por eso que registras Background Sync primero y recurres al fallback después.
Compatibilidad de navegadores con Background Sync en 2026
A partir de junio de 2026, el Background Sync de un solo uso (SyncManager) es una API exclusiva de Chromium. MDN la clasifica como “disponibilidad limitada” — no como Baseline, porque no funciona en algunos de los navegadores más utilizados. Los datos de caniuse confirman la división que se muestra a continuación.
| Navegador | Background Sync de un solo uso (SyncManager) |
|---|---|
| Chrome | ✅ Compatible |
| Edge (Chromium) | ✅ Compatible |
| Opera | ✅ Compatible |
| Samsung Internet | ✅ Compatible |
| Firefox | ❌ No compatible |
| Safari (macOS) | ❌ No compatible |
| Safari (iOS) | ❌ No compatible |
| Android WebView | ❌ No compatible |
Hay dos aspectos importantes a tener en cuenta. Microsoft Edge obtuvo soporte solo después de migrar a Chromium, por lo que cualquier afirmación de 2019 de que “Edge no lo admite” ya es incorrecta. Y Android WebView — el componente que las aplicaciones nativas integran para la navegación dentro de la aplicación — no expone SyncManager, por lo que envolver una PWA en una capa WebView elimina la cola de sincronización nativa y recurre al camino del evento online. Ten en cuenta que esta es la API de Background Sync de un solo uso; el Periodic Background Sync es una API separada y experimental para actualizaciones recurrentes — no confundas ambas.
Idempotencia: evitar que las reproducciones creen pedidos duplicados
Cualquier POST reintentado corre el riesgo de generar un duplicado: el primer intento puede haber llegado al servidor y confirmado antes de que se cortara la conexión, por lo que la respuesta nunca regresó y tu cliente reproduce una solicitud que el servidor ya procesó. Prevén esto con un ID de solicitud generado por el cliente enviado como encabezado Idempotency-Key — el mismo item.id que almacenaste al encolar. El servidor lo utiliza como clave y devuelve el resultado original ante una repetición en lugar de crear un segundo registro.
Idempotency-Key es una convención de API establecida popularizada por Stripe y PayPal, y es el objeto de un Internet-Draft de la IETF, draft-ietf-httpapi-idempotency-key-header. Trátalo como una convención en progreso, no como un estándar ratificado: el campo de encabezado de solicitud HTTP Idempotency-Key puede usarse para hacer tolerantes a fallos los métodos HTTP no idempotentes como POST o PATCH, pero el propio borrador incluye el aviso estándar de que no debe citarse más que como trabajo en progreso. El lado del servidor es sencillo: ante una solicitud duplicada cuya clave de idempotencia ya ha sido vista, el servidor de recursos debería responder con el resultado de la operación completada anteriormente, sea un éxito o un error.
UX de confirmación después de que el usuario se ha ido
El problema difícil de UX es que la entrega diferida frecuentemente se completa después de que el usuario ha navegado a otra página, por lo que necesitas una forma de reconciliar el estado optimista de “En cola” una vez que la solicitud realmente llega. Tres acciones lo cubren:
- Cambiar “En cola” → “Enviado” en tiempo real. Cuando el service worker reproduce exitosamente un elemento, usa
postMessagepara notificar a cualquier cliente abierto y que la interfaz pueda actualizarse en el momento. Escucha connavigator.serviceWorker.addEventListener('message', ...). - Reconciliar en la próxima carga. Al iniciar, lee el estado vaciado de la cola: los elementos aún presentes están pendientes; su ausencia significa que la entrega fue exitosa. Renderiza el estado desde el almacén, no desde una bandera establecida al momento del envío.
- Mostrar el fallo terminal. Usa
event.lastChanceen el manejadorsyncpara disparar una notificación (o persistir un marcador de “fallido” que la interfaz lea en la próxima carga) para que un envío que agotó sus reintentos no desaparezca en silencio.
La UX de confirmación es el segundo lugar donde la reproducción de sesión demuestra su valor: la reproducción hace visible la línea de tiempo del lado del usuario — el envío en cola, la pantalla que vieron, si la confirmación de “Enviado” llegó a renderizarse — que es la única vista que muestra la brecha de confianza cuando una solicitud nunca llegó al servidor para ser registrada.
Workbox como atajo para producción
Si prefieres no construir la cola manualmente, Workbox (actualmente en la versión principal 7) proporciona BackgroundSyncPlugin, que encola las solicitudes fallidas en IndexedDB y las reproduce en los eventos sync. Es importante destacar que incluye su propio fallback: en navegadores que no admiten nativamente la API de BackgroundSync, Workbox Background Sync intentará automáticamente una reproducción cada vez que tu service worker se inicie.
import { BackgroundSyncPlugin } from 'workbox-background-sync';
import { registerRoute } from 'workbox-routing';
import { NetworkOnly } from 'workbox-strategies';
const bgSync = new BackgroundSyncPlugin('order-queue', {
maxRetentionTime: 24 * 60, // minutes; the documented example keeps items 24h
});
registerRoute(/\/api\/orders/, new NetworkOnly({ plugins: [bgSync] }), 'POST');
Hay dos comportamientos que suelen confundir a los desarrolladores. Primero, BackgroundSyncPlugin se engancha al callback del plugin fetchDidFail, y fetchDidFail solo se invoca si se lanza una excepción, muy probablemente debido a un fallo de red, lo que significa que las solicitudes no se reintentarán si se recibe una respuesta con estado de error 4xx o 5xx. Si deseas que las respuestas 5xx sean reencoladas, añade un plugin fetchDidSucceed que lance un error cuando response.status >= 500. Segundo, al realizar pruebas, puedes verificar que las solicitudes han sido encoladas mirando en Chrome DevTools > Application > IndexedDB, y forzar una reproducción desde DevTools > Application > Service Workers. No valides el comportamiento sin conexión con la casilla “Offline” de DevTools — bloquea las solicitudes de la página pero deja pasar las solicitudes del service worker, por lo que oculta exactamente los errores que estás probando. Desconecta la red real en su lugar.
Ya sea que construyas la solución manualmente o adoptes Workbox, la arquitectura es la misma:
- captura localmente antes de la red;
- prefiere Background Sync donde esté disponible;
- recurre a los eventos
onlineen todos los demás casos; - deduplica las reproducciones con una clave de idempotencia; y
- reconcilia la interfaz cuando la entrega finalmente ocurra.
Conecta estas cinco piezas con tu propio formulario, luego verifica una reproducción real encolando sin conexión, reconectando y confirmando que aparece un único registro en el servidor.
Preguntas Frecuentes
¿Cuál es la diferencia entre el Background Sync de un solo uso y el Periodic Background Sync?
El Background Sync de un solo uso utiliza la interfaz SyncManager para reproducir una única tarea diferida, como un envío de formulario en cola, una vez que se restaura la conectividad, y el navegador dispara un evento sync que puede llegar incluso después de que el usuario navegue a otra página. El Periodic Background Sync utiliza una interfaz PeriodicSyncManager separada para actualizaciones recurrentes según un calendario gestionado por el navegador, como la actualización de contenido. Son APIs distintas, y el Periodic Background Sync es experimental, así que verifica su tabla de compatibilidad antes de depender de él en producción.
¿Por qué mi solicitud en cola sigue sin reintentarse cuando el servidor devuelve un error 400 o 500?
Background Sync y el BackgroundSyncPlugin de Workbox solo reintentan solicitudes que fallan por un error de red real, porque el plugin se engancha al callback fetchDidFail, que solo se dispara cuando se lanza una excepción. Una respuesta 400 o 500 cuenta como una respuesta recibida, por lo que se trata como entregada y no se reproducirá. Para reencolar respuestas 5xx, añade un plugin fetchDidSucceed que lance un error cuando response.status sea 500 o superior, o lanza manualmente en un manejador sync construido a mano.
¿Funciona Background Sync dentro de un Android WebView que envuelve una PWA?
No. Android WebView, el componente que las aplicaciones nativas integran para la navegación dentro de la aplicación, no expone SyncManager, por lo que una PWA envuelta en una capa WebView pierde la cola nativa de Background Sync. El envío recurre al camino del evento online, que solo se dispara mientras una página de tu origen está abierta y no puede despertar una pestaña cerrada. Debido a esto y a la ausencia de soporte en Firefox, Safari e iOS, una cola en IndexedDB con un fallback de evento online debe seguir siendo la línea base.
¿Cómo puedo probar el comportamiento de Background Sync sin conexión de forma confiable?
Desconecta la red real en lugar de usar la casilla 'Offline' de DevTools. La casilla Offline bloquea las solicitudes de la página pero deja pasar las solicitudes del service worker, por lo que oculta exactamente los errores que estás probando. Verifica los elementos en cola en Chrome DevTools en Application y luego IndexedDB, y fuerza una reproducción desde Application y luego Service Workers. Para confirmar el flujo completo, encola un envío sin conexión, reconecta y verifica que aparezca un único registro en el servidor.