Frontend 15 may 2026 · 6 min de lectura

PWAs offline-first: apps que funcionan cuando la señal no

Yohangel Ramos

Yohangel Ramos

Tech Lead · Senior Fullstack Developer

En un evento de Toyota en Colombia, la conexión no es un lujo garantizado: es un pabellón lleno de gente, redes móviles saturadas y stands donde el WiFi va y viene. Ahí construí, con el equipo de Destiny, una PWA con Next.js que no dependía de la señal para funcionar. No una app "tolerante a fallos de red", sino una app pensada desde el primer día para operar sin conexión y reconciliar cuando la señal volviera. Esa diferencia de enfoque —offline-first, no offline-tolerant— cambió cómo diseñamos el almacenamiento, la sincronización y, sobre todo, cómo le mostrábamos el estado a personas que no son técnicas y están atendiendo clientes en pleno stand.

Offline-first no es lo mismo que offline-tolerant

Una app offline-tolerant asume que la red es lo normal y el offline la excepción: cuando falla, muestra un error amable y espera. Una app offline-first invierte la premisa. La fuente de verdad inmediata es local; la red es un detalle de implementación que sirve para propagar y refrescar datos cuando está disponible.

En el evento de Toyota esto no era una decisión estética. Un promotor registrando datos de un cliente interesado no puede quedarse mirando un spinner porque el pabellón saturó la red móvil. La captura tenía que completarse siempre, guardarse localmente y salir a servidor cuando se pudiera. El backend era serverless en AWS —Amplify, Lambda y DynamoDB— pero desde el punto de vista del frontend, el servidor podía no existir durante minutos y la app seguía siendo plenamente usable.

Estrategias de caché en el service worker

El service worker es el corazón de una PWA offline-first. La clave está en no usar una sola estrategia, sino elegir por tipo de recurso:

  • App shell (precache): HTML, JS, CSS y assets del cascarón se precachean en la instalación. La app arranca sin red.
  • Stale-while-revalidate: para recursos que pueden estar un poco desactualizados (imágenes de catálogo, íconos). Sirvo la caché de inmediato y actualizo en segundo plano.
  • Network-first con fallback a caché: para datos que quiero frescos si hay señal, pero que no pueden bloquear la experiencia si no la hay.
// sw.js — enrutado por tipo de recurso
self.addEventListener('fetch', (event) => {
  const { request } = event;
  const url = new URL(request.url);

  // Datos de API: network-first con fallback a caché
  if (url.pathname.startsWith('/api/')) {
    event.respondWith(
      fetch(request)
        .then((res) => {
          const copy = res.clone();
          caches.open('api-cache').then((c) => c.put(request, copy));
          return res;
        })
        .catch(() => caches.match(request))
    );
    return;
  }

  // Assets del catálogo: stale-while-revalidate
  if (url.pathname.startsWith('/catalog/')) {
    event.respondWith(
      caches.match(request).then((cached) => {
        const network = fetch(request).then((res) => {
          caches.open('catalog-cache').then((c) => c.put(request, res.clone()));
          return res;
        });
        return cached || network;
      })
    );
    return;
  }

  // App shell: cache-first
  event.respondWith(caches.match(request).then((c) => c || fetch(request)));
});

Escrituras pendientes en IndexedDB

Leer offline es fácil; escribir offline es donde está el verdadero trabajo. Cada captura de datos que hacía un promotor se guardaba primero en IndexedDB como una operación pendiente, con un id propio generado en el cliente (un UUID), un timestamp y el payload completo. La UI daba feedback inmediato: el registro quedaba guardado, punto. No esperaba al servidor.

Esa cola local es lo que convierte el offline en algo confiable. Nada vive solo en memoria; si el promotor cerraba la pestaña o se quedaba sin batería, la operación seguía ahí al reabrir.

💡 En offline-first la red no es la fuente de verdad, es solo un canal de sincronización. Si tu UI espera al servidor para confirmar algo, todavía estás construyendo una app offline-tolerant disfrazada.

Sincronización diferida cuando vuelve la señal

Cuando la conectividad regresaba, había que vaciar esa cola. Usé la Background Sync API para delegar ese trabajo al navegador: registro un evento de sync y el propio sistema decide cuándo hay red estable para ejecutarlo, incluso si la pestaña ya no está en primer plano.

// Al encolar una escritura, pido un sync diferido
async function queueWrite(record) {
  await idbPut('pending-writes', record); // guarda en IndexedDB
  const reg = await navigator.serviceWorker.ready;
  if ('sync' in reg) {
    await reg.sync.register('flush-writes');
  }
}

// En el service worker: vaciar la cola cuando el navegador lo permita
self.addEventListener('sync', (event) => {
  if (event.tag === 'flush-writes') {
    event.waitUntil(flushPendingWrites());
  }
});

async function flushPendingWrites() {
  const pending = await idbGetAll('pending-writes');
  for (const record of pending) {
    const res = await fetch('/api/leads', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Idempotency-Key': record.id, // el UUID del cliente
      },
      body: JSON.stringify(record),
    });
    if (res.ok) await idbDelete('pending-writes', record.id);
  }
}

Conflictos e idempotencia

El escenario que no puedes ignorar: la escritura sí llegó al servidor, pero la respuesta se perdió en la red antes de volver. El cliente cree que falló y reintenta. Sin protección, duplicas el registro.

La solución fue idempotencia con la clave del cliente. Ese UUID generado al capturar el dato viajaba como Idempotency-Key. En la Lambda, si ya existía un registro con esa clave, se devolvía el resultado existente en lugar de crear uno nuevo. Así, reintentar era seguro por diseño: mil reintentos producían un solo registro.

Para conflictos de edición sobre el mismo dato, la regla fue simple y explicable: last-write-wins por timestamp del cliente. No era CRDTs ni nada sofisticado, pero para el flujo del evento —capturas mayormente independientes— era suficiente y predecible.

La UX del estado de sincronización

Todo lo anterior es invisible para el promotor, y así debe ser casi siempre. Pero "casi" es la palabra clave. La persona en el stand necesita confianza, no detalles técnicos. Mostrábamos tres estados simples:

  • Guardado (local, sin conexión todavía): un check y "guardado en el dispositivo".
  • Sincronizando: un indicador discreto cuando la cola se estaba vaciando.
  • Sincronizado: confirmación de que el dato ya vivía en el servidor.

También mostraba un contador de "pendientes por enviar". Eso le daba tranquilidad a alguien no técnico: sabía que nada se había perdido aunque no hubiera señal, y sabía cuándo todo estaba a salvo antes de cerrar el turno.

Qué haría distinto en 2026

Hoy repensaría un par de cosas. Primero, apoyarme más en librerías maduras en lugar de escribir tanto a mano: Workbox para las estrategias del service worker, y una capa como Dexie sobre IndexedDB en vez de la API cruda. Segundo, para la sincronización, evaluaría un motor local-first de verdad —algo con reconciliación basada en CRDTs cuando el flujo lo justifique— para no atarme a last-write-wins en casos con edición concurrente real. Y tercero, sería más deliberado con la observabilidad: registrar métricas de la cola (tamaño, edad de las operaciones, tasa de reintentos) para diagnosticar en campo, porque en un evento no tienes tiempo de abrir DevTools. La idea central, sin embargo, no la tocaría: la señal es opcional, el trabajo no.

Yohangel Ramos

Escrito por Yohangel Ramos

Senior Fullstack Developer y Tech Lead. Construyo con React, Next.js, Nest.js y AWS — y escribo sobre lo que aprendo en el camino.

Hablemos →

Sigue leyendo

IA

Embeddings más baratos: recortar dimensiones y cuantizar sin perder recall

IA

Qué va a pasar con el desarrollo de software: predicciones con fechas (julio 2026)