tutoriales.com

Aprovechando la API de Sincronización en Segundo Plano en PWAs: Datos Siempre al Día

Este tutorial te guiará a través de la implementación de la API de Sincronización en Segundo Plano (Background Sync API) en tus Progressive Web Apps. Aprenderás a mantener tus datos actualizados y a enviar información generada offline una vez que la conexión se restablezca, mejorando significativamente la resiliencia de tu PWA.

Intermedio18 min de lectura6 views
Reportar error

La resiliencia de una Progressive Web App (PWA) se mide por su capacidad para ofrecer una experiencia de usuario fluida y funcional, independientemente del estado de la red. Una de las mayores fortalezas de las PWAs es su habilidad para trabajar sin conexión, pero ¿qué ocurre cuando el usuario genera datos offline o cuando la aplicación necesita actualizar información crítica sin que el usuario esté activamente interactuando con ella?

Aquí es donde entra en juego la API de Sincronización en Segundo Plano (Background Sync API). Esta potente API permite a tu Service Worker diferir ciertas operaciones (como enviar datos a un servidor o recuperar actualizaciones) hasta que el usuario tenga una conexión de red estable. Esto significa que las acciones iniciadas por el usuario mientras está offline pueden completarse automáticamente más tarde, sin intervención manual, y que tu aplicación puede mantenerse fresca y relevante en todo momento.


🎯 ¿Qué es la Background Sync API y por qué es crucial para tu PWA?

La Background Sync API es una funcionalidad de navegador que permite a los Service Workers registrarse para eventos de sincronización que se dispararán cuando el navegador detecte que hay una conexión de red estable. Esto es particularmente útil para escenarios como:

  • Envío de datos offline: Si un usuario crea una nueva nota o envía un formulario mientras está offline, la API puede almacenar esos datos y enviarlos al servidor automáticamente cuando haya conexión.
  • Actualización de caché: Permite que tu PWA descargue las últimas versiones de tus recursos estáticos o contenido dinámico en segundo plano, asegurando que la próxima vez que el usuario abra la aplicación, ya tenga la versión más reciente.
  • Sincronización de bases de datos: Útil para sincronizar una base de datos local (como IndexedDB) con un servidor remoto, manteniendo la consistencia de los datos.

💡 Beneficios clave de la Background Sync API:

  • Mejora la experiencia de usuario: Los usuarios no pierden datos y la aplicación siempre está actualizada, incluso después de periodos offline.
  • Fiabilidad: Garantiza que las operaciones importantes se completen, incluso si la conexión es intermitente o se pierde temporalmente.
  • Eficiencia: Permite programar la sincronización para momentos óptimos, como cuando el dispositivo está cargando o conectado a Wi-Fi, ahorrando batería y datos móviles.

🛠️ Requisitos y consideraciones previas

Antes de sumergirnos en el código, es importante entender algunos puntos:

  1. Service Worker: La Background Sync API solo funciona dentro del contexto de un Service Worker. Debes tener uno registrado y activo en tu PWA.
  2. HTTPS: Como todas las funcionalidades de Service Worker, tu aplicación debe servirse a través de HTTPS (o localhost para desarrollo).
  3. Soporte del navegador: La API es ampliamente soportada en navegadores modernos basados en Chromium (Chrome, Edge, Opera, Samsung Internet) y Firefox. Safari no la soporta directamente, pero se pueden implementar soluciones alternativas con fetch y setTimeout para emular un comportamiento similar en ciertos casos, aunque no con la misma robustez.
⚠️ Advertencia: La Background Sync API no es una solución para sincronización en tiempo real. Está diseñada para operaciones de "una vez que haya conexión, haz esto". Para tiempo real, considera WebSockets u otras APIs.

🚀 Implementación de la Background Sync API: Paso a paso

Vamos a desglosar el proceso en varios pasos clave:

Paso 1: Registrar el Service Worker

Si aún no tienes un Service Worker, este es el primer paso. En tu archivo principal (por ejemplo, index.html o app.js):

if ('serviceWorker' in navigator) {
  window.addEventListener('load', () => {
    navigator.serviceWorker.register('/service-worker.js')
      .then(registration => {
        console.log('Service Worker registrado con éxito:', registration);
      })
      .catch(error => {
        console.log('Fallo en el registro del Service Worker:', error);
      });
  });
}

Asegúrate de que service-worker.js exista en la raíz de tu dominio (o en la ruta especificada).

Paso 2: Registrar una sincronización en el cliente (página web)

Cuando una acción del usuario requiere una sincronización (por ejemplo, después de un envío de formulario offline), puedes registrar una etiqueta de sincronización en tu Service Worker. El método sync.register() devuelve una promesa que se resuelve si el registro es exitoso.

Imagina que tienes un formulario para enviar mensajes. Si el usuario no tiene conexión, guardas el mensaje en IndexedDB y luego registras una sincronización:

// app.js (o donde se maneje la lógica del formulario)

async function sendMessage(messageData) {
  if ('serviceWorker' in navigator && 'SyncManager' in window) {
    try {
      // Almacenar el mensaje en IndexedDB para persistencia offline
      await saveMessageToIndexedDB(messageData);
      
      // Registrar una sincronización para enviar el mensaje cuando haya conexión
      const registration = await navigator.serviceWorker.ready;
      await registration.sync.register('send-message-tag');
      console.log('Sincronización registrada para enviar mensaje.');
      return { success: true, message: 'Mensaje guardado y sincronización programada.' };
    } catch (error) {
      console.error('Error al registrar la sincronización:', error);
      return { success: false, message: 'Fallo al programar el envío del mensaje.' };
    }
  } else {
    // Si no hay soporte para SyncManager, intentar enviar directamente
    // o informar al usuario de que la función offline no está disponible.
    console.warn('Background Sync no soportado. Intentando enviar directamente...');
    return await sendDirectly(messageData);
  }
}

// Función dummy para guardar en IndexedDB (implementa esto con tu lógica real)
async function saveMessageToIndexedDB(message) {
  console.log('Guardando mensaje en IndexedDB:', message);
  // Implementa aquí la lógica para abrir/crear DB, almacenar el objeto, etc.
  // Ejemplo muy simplificado:
  return new Promise(resolve => setTimeout(resolve, 500)); // Simula operación async
}

// Ejemplo de uso (por ejemplo, al enviar un formulario)
document.getElementById('messageForm').addEventListener('submit', async (event) => {
  event.preventDefault();
  const messageInput = document.getElementById('messageInput');
  const messageText = messageInput.value.trim();

  if (messageText) {
    const result = await sendMessage({ text: messageText, timestamp: Date.now() });
    alert(result.message);
    messageInput.value = '';
  }
});
📌 Nota: El string `'send-message-tag'` es un identificador único. Puedes usar diferentes tags para diferentes tipos de sincronización. Si registras el mismo tag múltiples veces, el navegador simplemente consolidará los requests en uno solo.

Paso 3: Manejar el evento sync en el Service Worker

Ahora, en tu service-worker.js, necesitas escuchar el evento sync. Cuando el navegador determine que es un buen momento para sincronizar (es decir, hay conexión), disparará este evento.

// service-worker.js

self.addEventListener('sync', (event) => {
  if (event.tag === 'send-message-tag') {
    console.log('Evento de sincronización disparado para send-message-tag');
    event.waitUntil(sendMessagesFromIndexedDB());
  }
  // Puedes tener múltiples tags y manejarlos aquí
  if (event.tag === 'update-content-tag') {
    console.log('Evento de sincronización disparado para update-content-tag');
    event.waitUntil(updateAppContent());
  }
});

async function sendMessagesFromIndexedDB() {
  console.log('Intentando enviar mensajes pendientes...');
  // 1. Abrir IndexedDB
  // 2. Recuperar todos los mensajes pendientes
  // 3. Iterar y enviar cada mensaje al servidor
  // 4. Si el envío es exitoso, eliminar el mensaje de IndexedDB
  
  try {
    const messages = await getPendingMessagesFromIndexedDB(); // Implementa esta función
    const sendPromises = messages.map(async (message) => {
      try {
        const response = await fetch('/api/messages', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(message)
        });

        if (response.ok) {
          console.log('Mensaje enviado con éxito:', message);
          await deleteMessageFromIndexedDB(message.id); // Implementa esta función
        } else {
          console.error('Fallo al enviar mensaje:', message, response.statusText);
          // No eliminar de IndexedDB, se intentará de nuevo en la próxima sincronización
        }
      } catch (fetchError) {
        console.error('Error de red al enviar mensaje:', message, fetchError);
        // Error de red, no eliminar de IndexedDB, se intentará de nuevo
      }
    });
    await Promise.allSettled(sendPromises);
    console.log('Todos los intentos de envío de mensajes han finalizado.');
  } catch (dbError) {
    console.error('Error al acceder a IndexedDB durante la sincronización:', dbError);
    // En este caso, la próxima sincronización intentará de nuevo
  }
}

async function updateAppContent() {
  console.log('Actualizando contenido de la aplicación...');
  // Aquí podrías, por ejemplo, hacer un fetch de nuevas noticias, artículos, etc.
  // y guardarlos en caché o IndexedDB para que estén disponibles offline.
  try {
    const response = await fetch('/api/latest-content');
    if (response.ok) {
      const newContent = await response.json();
      // Guarda newContent en IndexedDB o en la caché si es un recurso estático
      console.log('Contenido actualizado:', newContent);
      // Ejemplo: Guardar en cache
      const cache = await caches.open('app-content-cache');
      await cache.put('/latest-content', response.clone()); // Guardar la respuesta entera
    } else {
      console.error('Fallo al actualizar contenido:', response.statusText);
    }
  } catch (error) {
    console.error('Error de red al actualizar contenido:', error);
  }
}

// Funciones dummy para IndexedDB (debes reemplazarlas con tu implementación real)
async function getPendingMessagesFromIndexedDB() {
  console.log('Recuperando mensajes pendientes...');
  // Esto es un placeholder. En una aplicación real, usarías la API de IndexedDB.
  // Ejemplo: Podría devolver un array como [{ id: 1, text: 'Hola', timestamp: ... }]
  return []; 
}

async function deleteMessageFromIndexedDB(id) {
  console.log('Eliminando mensaje de IndexedDB con ID:', id);
  // Implementa la lógica para eliminar el mensaje de IndexedDB.
  return new Promise(resolve => setTimeout(resolve, 100));
}

// --- Estrategia de cacheado básica para que el SW funcione ---
const CACHE_NAME = 'my-pwa-cache-v1';
const urlsToCache = [
  '/',
  '/index.html',
  '/style.css',
  '/app.js',
  // etc.
];

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then((cache) => {
        console.log('Cache abierta');
        return cache.addAll(urlsToCache);
      })
  );
});

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request)
      .then((response) => {
        if (response) {
          return response;
        }
        return fetch(event.request);
      })
  );
});
🔥 Importante: La función pasada a `event.waitUntil()` debe devolver una promesa. Si la promesa se resuelve, el navegador considera que la sincronización fue exitosa. Si la promesa se rechaza, intentará la sincronización de nuevo más tarde. Esto es crucial para la robustez.

Paso 4: Manejo de IndexedDB para persistencia

La Background Sync API solo se encarga de disparar el evento. La persistencia de los datos mientras el usuario está offline (y antes de la sincronización) debe gestionarse con una base de datos local como IndexedDB.

Aquí tienes un esquema básico de cómo podrías interactuar con IndexedDB en tus funciones saveMessageToIndexedDB, getPendingMessagesFromIndexedDB y deleteMessageFromIndexedDB:

Ejemplo básico de IndexedDB (fuera del Service Worker)
// db.js (o integrado en app.js para simplificar)
const DB_NAME = 'pwa-sync-db';
const DB_VERSION = 1;
const STORE_NAME = 'outbox';

function openDB() {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(DB_NAME, DB_VERSION);

    request.onupgradeneeded = (event) => {
      const db = event.target.result;
      db.createObjectStore(STORE_NAME, { keyPath: 'id', autoIncrement: true });
    };

    request.onsuccess = (event) => {
      resolve(event.target.result);
    };

    request.onerror = (event) => {
      reject('Error al abrir la base de datos: ' + event.target.errorCode);
    };
  });
}

async function saveMessageToIndexedDB(message) {
  const db = await openDB();
  const tx = db.transaction(STORE_NAME, 'readwrite');
  const store = tx.objectStore(STORE_NAME);
  return new Promise((resolve, reject) => {
    const request = store.add(message);
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

async function getPendingMessagesFromIndexedDB() {
  const db = await openDB();
  const tx = db.transaction(STORE_NAME, 'readonly');
  const store = tx.objectStore(STORE_NAME);
  return new Promise((resolve, reject) => {
    const request = store.getAll();
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

async function deleteMessageFromIndexedDB(id) {
  const db = await openDB();
  const tx = db.transaction(STORE_NAME, 'readwrite');
  const store = tx.objectStore(STORE_NAME);
  return new Promise((resolve, reject) => {
    const request = store.delete(id);
    request.onsuccess = () => resolve();
    request.onerror = () => reject(request.error);
  });
}

// Asegúrate de importar estas funciones donde las necesites (página principal y Service Worker).
💡 Consejo: Para simplificar la interacción con IndexedDB, considera usar librerías como `idb` (una envoltura para IndexedDB basada en promesas) o `Dexie.js`.

📊 Flujo de la Background Sync API

Para entender mejor cómo interactúan los componentes, visualicemos el flujo:

Offline sync.register('tag') Conexión detectada Confirma y actualiza Página Web (Cliente) Almacena en IndexedDB Service Worker Navegador detecta conexión Dispara evento 'sync' SW lee IndexedDB Envía datos al servidor

🔍 Probando la sincronización en segundo plano

Para probar tu implementación:

  1. Abre las Herramientas de Desarrollo de Chrome (o similar).
  2. Ve a la pestaña Application -> Service Workers.
  3. Marca la casilla Offline para simular la falta de conexión.
  4. En la consola, deberías ver mensajes de que la sincronización ha sido registrada.
  5. Realiza la acción que debería desencadenar la sincronización (ej. envía el formulario).
  6. Desmarca la casilla Offline (o reconecta tu dispositivo).
  7. En la misma pestaña Service Workers, puedes hacer clic en Sync para forzar manualmente un evento de sincronización para cualquier tag registrado. Si no lo haces manualmente, el navegador eventualmente lo disparará cuando considere que la conexión es buena.
  8. Observa la consola de tu Service Worker (hay un enlace service-worker.js en la pestaña Application para abrir su propia consola). Deberías ver los mensajes de que el evento sync se ha disparado y los datos han sido procesados.
💡 Consejo: Asegúrate de que el Service Worker esté actualizado. Si haces cambios en `service-worker.js`, es posible que necesites recargar la página o hacer clic en `Update on reload` y `skipWaiting` en las Herramientas de Desarrollo.

⚠️ Consideraciones avanzadas y mejores prácticas

Manejo de errores y reintentos

Como mencionamos, si la promesa que pasas a event.waitUntil() es rechazada, el navegador volverá a intentar la sincronización más tarde. Esto es una característica clave para la robustez. Sin embargo, debes diseñar tu lógica para manejar errores específicos:

  • Errores de red: La API ya maneja los reintentos para estos. No necesitas lógica adicional para ellos.
  • Errores de servidor (por ejemplo, 4xx, 5xx): Si recibes un error 400 Bad Request, reintentar no ayudará. Debes implementar lógica para marcar esos mensajes como failed en IndexedDB y quizás notificar al usuario.
  • Errores de datos: Si los datos son inválidos, tampoco tiene sentido reintentar. Elimínalos o márcalos.

Gestión de múltiples sincronizaciones

Puedes tener múltiples tags de sincronización para diferentes propósitos. Es buena práctica mantener la lógica de cada tag encapsulada en funciones separadas, como en nuestro ejemplo sendMessagesFromIndexedDB() y updateAppContent().

// service-worker.js (ejemplo con más tags)
self.addEventListener('sync', (event) => {
  switch (event.tag) {
    case 'send-message-tag':
      event.waitUntil(sendMessagesFromIndexedDB());
      break;
    case 'update-profile-tag':
      event.waitUntil(updateUserProfile());
      break;
    case 'analytics-upload':
      event.waitUntil(uploadAnalyticsData());
      break;
    default:
      console.warn('Tag de sincronización desconocido:', event.tag);
  }
});

Periodic Background Sync (sincronización periódica en segundo plano)

Además de la sincronización única (Background Sync), existe la Periodic Background Sync API. Esta permite a los Service Workers registrarse para sincronizar periódicamente en intervalos definidos (ej. cada 24 horas). Es ideal para mantener el contenido fresco o para tareas de mantenimiento, sin requerir una acción explícita del usuario para cada sincronización.

Ejemplo básico de Periodic Background Sync (requiere soporte del navegador)
// En el cliente (app.js)
if ('serviceWorker' in navigator && 'periodicSync' in navigator.serviceWorker) {
  navigator.serviceWorker.ready.then(async (registration) => {
    try {
      // Solicitar permiso para sincronización periódica
      const status = await navigator.permissions.query({ name: 'periodic-background-sync' });
      if (status.state === 'granted') {
        await registration.periodicSync.register('fetch-daily-news', {
          minInterval: 24 * 60 * 60 * 1000, // Cada 24 horas
        });
        console.log('Sincronización periódica registrada para noticias diarias.');
      } else {
        console.warn('Permiso denegado para sincronización periódica.');
      }
    } catch (error) {
      console.error('Error al registrar sincronización periódica:', error);
    }
  });
}

// En el Service Worker (service-worker.js)
self.addEventListener('periodicsync', (event) => {
  if (event.tag === 'fetch-daily-news') {
    console.log('Evento de sincronización periódica para noticias disparado.');
    event.waitUntil(fetchAndCacheDailyNews());
  }
});

async function fetchAndCacheDailyNews() {
  // Lógica para obtener las últimas noticias y guardarlas en caché o IndexedDB
  console.log('Obteniendo y cacheando noticias diarias...');
  try {
    const response = await fetch('/api/daily-news');
    if (response.ok) {
      const cache = await caches.open('daily-news-cache');
      await cache.put('/api/daily-news', response.clone());
      console.log('Noticias diarias actualizadas.');
    }
  } catch (error) {
    console.error('Error al obtener noticias diarias:', error);
  }
}
⚠️ Advertencia: La Periodic Background Sync API es una propuesta web y su implementación y disponibilidad varían. Chrome y Edge la soportan actualmente. Siempre verifica el soporte con `if ('periodicSync' in navigator.serviceWorker)`.

Limitaciones de la Background Sync API

  • Control del navegador: El navegador decide cuándo se dispara el evento sync, basándose en factores como la conectividad, el estado de la batería y si el dispositivo está cargando. No tienes control preciso sobre el momento exacto.
  • Tiempo de ejecución limitado: Un evento sync tiene un tiempo limitado para completarse. Si una operación toma demasiado tiempo, el navegador podría terminarla. Por eso es vital que las operaciones sean eficientes.
  • Sin UI directa: La API opera completamente en segundo plano. No hay una interfaz de usuario integrada que indique el progreso de la sincronización al usuario final. Debes implementar esto si es necesario (ej. notificaciones push una vez completada la sincronización).

✅ Conclusión

La Background Sync API es una herramienta fundamental para construir PWAs robustas y fiables. Al permitir que tu aplicación gestione de forma inteligente las operaciones de red, incluso cuando la conectividad es limitada o inexistente, puedes ofrecer una experiencia de usuario superior, mantener los datos actualizados y garantizar que las acciones del usuario se completen con éxito.

Integrar esta API requiere una planificación cuidadosa, especialmente en cuanto a la persistencia de datos con IndexedDB y el manejo de errores. Sin embargo, el esfuerzo vale la pena para transformar una aplicación web estándar en una experiencia verdaderamente offline-first.

Explora la API, experimenta con diferentes escenarios de sincronización y lleva la resiliencia de tu PWA al siguiente nivel. ¡Tu usuario te lo agradecerá!

Tutoriales relacionados

Comentarios (0)

Aún no hay comentarios. ¡Sé el primero!