Skip to content

PWA Pattern

Progressive Web App with offline support and update management.

Overview

flowchart TB
    subgraph Registration
        A[App Load] --> B[Register SW]
        B --> C[SW Active]
    end

    subgraph Caching
        D[Request] --> E{Cache Strategy}
        E -->|HTML| F[NetworkFirst]
        E -->|API| G[NetworkOnly]
        E -->|Images| H[CacheFirst]
        E -->|Fonts| I[CacheFirst]
    end

    subgraph Updates
        J[Check Updates] --> K{New Version?}
        K -->|Yes| L[Show Prompt]
        L --> M[User Accepts]
        M --> N[Reload App]
    end

Service Worker Registration

import { registerSW } from "virtual:pwa-register";
import { PWA, EVENTS } from "./constants/app.constants";
import { usePwaUpdateStore } from "./stores/pwaUpdateStore";

export const updateSW = registerSW({
  immediate: true,

  onNeedRefresh() {
    usePwaUpdateStore.getState().setUpdateAvailable();
  },

  onRegisteredSW(_swUrl, registration) {
    if (!registration) {
      return;
    }
    setInterval(() => {
      registration.update().catch(() => {});
    }, PWA.UPDATE_CHECK_INTERVAL_MS);
  },

  onRegisterError(error) {
    Sentry.captureException(error, {
      tags: { context: "service_worker_registration" },
    });
  },
});

window.addEventListener(EVENTS.PWA_APPLY_UPDATE, () => updateSW(true));

Cache Strategies

Resource Strategy TTL Max Entries
HTML NetworkFirst 3s timeout
API calls NetworkOnly
Images CacheFirst 30 days 100
Fonts CacheFirst 1 year 30
JS/CSS StaleWhileRevalidate

Workbox Configuration

// vite.config.ts
VitePWA({
  registerType: "prompt",
  workbox: {
    runtimeCaching: [
      {
        urlPattern: /\.html$/,
        handler: "NetworkFirst",
        options: {
          networkTimeoutSeconds: 3,
          cacheName: "html-cache",
        },
      },
      {
        urlPattern: /\/api\//,
        handler: "NetworkOnly",
      },
      {
        urlPattern: /\.(png|jpg|jpeg|webp|svg)$/,
        handler: "CacheFirst",
        options: {
          cacheName: "image-cache",
          expiration: {
            maxEntries: 100,
            maxAgeSeconds: 30 * 24 * 60 * 60,
          },
        },
      },
      {
        urlPattern: /\.(woff2?|ttf|otf)$/,
        handler: "CacheFirst",
        options: {
          cacheName: "font-cache",
          expiration: {
            maxEntries: 30,
            maxAgeSeconds: 365 * 24 * 60 * 60,
          },
        },
      },
    ],
  },
});

Update Store

type PwaUpdateState = {
  updateAvailable: boolean;
  dismissed: boolean;
  setUpdateAvailable: (available: boolean) => void;
  dismiss: () => void;
  applyUpdate: () => void;
};

const usePwaUpdateStore = create<PwaUpdateState>((set) => ({
  updateAvailable: false,
  dismissed: false,

  setUpdateAvailable: (available) => set({ updateAvailable: available }),

  dismiss: () => set({ dismissed: true }),

  applyUpdate: () => {
    window.dispatchEvent(new CustomEvent(EVENTS.PWA_APPLY_UPDATE));
  },
}));

Update Dialog

function PwaUpdateDialog() {
  const { updateAvailable, dismissed, dismiss, applyUpdate } =
    usePwaUpdateStore();

  if (!updateAvailable || dismissed) return null;

  return (
    <Dialog open>
      <DialogTitle>{t("pwa.updateAvailable")}</DialogTitle>
      <DialogContent>
        {t("pwa.updateDescription")}
      </DialogContent>
      <DialogActions>
        <Button onClick={dismiss}>{t("shared.later")}</Button>
        <Button onClick={applyUpdate} variant="contained">
          {t("shared.update")}
        </Button>
      </DialogActions>
    </Dialog>
  );
}

Auto-Update Check

function useAutoUpdateOnIdle() {
  useEffect(() => {
    let timeout: number;

    const resetTimer = () => {
      clearTimeout(timeout);
      timeout = window.setTimeout(() => {
        // Check for updates after 30 min idle
        navigator.serviceWorker?.ready.then((registration) => {
          registration.update();
        });
      }, 30 * 60 * 1000);
    };

    window.addEventListener("mousemove", resetTimer);
    window.addEventListener("keydown", resetTimer);
    resetTimer();

    return () => {
      clearTimeout(timeout);
      window.removeEventListener("mousemove", resetTimer);
      window.removeEventListener("keydown", resetTimer);
    };
  }, []);
}

Manifest

{
  "name": "Unicorn Trails",
  "short_name": "Trails",
  "theme_color": "#673ab7",
  "background_color": "#ffffff",
  "display": "standalone",
  "start_url": "/",
  "icons": [
    { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
  ]
}