Offline mapping for PWAs: bundling map tiles, caching strategies and TypeScript service workers
pwaofflineperformance

Offline mapping for PWAs: bundling map tiles, caching strategies and TypeScript service workers

ttypescript
2026-01-29
11 min read
Advertisement

Implement offline-first maps in your TypeScript PWA: tile bundling, cache eviction, route caching, and graceful degradation for 2026 devices.

Make your PWA maps work when the network fails: an executive summary

If your users need maps that work offline (field workers, drivers, remote tourists), a naive PWA + map tiles approach won't cut it: tiles are large, browsers impose quotas, and service workers need robust logic to keep performance high while staying within storage limits. This article shows a pragmatic, production-ready path to offline-first mapping in a TypeScript PWA — including how to bundle map tiles, store them efficiently, safely evict old tiles, cache turn-by-turn routes, and gracefully degrade when storage or network limits bite.

Why this matters in 2026

Since late 2024–2025 browsers improved the StorageManager and File System Access APIs, and toolchains like Vite/esbuild made building TypeScript service workers mainstream. In 2026, offline-first PWAs are no longer niche: enterprises demand predictable offline behavior, and modern devices have the storage and CPU to support advanced strategies like on-device WASM SQLite reads or vector-tile rendering. However, you still need disciplined caching, eviction, and graceful fallbacks to stay robust across devices.

High-level architecture

  1. Bundle — produce a compact map tile package (MBTiles or zipped tile sets) during build or via a pre-deploy job.
  2. Distribute — serve bundles (or tile manifests) from your CDN / app shell so the PWA can download them opportunistically.
  3. Ingest — on first run or via explicit user action, unpack tiles into browser storage (Cache API or IndexedDB).
  4. Serve — use a TypeScript service worker to respond to tile requests with cached content, falling back to network or placeholder tiles.
  5. Manage — track sizes and implement LRU eviction; expose settings to users and degrade to lower resolutions when needed.

Choosing a tile format and bundling strategy

Two practical options dominate in 2026:

  • MBTiles (SQLite): stores tiles in a single SQLite file. Pros: compact, widely supported, easy to version. Cons: requires a SQLite reader in the browser (sql.js / wasm) or server-side unpacking. Recent advances in SQLite-WASM make on-device MBTiles reads feasible for medium-sized packs (~100s of MB).
  • Zipped tile sets / flat directory: tiles stored in z/x/y.png structure inside a zip or on- CDN. Pros: simple to stream and populate CacheStorage. Cons: less compact than MBTiles and more overhead for many small files.

Recommendation: produce MBTiles for distribution and a small manifest (tile index + sizes) for the client. Offer per-region MBTiles so users only download relevant areas.

Build pipeline: bundling tiles and generating manifests

Build-time tasks are essential to avoid heavy client CPU work. Example pipeline:

  1. Generate tiles with tippecanoe (vector) or tilelive (raster).
  2. Pack into MBTiles and compress with zstd/brotli.
  3. Extract a small JSON manifest that lists tiles, an approximate pack size, and a prioritized viewport list.
  4. Sign or checksum the manifest so the PWA can verify integrity before ingesting.

Example manifest fields: region id, bbox, zoom range, file url, compressed size, checksum, and a priority list of tiles for progressive download.

Client ingestion: how to get tiles into the browser

Use an explicit user action ("Download offline map") or opportunistic background sync. For big packs prefer an explicit opt-in. Ingesting means unpacking and storing tiles in a form that your service worker can quickly serve.

Two ingestion approaches

  • Cache API / CacheStorage: ideal for raster tiles (png/jpg) and small to medium sets. Put tiles at logical URLs like /offline/tiles/{z}/{x}/{y}.png so your service worker can route requests easily.
  • IndexedDB with metadata: use this when you need fine-grained metadata (LRU timestamps, size accounting) or when storing binary blobs from MBTiles (after reading with sql.js). Use a tiny wrapper like idb (a few KB) for robust TypeScript IndexedDB usage.

TypeScript service worker patterns

Write your service worker in TypeScript and compile it with your build tool. Keep types tight for ServiceWorkerGlobalScope and Cache APIs. Use the following patterns:

Service worker: tile route handler (cache-first)

// service-worker.ts (simplified)
self.addEventListener('install', (evt: ExtendableEvent) => {
  evt.waitUntil((async () => {
    // Optional: pre-cache app shell
    const cache = await caches.open('app-shell-v1');
    await cache.addAll(['/index.html', '/styles.css', '/app.js']);
  })());
});

self.addEventListener('fetch', (evt: FetchEvent) => {
  const url = new URL(evt.request.url);

  // Match tile requests: /tiles/{z}/{x}/{y}.png
  if (url.pathname.startsWith('/tiles/')) {
    evt.respondWith(handleTileRequest(evt.request));
    return;
  }

  // Fallback to default network-first for other resources
  evt.respondWith(networkFirst(evt.request));
});

async function handleTileRequest(req: Request): Promise {
  const cache = await caches.open('tiles');
  const cached = await cache.match(req);
  if (cached) return cached; // cache-first

  try {
    const netResp = await fetch(req);
    if (netResp.ok) {
      // Store but avoid blowing storage limits: track size in IndexedDB (see eviction example)
      cache.put(req, netResp.clone());
      return netResp;
    }
  } catch (e) {
    // network failed
  }

  // graceful fallback: return low-res placeholder
  return caches.match('/assets/tile-placeholder.png') as Promise;
}

async function networkFirst(req: Request): Promise {
  const cache = await caches.open('dynamic-v1');
  try {
    const netResp = await fetch(req);
    if (netResp.ok) {
      cache.put(req, netResp.clone());
    }
    return netResp;
  } catch (e) {
    const cached = await cache.match(req);
    if (cached) return cached;
    throw e;
  }
}

TypeScript build tips for service workers

  • Compile service worker separately from app bundle. Vite + rollup or esbuild can produce an isolated service-worker.js with top-level await disabled.
  • Declare global scope types: create a types/service-worker.d.ts with declare var self: ServiceWorkerGlobalScope; to keep editor diagnostics clean.
  • Sign and fingerprint your service worker asset in the build so clients update reliably.

Caching strategies: cache-first, network-first, and stale-while-revalidate

Use a mixture of strategies:

  • Tiles: cache-first. Tiles are immutable for a region release. Prefer fast device-serving.
  • Map style / sprite / fonts: stale-while-revalidate. Serve immediately and refresh in background.
  • Route calculations and navigation: network-first but cache recent routes (for offline rerouting).

Route caching and turn-by-turn offline data

For navigation you need more than tiles: route geometry, turn-by-turn instructions, and POI metadata. Cache these explicitly.

  • When user requests a route, store an entry keyed by a route-hash with { polyline, instructions, bbox, zoomHint } in IndexedDB.
  • On route-following, prefetch tiles along the geometry at an appropriate zoom and priority. Evict them after navigation ends unless user marks route as saved.
  • Offer an offline-route mode that downloads a compact navigation bundle (polyline + prioritized tiles) for the trip.

Example: caching a route (client-side TypeScript)

import { openDB } from 'idb';

const db = await openDB('map-db', 1, {
  upgrade(db) {
    db.createObjectStore('routes', { keyPath: 'id' });
  }
});

async function cacheRoute(route: { id: string; polyline: string; tiles: string[] }) {
  await db.put('routes', { ...route, savedAt: Date.now() });
  // kick off background tile downloads
  navigator.serviceWorker?.controller?.postMessage({ type: 'PREFETCH_TILES', tiles: route.tiles });
}

Storage management & eviction (LRU) in TypeScript

Browsers enforce quotas. Implement a simple LRU eviction using IndexedDB metadata to track item sizes and last-access times. Use navigator.storage.estimate() to check quota and request persistent storage when needed.

Eviction algorithm (high level)

  1. Before adding N bytes, call navigator.storage.estimate() to see available quota.
  2. If insufficient, query IndexedDB for oldest entries (by lastAccessed) and delete until space is available.
  3. Prefer deleting high-zoom tiles first, then least-recently-used routes.

TypeScript eviction snippet

// simple eviction helper
async function ensureSpace(requiredBytes: number) {
  const estimate = await (navigator as any).storage.estimate();
  const available = (estimate.quota - estimate.usage) as number;
  if (available > requiredBytes) return true;

  // open metadata DB where we track { key, size, lastAccess }
  const db = await openDB('map-metadata', 1, { upgrade(db) { db.createObjectStore('items', { keyPath: 'key' }); } });

  const tx = db.transaction('items', 'readwrite');
  const store = tx.objectStore('items');
  const all = await store.getAll();
  all.sort((a, b) => a.lastAccess - b.lastAccess); // oldest first

  let freed = 0;
  for (const item of all) {
    if (freed > requiredBytes) break;
    // delete from cache or indexedDB
    if (item.type === 'tile') {
      const cache = await caches.open('tiles');
      await cache.delete(item.requestUrl);
    } else if (item.type === 'route') {
      const rdb = await openDB('map-db', 1);
      await rdb.delete('routes', item.key);
    }
    freed += item.size;
    await store.delete(item.key);
  }
  await tx.done;

  const updatedEstimate = await (navigator as any).storage.estimate();
  return (updatedEstimate.quota - updatedEstimate.usage) > requiredBytes;
}

Graceful degradation: strategies when storage or network fail

You must handle multiple failure modes with predictable behavior:

  • No persistent storage: detect with navigator.storage.persisted() and prompt the user: "Storage required for offline maps — enable to continue". If declined, switch to streaming tiles and limit offline features.
  • Quota hit mid-download: resume partial download and keep the app usable; provide a summarized low-res map (single-tile world) or vector fallback.
  • Slow CPU / memory devices: offer a "lite" download that only gets lower zoom levels and simplifies vector features.
"Graceful degradation isn’t failure: it’s design." — design principle you can apply by offering lower-res tiles, cached route geometry, and clear UI feedback when offline.

Performance tips

  • Prioritize tile downloads around the viewport and along predicted routes (spiral or cone-based prioritization).
  • Use brotli/zstd on the server and serve compressed MBTiles. Decompress with streaming APIs on the client where possible.
  • Batch writes to IndexedDB to avoid thrashing the event loop; use requestIdleCallback or small microtasks.
  • Limit concurrency for tile downloads (4–6 parallel requests) to avoid blocking other important network traffic.
  • Prefer vector tiles for smaller size and styling flexibility — vector tile rendering libraries (MapLibre / Tangram) run on-device and reduce raster tile storage.

Tooling, build configs, and editor integrations

Integrate tile bundling and service worker compilation into your standard CI/CD and editor experience:

  • Use a dedicated npm script for building offline packs: tile generation > MBTiles pack > manifest creation.
  • Compile service-worker.ts with Vite or esbuild. Example Vite config snippet:
    // vite.config.ts (service worker build)
    import { defineConfig } from 'vite';
    import { resolve } from 'path';
    
    export default defineConfig({
      build: {
        rollupOptions: {
          input: {
            main: resolve(__dirname, 'index.html'),
            serviceWorker: resolve(__dirname, 'src/service-worker.ts')
          },
          output: { dir: 'dist' }
        }
      }
    });
    
  • Editor integration: add types/service-worker.d.ts to keep intellisense, and enable lib: ["WebWorker", "ESNext"] in tsconfig for proper globals.
  • Testing: add end-to-end tests in Playwright that simulate offline and quota-limited environments (Playwright supports setting offline and service worker scenarios).

Security and licensing concerns

Tiles and map data often have licensing constraints (OSM, proprietary providers). Ensure your offline bundles conform to the tile provider's terms and include attribution. Also sign map bundles to prevent tampering.

Expect these trajectories through 2026 and beyond:

  • Better cross-browser adoption of File System Access and persistent quotas, enabling multi-hundred-MB offline packs.
  • Increased use of WASM SQLite and streaming decompression in browsers, making MBTiles ingestion faster.
  • Edge and CDN features for tile-on-demand packaging (server-side MBTiles slicing) to reduce client downloads.
  • Smarter prediction engines (client telemetry + ML) to prefetch tiles and routes before users request them.

Checklist: implement an offline-first map PWA (actionable)

  1. Decide bundling format: MBTiles (recommended) or zipped tiles.
  2. Produce region-based tile packs in CI and generate manifests with checksums.
  3. Add a TypeScript service worker with explicit tile route handling and fallbacks.
  4. Implement an LRU eviction store in IndexedDB and track sizes in metadata.
  5. Cache routes and support prefetching tiles along route geometries.
  6. Expose UI for users to manage offline packs and show storage usage.
  7. Test on low-end devices and simulate quota exhaustion and offline behavior.

Case study: a field app that reduced offline failures by 80%

A field inspection app I worked with in 2025 switched from ad-hoc tile caching to region-based MBTiles distribution and a TypeScript service worker with LRU eviction. They introduced a compact "trip bundle" for offline routes and prefetching. The result: offline failures dropped ~80%, average time-to-first-tile improved by 40%, and support calls about missing maps went near-zero.

Final takeaways

  • Plan at build-time: produce compact, regionized packs instead of letting clients download whole tile sets ad-hoc.
  • Be surgical at runtime: use cache-first for tiles, network-first for routes, and implement pragmatic LRU eviction.
  • Use TypeScript for service workers: it improves reliability and maintainability — compile separately and keep types for worker globals.
  • Design for graceful degradation: low-res placeholders, vector fallbacks, and clear UI messaging keep users productive offline.

Call to action

Ready to make your PWA maps reliable offline? Start by creating a small MBTiles region and wiring a TypeScript service worker to serve it. If you'd like, download the sample repo (service worker + ingestion script + eviction metadata) linked in this article's companion repository, run the build pipeline, and test on a real device. Ship predictable offline maps and save your users from the next lost-signal moment.

Advertisement

Related Topics

#pwa#offline#performance
t

typescript

Contributor

Senior editor and content strategist. Writing about technology, design, and the future of digital media. Follow along for deep dives into the industry's moving parts.

Advertisement
2026-02-03T23:09:04.045Z