Skip to content

An offline-first PWA on AWS Amplify, part 3: Workbox, tile caching, and airplane mode

App-shell precaching, one Workbox rule for map tiles, and a 'cache this area' button that warms the cache with plain fetch(): the service-worker layer of an offline-first PWA.

PWA Workbox Offline-first

Part 3 of a three-part walkthrough of Waypoints, an offline-first PWA for marking and mapping GPS waypoints. Part 1 covered the data layer; part 2 covered the map. This part covers the layer that makes it an app: the service worker.

Here’s the acceptance test for this whole series: put the phone in airplane mode, kill the app, and reopen it. The app loads. Your waypoints are there. The map shows tiles for the area you were working in. You mark three more points, measure an area, and everything queues for later. Part 1’s data layer covers the second half of that list, but the app loading at all with no network, fonts and map tiles included, is service-worker territory. This part walks that layer, which is one config block, one caching rule, and one button.

The whole PWA config

Everything the service worker does is declared in vite.config.ts. Here’s the config, minus the manifest icons:

const TILE_CACHE = {
  urlPattern: /^https:\/\/[a-d]\.basemaps\.cartocdn\.com\/.*/i,
  handler: "CacheFirst" as const,
  options: {
    cacheName: "carto-tiles",
    expiration: { maxEntries: 1500, maxAgeSeconds: 60 * 60 * 24 * 30 },
    cacheableResponse: { statuses: [0, 200] },
  },
};

export default defineConfig({
  plugins: [
    react(),
    VitePWA({
      registerType: "autoUpdate",
      includeAssets: ["fonts/*.woff2", "icons/*.png"],
      manifest: {
        name: "Waypoints",
        short_name: "Waypoints",
        description:
          "Offline-first field app for marking and mapping GPS waypoints.",
        theme_color: "#21302a",
        background_color: "#f4efe3",
        display: "standalone",
        start_url: "/",
        icons: [
          /* 192, 512, and a maskable 512 */
        ],
      },
      workbox: {
        globPatterns: ["**/*.{js,css,html,woff2,png,svg}"],
        runtimeCaching: [TILE_CACHE],
      },
    }),
  ],
});

Three decisions in one small file, each worth unpacking.

Decision one: precache everything the app is made of

globPatterns: ['**/*.{js,css,html,woff2,png,svg}'] tells Workbox to precache every built asset matching those extensions: the app shell. On first visit, the generated service worker downloads the lot into the cache; on every visit after, navigations and asset requests are served from disk before the network is consulted. Because Vite fingerprints filenames, precache entries carry revision: null and cache-bust for free: a changed chunk gets a new URL, an unchanged one is never re-downloaded.

The woff2 in that glob matters more than it looks. The fonts (Fraunces, Inter, IBM Plex Mono) are self-hosted via @fontsource packages, imported at the top of main.tsx like any other module. Vite fingerprints every subset file into dist/assets/, the glob sweeps them into the precache (the built worker lists about forty woff2 entries across the charset subsets), and the result is that typography is identical offline and on. If the app pulled fonts from Google Fonts instead, the first airplane-mode reload would fall back to system fonts and the “field instrument” would suddenly look like a default web page. Offline-first extends to assets you usually don’t think of as data.

Navigations get the standard SPA treatment: the plugin registers a NavigationRoute bound to the precached index.html, so a hard refresh on /map while offline serves the shell and lets React Router take it from there. (Its online mirror is the SPA 200-rewrite configured in Amplify Hosting: same problem, solved once per environment.)

Decision two: exactly one runtime cache, for tiles

The runtimeCaching array has a single entry, and its scope line is the regex: only https://[a-d].basemaps.cartocdn.com/..., the CARTO tile CDN from part 2, subdomains a through d. Map tiles are immutable images with stable URLs, the best possible candidate for CacheFirst: serve from cache if present, hit the network only on a miss, and store what comes back.

The two options lines are where the operational thinking lives:

  • expiration: { maxEntries: 1500, maxAgeSeconds: 60 * 60 * 24 * 30 }: the cache is bounded, 1,500 tiles for 30 days, oldest evicted first. Unbounded tile caches are how a well-meaning PWA quietly eats a phone’s storage quota; this one has a ceiling of roughly 30–50 MB.
  • cacheableResponse: { statuses: [0, 200] }: the 0 is not a typo. A cross-origin request made with mode: 'no-cors' resolves to an opaque response: the body is there, but the status reads 0 and JavaScript may not inspect it. Workbox’s default policy refuses to cache opaques (an opaque could be an error page, and it counts against quota at an inflated size). Allowing status 0 is an explicit, scoped exception, and it’s the hinge the “cache this area” feature turns on.

Just as deliberate is what has no runtime rule: the AppSync endpoint. API traffic passes straight through the service worker to the network, because caching a mutable API behind a service worker would fight the actual offline strategy: part 1’s Dexie-plus-outbox layer, which handles data offline with real transactional guarantees. The service worker owns assets; the sync engine owns data. Keeping those responsibilities separate is most of what “offline-first architecture” means here.

navigation

built asset · js / css / woff2

a–d.basemaps.cartocdn.com

AppSync

outgoing request

what is it?

precache · index.html app shell

precache · fingerprinted files

carto-tiles · CacheFirst · 1500 entries / 30 days

network only · the sync engine owns offline

Decision three: update silently

registerType: 'autoUpdate' produces a worker that calls skipWaiting() and clientsClaim(): a new deploy activates immediately, without the “refresh to update” toast pattern. Registration is the generated one-liner; there’s no useRegisterSW hook, no update UI anywhere in the app. The trade-off is real and worth naming: a service worker that activates mid-session can, in principle, swap chunks under a running tab. For a small app shell where any version can read the local database, silent updates are the right amount of machinery; an app with versioned client-side migrations would want the prompt. The right choice depends on the app; make it deliberately.

”Cache this area”: warming the cache with plain fetch()

Tiles cache as you pan: CacheFirst stores every tile you’ve ever looked at. But “I looked at it once” is a weak guarantee to carry into a dead zone, so the map has a button that pre-fetches the visible area at the current zoom plus the next two levels. The interesting part is what the implementation doesn’t contain: no Cache API calls, no caches.open('carto-tiles'), no postMessage to the worker. The entire mechanism, from src/components/CacheAreaButton.tsx:

const TILE_CAP = 400;
const EXTRA_ZOOM = 2;

const cacheArea = async (): Promise<void> => {
  const b = map.getBounds();
  const bounds: Bounds = {
    north: b.getNorth(),
    south: b.getSouth(),
    east: b.getEast(),
    west: b.getWest(),
  };
  const z = map.getZoom();
  const tiles = tilesForZooms(bounds, z, z + EXTRA_ZOOM, TILE_CAP);
  setProgress(0);
  let done = 0;
  // Sequential, polite fetching so Workbox's CacheFirst route stores each tile.
  for (const tile of tiles) {
    try {
      await fetch(tileUrl(tile), { mode: "no-cors" });
    } catch {
      /* ignore single-tile failure */
    }
    done += 1;
    setProgress(Math.round((done / tiles.length) * 100));
  }
  setProgress(null);
};

It just fetches the tile URLs. Each request leaves the page, passes through the service worker, matches the carto-tiles route’s regex, and Workbox stores the response on the way back: the same code path a pan or zoom takes, exercised deliberately. Prefetched tiles and browsed tiles land in one cache with one eviction policy; there’s no second bucket to size, no duplicate storage, and nothing to keep consistent with the runtime rule. The mode: 'no-cors' is what makes the cross-origin fetch resolve at all from page context, producing exactly the opaque, status-0 responses that cacheableResponse.statuses: [0, 200] was configured to admit. The two lines are a matched pair in different files; delete either and the feature quietly stops caching.

The enumeration is textbook slippy-map math in src/lib/tilePrefetch.ts: longitude to tile column, latitude to tile row via the Web Mercator formula:

const latToTileY = (lat: number, z: number): number => {
  const rad = (lat * Math.PI) / 180;
  return Math.floor(
    ((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2) * 2 ** z,
  );
};

export const tilesForZooms = (bounds, minZoom, maxZoom, cap): TileCoord[] => {
  const tiles: TileCoord[] = [];
  for (let z = minZoom; z <= maxZoom; z += 1) {
    for (const tile of tileRange(bounds, z)) {
      if (tiles.length >= cap) return tiles;
      tiles.push(tile);
    }
  }
  return tiles;
};

The bounds come from restraint, twice over. TILE_CAP = 400 is a hard early-return across all zoom levels: lower zooms enumerate first, so a cap hit sacrifices detail rather than coverage. And the fetch loop is sequential (await per tile, not Promise.all), which turns cache-warming into a polite trickle instead of a 400-request burst against a keyless CDN that’s providing tiles for free. Each tile gets its own try/catch, so one failure doesn’t abort the batch; progress just ticks on. The button reports Caching… {progress}% and disables itself while running.

The honest limits: 400 tiles across three zoom levels covers a work area, not a county. Cache several areas and the 1,500-entry ceiling starts evicting the earliest one: the caps compose into “your recent areas” rather than permanent regional coverage. A future version that needs guaranteed regional coverage would bundle an offline tile pack (PMTiles) instead of leaning on the runtime cache; for a field tool whose user knows where they’re going tomorrow, “cache this area before you drive out” is the right size of solution. And a tile that was never cached still renders as gray void offline: there’s no placeholder tile to say so; the only connectivity signal is the status pill.

Telling the user the truth about connectivity

The last piece is small but does disproportionate UX work. useSyncStatus combines the browser’s connectivity events with a live count of part 1’s outbox:

export const useSyncStatus = (): SyncStatus => {
  const [online, setOnline] = useState<boolean>(
    typeof navigator === "undefined" ? true : navigator.onLine,
  );
  useEffect(() => {
    const update = (): void => setOnline(navigator.onLine);
    window.addEventListener("online", update);
    window.addEventListener("offline", update);
    return () => {
      /* remove both */
    };
  }, []);

  const pending = useLiveQuery(() => db.outbox.count(), [], 0);
  return { online, pending };
};

A pill in the nav collapses the two signals into three states:

const state: "offline" | "syncing" | "synced" = !online
  ? "offline"
  : pending > 0
    ? "syncing"
    : "synced";
const label =
  state === "offline"
    ? `Offline · ${pending} queued`
    : state === "syncing"
      ? `Syncing ${pending}`
      : "Synced";

“Offline · 3 queued” answers the question a field user actually has (is my work safe?) with a number rather than reassurance. The pill pairs a colored dot with a text label (never color alone) and announces changes through aria-live="polite". Because pending is a liveQuery over the same outbox the sync engine drains, the pill counts down during a flush with no event wiring at all: the database is the message bus.

One platform caveat closes the loop from part 1: there is no Background Sync API in this app (and iOS Safari doesn’t support it anyway), so a queued change flushes only when the app is open to hear the online event, or on the next launch. The pill’s queue count is the honest interface to that limitation.

The shape of the whole thing

Three parts, three layers, one rule each. The data layer: the UI reads and writes only IndexedDB, and an outbox makes the cloud eventually agree. The map layer: selection is state, geometry is derived, and nothing on the map needs a network but the tiles. The service-worker layer: the app shell is precached, exactly one runtime cache exists, and it’s bounded. None of these layers knows much about the others: the seam between the service worker and the sync engine is literally “one handles assets, one handles data,” which is why each fit in a single article and the whole thing fits in your head.

The repo is github.com/djheru/waypoints. Clone it, run npx ampx sandbox and npm run dev, then do the real test: npm run build && npm run preview, load it, flip on airplane mode, and reload.