Qué es una PWA y cómo construí una para usar en eventos

Una PWA (Progressive Web App) es una aplicación web que adopta características de las apps nativas: se instala en la pantalla de inicio, funciona sin conexión y puede acceder a APIs del dispositivo como la cámara, las notificaciones o el sensor de luz. Sin pasar por la App Store.

Este artículo documenta cómo funciona el Event Timer — un timer para charlas y ponencias que se instala desde Safari en iOS y funciona 100% offline — y qué tuve que construir para lograrlo.


Por qué una PWA y no una app nativa

El Event Timer resuelve un problema muy concreto: el expositor necesita un timer en su iPhone conectado al proyector, sin depender de señal ni de una app descargada de antemano. Los criterios de diseño fueron claros desde el principio:

Una PWA cumple los cuatro. Una app nativa resuelve el tercero y el segundo a medias. Una web app estándar falla en el tercero.


Los tres pilares de una PWA

Google define tres requisitos para que una app califique como PWA instalable:

  1. HTTPS — la app debe servirse en una conexión segura
  2. Web App Manifest — un JSON que describe la app al navegador
  3. Service Worker — un script que intercepta requests de red y gestiona la caché

Sin los tres, el navegador no ofrece el prompt de instalación.


1. El Web App Manifest

El manifest es un archivo JSON que le dice al navegador el nombre, íconos, colores y comportamiento de la app cuando se instala. Se enlaza desde el <head>:

<link rel="manifest" href="/manifest.json" />

El manifest del Event Timer:

{
  "name": "Event Timer",
  "short_name": "Timer",
  "description": "Timer profesional para charlas y ponencias",
  "start_url": "/",
  "display": "standalone",
  "orientation": "portrait",
  "background_color": "#0a0a0a",
  "theme_color": "#0a0a0a",
  "icons": [
    { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
    {
      "src": "/icons/icon-512-maskable.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "maskable"
    }
  ]
}

Los campos más importantes:

Con Vite, el manifest se puede generar con el plugin oficial:

// vite.config.ts
import { VitePWA } from "vite-plugin-pwa";

export default defineConfig({
  plugins: [
    VitePWA({
      registerType: "autoUpdate",
      manifest: {
        name: "Event Timer",
        short_name: "Timer",
        display: "standalone",
        theme_color: "#0a0a0a",
        background_color: "#0a0a0a",
        icons: [
          { src: "/icons/icon-192.png", sizes: "192x192", type: "image/png" },
          { src: "/icons/icon-512.png", sizes: "512x512", type: "image/png" },
        ],
      },
    }),
  ],
});

2. El Service Worker

El Service Worker es el componente más poderoso — y más complejo — de una PWA. Es un script JavaScript que el navegador registra como proxy entre la app y la red. Se ejecuta en un hilo separado, sin acceso al DOM, y persiste aunque el usuario cierre la pestaña.

Ciclo de vida

install → activate → fetch (cada request)

Durante install, el SW pre-cachea los assets. Durante activate, limpia cachés viejas. En cada fetch, decide si responde desde caché o desde red.

Estrategias de caché

Hay cinco estrategias principales:

| Estrategia | Descripción | Caso de uso | |---|---|---| | Cache First | Sirve desde caché; red solo si no hay entrada | Assets estáticos, íconos | | Network First | Intenta red; caché como fallback | Datos que cambian con frecuencia | | Stale While Revalidate | Sirve caché y actualiza en paralelo | Contenido que puede estar levemente desactualizado | | Cache Only | Solo caché, nunca red | Modo offline estricto | | Network Only | Solo red, nunca caché | Requests de analytics, logs |

Para el Event Timer, la estrategia es Cache First para todo: la app entera vive en caché tras la primera carga y no necesita red en absoluto.

Implementación manual vs. Workbox

Un Service Worker manual para pre-cachear assets se ve así:

const CACHE_NAME = "event-timer-v1";
const ASSETS = ["/", "/index.html", "/main.js", "/main.css"];

self.addEventListener("install", (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => cache.addAll(ASSETS))
  );
});

self.addEventListener("fetch", (event) => {
  event.respondWith(
    caches.match(event.request).then(
      (cached) => cached ?? fetch(event.request)
    )
  );
});

El problema: en producción los assets tienen hashes en el nombre (main.abc123.js). Hay que regenerar esa lista en cada build. Para eso existe Workbox.


3. Workbox: el SW sin dolor

Workbox es la librería de Google para escribir Service Workers con estrategias declarativas. vite-plugin-pwa lo integra directamente.

// vite.config.ts
VitePWA({
  registerType: "autoUpdate",
  workbox: {
    globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2}"],
    runtimeCaching: [
      {
        urlPattern: /^https:\/\/fonts\.googleapis\.com/,
        handler: "StaleWhileRevalidate",
        options: { cacheName: "google-fonts-stylesheets" },
      },
    ],
  },
});

globPatterns le dice a Workbox qué archivos pre-cachear. runtimeCaching define estrategias para requests dinámicos (fuentes externas, APIs). El resultado: Workbox genera un sw.js con el inventario exacto de assets del build, incluyendo los hashes.


4. Wake Lock: que la pantalla no se apague

El problema real del Event Timer en un evento: iOS apaga la pantalla si no hay interacción táctil. El timer sigue corriendo en JavaScript, pero la pantalla se va. El proyector muestra el último frame congelado.

La solución es la Wake Lock API:

let wakeLock: WakeLockSentinel | null = null;

async function requestWakeLock() {
  if (!("wakeLock" in navigator)) return;
  try {
    wakeLock = await navigator.wakeLock.request("screen");
  } catch {
    // El dispositivo puede denegar si la batería es baja
  }
}

// El wake lock se libera automáticamente al minimizar la app.
// Hay que readquirirlo cuando vuelve al frente:
document.addEventListener("visibilitychange", () => {
  if (document.visibilityState === "visible") {
    requestWakeLock();
  }
});

navigator.wakeLock.request("screen") devuelve una promesa que resuelve con un WakeLockSentinel. Mientras ese sentinel esté activo, el sistema operativo no puede apagar la pantalla. El navegador lo libera automáticamente cuando la app pasa a segundo plano — el evento visibilitychange permite readquirirlo cuando el usuario vuelve.

Soporte actual: Chrome, Edge, Safari 16.4+, Samsung Internet. Firefox no lo soporta.


5. Instalar desde Safari en iOS

El proceso de instalación en iOS es diferente al de Android, donde Chrome muestra un banner automático. En Safari hay que hacerlo manual:

  1. Abrir la URL en Safari
  2. Tocar el botón de compartir (cuadrado con flecha)
  3. Seleccionar "Agregar a pantalla de inicio"
  4. Confirmar el nombre

Una vez instalada, la app abre en modo standalone: sin barra de navegación, sin barra de URL, exactamente como una app nativa. El ícono aparece en el home screen con el nombre y el ícono del manifest.

Para guiar al usuario, el Event Timer muestra un banner contextual la primera vez:

const [showInstallPrompt, setShowInstallPrompt] = useState(false);
const isIOS = /iphone|ipad|ipod/i.test(navigator.userAgent);
const isStandalone = window.matchMedia("(display-mode: standalone)").matches;

useEffect(() => {
  if (isIOS && !isStandalone) {
    setShowInstallPrompt(true);
  }
}, []);

display-mode: standalone es true cuando la app ya está instalada y corriendo fuera del navegador. La detección evita mostrar el banner a quien ya la tiene instalada.


6. Actualización automática del Service Worker

El SW tiene un ciclo de vida que puede confundir: cuando subes una nueva versión, el navegador descarga el nuevo sw.js pero no lo activa hasta que el usuario cierra todas las pestañas de la app.

Con registerType: "autoUpdate" en vite-plugin-pwa, el nuevo SW se activa automáticamente mediante skipWaiting():

// En el SW generado por Workbox:
self.addEventListener("install", () => self.skipWaiting());
self.addEventListener("activate", (event) => {
  event.waitUntil(clients.claim());
});

skipWaiting() fuerza la activación inmediata. clients.claim() toma control de todas las pestañas abiertas. El resultado: el usuario siempre corre la versión más reciente sin tener que cerrar la app.


Resultado: qué obtienes

| Característica | Web normal | Event Timer (PWA) | |---|---|---| | Funciona offline | ✗ | ✓ | | Instalable en home screen | ✗ | ✓ | | Sin barra del navegador | ✗ | ✓ | | Pantalla siempre encendida | ✗ | ✓ (Wake Lock) | | Distribución por link | ✓ | ✓ | | Sin App Store | ✓ | ✓ | | Actualización automática | ✓ | ✓ |


Checklist de implementación

Manifest
  ✓ name, short_name, description
  ✓ display: "standalone"
  ✓ start_url
  ✓ theme_color + background_color
  ✓ Íconos 192×192, 512×512 y maskable 512×512

Service Worker
  ✓ Registrado en el entry point de la app
  ✓ Estrategia de caché definida para todos los assets
  ✓ registerType: "autoUpdate" (o manejo manual de skipWaiting)
  ✓ globPatterns incluye todos los tipos de asset

APIs nativas
  ✓ Wake Lock para apps que muestran contenido continuo
  ✓ Detección de display-mode: standalone para UX condicional
  ✓ visibilitychange para readquirir Wake Lock

iOS
  ✓ Banner de instalación contextual para Safari
  ✓ apple-touch-icon en el <head>
  ✓ apple-mobile-web-app-capable y status-bar-style

Testing
  ✓ Lighthouse PWA audit (score 100)
  ✓ Probar offline en DevTools → Network → Offline
  ✓ Probar instalación real en dispositivo físico

Publicado el 12 de junio de 2026 · elEddie.com