Offline‑First Map Components for Mobile Web: Handling Routing, Caching, and Incidents
tutorialmapsperformance

Offline‑First Map Components for Mobile Web: Handling Routing, Caching, and Incidents

jjavascripts
2026-01-26
13 min read
Advertisement

Build offline map controls that cache routes and merge Waze‑style incident feeds using service workers, IndexedDB, and delta sync.

Hook: stop losing users when the network drops — build offline‑first map controls

Mobile web apps fail in one place more often than any other: flaky cellular networks. For developer teams building navigation, delivery, or field‑work apps, that means lost routes, stale incident alerts (Waze‑style), and frustrated users. This guide shows how to build map controls that work offline, cache routes, and merge incident feeds — using modern browser primitives (service workers, IndexedDB, Cache Storage) and a practical delta‑sync pattern you can run today in 2026.

What you'll get

  • Architecture: offline tile + route + incident cache using a service worker and IndexedDB
  • Concrete code: service worker, IndexedDB helper, delta sync, and merge logic
  • Integration: runnable examples for React (MapLibre), Vue, vanilla JS, and a Web Component control
  • Advanced tips: eviction, integrity, security, and 2026 trends (Edge + WebTransport)

Why this matters in 2026

By late 2025 and into 2026 we saw broad PWA adoption across enterprise mobile fleets, better persistent storage APIs, and practical serverless edge functions to serve vector tiles and small routing artifacts with low latency. Browser vendors stabilized background fetch and WebTransport features that make near‑real‑time incident feeds feasible on the web. That means the mobile web can now provide navigation experiences that used to be native‑only — if you design for offline first.

High-level architecture

Keep the client simple: a service worker handles network caching and background fetch duties; a small client data layer (IndexedDB) stores routes and incidents; the map control reads from that layer and falls back to online requests when available.

  1. Service worker — intercepts tile/route/incident requests, serves Cache Storage entries, coordinates background sync.
  2. IndexedDB — authoritative local DB for cached routes and incident records; supports atomic updates and delta application.
  3. Delta sync API — server endpoint that returns incremental changes for incidents since a sequence number or timestamp.
  4. Map control — UI component that reads cached data, shows offline indicators, and merges live incident overlays.

Design decisions (quick)

  • Tiles: Cache Storage with stale‑while‑revalidate; vector tiles preferred for size and mergeability.
  • Routes: Cache route polylines (server precomputes) in IndexedDB keyed by origin/destination hash.
  • Incidents: Use delta sync (sequence numbers), store canonical id, lastUpdated, geometry, severity, and source.
  • Background updates: Periodic background sync where supported, fallback on visibilitychange and on app resume.

Service worker: caching tiles, routes, and incident deltas

The service worker is the first line of defense. Below is a compact service worker that implements:

// sw.js
const TILE_CACHE = 'tiles-v1';
const ROUTE_CACHE = 'routes-v1';
const INCIDENT_CACHE = 'incidents-v1';

self.addEventListener('install', evt => self.skipWaiting());
self.addEventListener('activate', evt => clients.claim());

// Helper: stale-while-revalidate for tiles and routes
async function staleWhileRevalidate(request, cacheName) {
  const cache = await caches.open(cacheName);
  const cached = await cache.match(request);
  const networkPromise = fetch(request)
    .then(resp => { if (resp && resp.ok) cache.put(request, resp.clone()); return resp; })
    .catch(() => null);
  return cached || (await networkPromise) || new Response('', { status: 504 });
}

self.addEventListener('fetch', evt => {
  const url = new URL(evt.request.url);
  // Example tile pattern: /tiles/{z}/{x}/{y}.pbf
  if (url.pathname.startsWith('/tiles/')) {
    evt.respondWith(staleWhileRevalidate(evt.request, TILE_CACHE));
    return;
  }
  // Example route API: /api/route?src=...&dst=...
  if (url.pathname === '/api/route') {
    evt.respondWith(staleWhileRevalidate(evt.request, ROUTE_CACHE));
    return;
  }
  // Incident delta fetch - let client handle applying to IndexedDB,
  // but cache the JSON for fallback
  if (url.pathname === '/api/incidents/delta') {
    evt.respondWith(staleWhileRevalidate(evt.request, INCIDENT_CACHE));
    return;
  }
});

// Simple message handler to trigger a delta fetch from SW context
self.addEventListener('message', async (evt) => {
  if (evt.data && evt.data.type === 'SYNC_INCIDENTS') {
    // Try background fetch or just fetch and postMessage to all clients
    try {
      const resp = await fetch('/api/incidents/delta?since=' + encodeURIComponent(evt.data.since));
      const json = await resp.json();
      // broadcast to clients
      const all = await clients.matchAll({ includeUncontrolled: true });
      for (const client of all) client.postMessage({ type: 'INCIDENTS_DELTA', data: json });
    } catch (e) {
      console.warn('Incident sync failed', e);
    }
  }
});

Notes

  • Cache Storage is fine for tile assets (immutable vector tiles). Use Cache‑Control headers from your tile server.
  • For large route blobs consider storing them in IndexedDB instead of Cache Storage to keep typed metadata.

IndexedDB: small wrapper and schema

Use a tiny promise wrapper over IndexedDB. Below is a compact helper (no third‑party deps) and the schema we use:

// idb-lite.js
function openDB(name='map-db', version=1) {
  return new Promise((resolve, reject) => {
    const req = indexedDB.open(name, version);
    req.onupgradeneeded = (evt) => {
      const db = evt.target.result;
      if (!db.objectStoreNames.contains('routes')) db.createObjectStore('routes', { keyPath: 'key' });
      if (!db.objectStoreNames.contains('incidents')) db.createObjectStore('incidents', { keyPath: 'id' });
      if (!db.objectStoreNames.contains('meta')) db.createObjectStore('meta', { keyPath: 'k' });
    };
    req.onsuccess = e => resolve(e.target.result);
    req.onerror = e => reject(e.target.error);
  });
}

async function idbPut(storeName, value) {
  const db = await openDB();
  return new Promise((resolve, reject) => {
    const tx = db.transaction(storeName, 'readwrite');
    tx.objectStore(storeName).put(value);
    tx.oncomplete = () => resolve();
    tx.onerror = e => reject(e.target.error);
  });
}

async function idbGet(storeName, key) {
  const db = await openDB();
  return new Promise((resolve, reject) => {
    const tx = db.transaction(storeName, 'readonly');
    const req = tx.objectStore(storeName).get(key);
    req.onsuccess = () => resolve(req.result);
    req.onerror = e => reject(e.target.error);
  });
}

async function idbAll(storeName) {
  const db = await openDB();
  return new Promise((resolve, reject) => {
    const tx = db.transaction(storeName, 'readonly');
    const req = tx.objectStore(storeName).getAll();
    req.onsuccess = () => resolve(req.result);
    req.onerror = e => reject(e.target.error);
  });
}

Schema:

  • routes: { key: string (src|dst hash), polyline: GeoJSON/encoded, meta: { distance, duration, expires } }
  • incidents: { id: string, geom: GeoJSON, severity: number, lastUpdated: ISO, source: string, status }
  • meta: store last applied incident sequence number: { k: 'incidents_seq', v: 12345 }

Delta sync: contract and client merge logic

A reliable delta protocol is the heart of offline incident merging. Keep it simple and deterministic.

Server contract

  1. Endpoint: GET /api/incidents/delta?since={seq}
  2. Response: { seq: , changes: { upserts: [incident], deletes: [id] }, serverTime: ISO }
  3. Each incident has stable id and lastUpdated timestamp.

Client merge algorithm (idempotent)

async function applyIncidentDelta(delta) {
  // delta = { seq, changes: { upserts: [...], deletes: [...] } }
  // 1) apply deletes
  for (const id of delta.changes.deletes || []) {
    await idbPut('incidents', { id, _deleted: true });
  }
  // 2) upserts - replace if newer
  for (const inc of delta.changes.upserts || []) {
    const existing = await idbGet('incidents', inc.id);
    if (!existing || new Date(inc.lastUpdated) > new Date(existing.lastUpdated)) {
      await idbPut('incidents', inc);
    }
  }
  // 3) persist seq
  await idbPut('meta', { k: 'incidents_seq', v: delta.seq });
}

This approach is resilient: reapplying the same delta is harmless, and applying deltas out of order is detected by seq checks on the server or a reconciliation step. For practical delta and cache-first API patterns see Next‑Gen Catalog & Cache‑First APIs.

Route caching strategy

For routing, the simplest robust pattern for mobile web is to cache route polylines for requested O/D pairs, plus occasional prefetch of popular legs.

async function getRoute(src, dst) {
  const key = btoa(`${src.lat},${src.lng}|${dst.lat},${dst.lng}`);
  const cached = await idbGet('routes', key);
  if (cached && new Date(cached.meta.expires) > new Date()) return cached;

  // Online - ask server for optimized route (server may run OSRM/Valhalla, edge function)
  try {
    const resp = await fetch(`/api/route?src=${src.lat},${src.lng}&dst=${dst.lat},${dst.lng}`);
    const json = await resp.json();
    await idbPut('routes', { key, polyline: json.polyline, meta: { distance: json.distance, duration: json.duration, expires: json.expires } });
    return json;
  } catch (e) {
    // offline fallback: return best cached or a straight line polyline
    if (cached) return cached;
    return { polyline: [ [src.lng, src.lat], [dst.lng, dst.lat] ], distance: null, duration: null };
  }
}

Prefetch strategy

  • Prefetch route legs when user taps a destination or when the route is likely to be used (e.g., delivery run).
  • Use cheap heuristics: cache legs under a size cap, evict LRU items from IndexedDB (store lastAccessed). For guidance on choosing between micro-apps and buying patterns that affect prefetch and runtime, see Choosing Between Buying and Building Micro Apps.

Map control integration — examples

Below are short, copy‑pasteable patterns for showing cached incidents and routes on a MapLibre GL map. Adapt to Leaflet or other libs.

React (MapLibre) map control

// OfflineMapControl.jsx (React)
import { useEffect, useRef } from 'react';
import maplibregl from 'maplibre-gl';

export default function OfflineMapControl({ center, zoom }) {
  const mapRef = useRef();
  useEffect(() => {
    const map = new maplibregl.Map({ container: mapRef.current, style: '/styles/basic.json', center, zoom });
    map.on('load', async () => {
      // load cached incidents from IndexedDB
      const incidents = await idbAll('incidents');
      map.addSource('incidents', { type: 'geojson', data: { type: 'FeatureCollection', features: incidents.filter(i=>!i._deleted).map(i => ({ type: 'Feature', geometry: i.geom, properties: i })) } });
      map.addLayer({ id: 'incidents-circle', type: 'circle', source: 'incidents', paint: { 'circle-radius': 6, 'circle-color': ['interpolate', ['linear'], ['get', 'severity'], 1, '#2ECC40', 3, '#FF851B', 5, '#FF4136'] } });

      // load cached routes if any
      const routes = await idbAll('routes');
      if (routes.length) {
        map.addSource('routes', { type: 'geojson', data: { type: 'FeatureCollection', features: routes.map(r => ({ type: 'Feature', geometry: { type: 'LineString', coordinates: r.polyline }, properties: r.meta })) } });
        map.addLayer({ id: 'route-line', type: 'line', source: 'routes', paint: { 'line-width': 4, 'line-color': '#0074D9' } });
      }
    });
    return () => map.remove();
  }, []);
  return (
); }

Vue 3 (Composition API) example

// OfflineLayer.vue
<script setup>
import { onMounted, ref } from 'vue';
import maplibregl from 'maplibre-gl';
const el = ref(null);
onMounted(async () => {
  const map = new maplibregl.Map({ container: el.value, style: '/styles/basic.json', center: [0,0], zoom: 12 });
  map.on('load', async () => {
    const incidents = await idbAll('incidents');
    map.addSource('incidents', { type: 'geojson', data: { type: 'FeatureCollection', features: incidents.filter(i=>!i._deleted).map(i => ({ type: 'Feature', geometry: i.geom })) } });
    map.addLayer({ id: 'incidents', type: 'symbol', source: 'incidents', layout: { 'icon-image': 'marker-15' } });
  });
});
</script>

<template>
  <div ref="el" style="height:100%" />
</template>

Vanilla JS + Web Component

// offline-map.js
class OfflineMap extends HTMLElement {
  connectedCallback() {
    this.style.display = 'block';
    this.style.height = this.getAttribute('height') || '400px';
    const div = document.createElement('div');
    div.style.height = '100%';
    this.appendChild(div);
    import('https://unpkg.com/maplibre-gl/dist/maplibre-gl.js').then(({ default: maplibregl }) => {
      const map = new maplibregl.Map({ container: div, style: '/styles/basic.json', center: [0,0], zoom: 13 });
      map.on('load', async () => {
        const incidents = await idbAll('incidents');
        map.addSource('incidents', { type: 'geojson', data: { type: 'FeatureCollection', features: incidents.filter(i=>!i._deleted).map(i => ({ type: 'Feature', geometry: i.geom })) } });
        map.addLayer({ id: 'incidents', type: 'circle', source: 'incidents' });
      });
    });
  }
}
customElements.define('offline-map', OfflineMap);

Offline UX patterns (must shipping items)

  • Offline indicator: show last sync time, and whether data is stale.
  • Optimistic route booking: allow users to confirm navigation even when route is cached and may be stale.
  • Merge conflict policy: lastUpdated wins for incidents; surface a reconciliation view for manual verification on server if necessary.
  • Graceful degradation: when routing data is missing, show approximate straight‑line guidance and warn about accuracy.
Best practice: prefer server‑computed routes cached locally instead of running full graph routing on mobile browsers — it's smaller, faster, and safer for low‑power devices.

Data validation, security, and licensing

  • Tile and routing licensing: ensure your vector tiles and routing engine allow offline caching. Many providers require explicit offline licenses.
  • Integrity: use Subresource Integrity or signed route payloads for high‑stakes apps (logistics, safety). See approaches in Edge‑First Directories for integrity and resilience ideas.
  • Privacy: incident feeds from users should be sanitized and rate‑limited to avoid deanonymization.
  • Storage quotas: request persistent storage (navigator.storage.persist()) to reduce eviction risk — check user consent flows. For operational patterns around storage and migration see Multi‑Cloud Migration Playbook.

Performance and eviction

IndexedDB and Cache Storage are not infinite. Implement an LRU eviction policy in your meta store and cap vector tile caches by zoom ranges and bounding boxes.

// simple LRU eviction hook: increment access counter on get; periodically remove lowest scored
async function recordRouteAccess(key) {
  const r = await idbGet('routes', key);
  r.lastAccess = new Date().toISOString();
  await idbPut('routes', r);
}

async function evictOldRoutes(maxEntries=200) {
  const all = await idbAll('routes');
  if (all.length <= maxEntries) return;
  all.sort((a,b) => new Date(a.lastAccess || 0) - new Date(b.lastAccess || 0));
  const toDelete = all.slice(0, all.length - maxEntries);
  for (const r of toDelete) await idbPut('routes', { key: r.key, _deleted: true });
}

Advanced: realtime incident stream and WebTransport (2025+)

If you need realtime incident updates, consider a hybrid model in 2026: a delta REST endpoint for periodic sync (works everywhere) and a WebTransport/HTTP/3 push stream for low‑latency updates in capable browsers. Use the stream to push short messages (new incident ids, urgent clears) and still apply full deltas from the canonical REST endpoint to ensure eventual consistency. See trends on On‑Device AI for Web Apps and how low‑latency channels change API design.

Testing and benchmarking

  • Simulate offline in DevTools and test route fallbacks and merge behavior.
  • Measure route retrieval latency from IndexedDB vs network; aim for under 200ms for cached legs on typical devices.
  • Load test your delta endpoint; incident feeds can be chatty — provide per‑area subscriptions and rate limits.

Checklist before shipping

  1. Service worker registered and asserts cache sizes
  2. Persistent storage requested and documented for users
  3. Delta sync implemented with sequence numbers and idempotent merge
  4. Route prefetch heuristics for common flows (driver routes, delivery legs)
  5. Eviction policy and clear UX for stale data
  6. Security review for tile and route licensing

Actionable takeaways

  • Implement a simple delta protocol for incidents — sequence numbers + idempotent upserts is reliable and efficient.
  • Cache server‑computed routes in IndexedDB rather than shipping full graph routing to the browser.
  • Use stale‑while‑revalidate in service workers for tiles and route requests to keep UX snappy.
  • Design UX for offline: show staleness, allow optimistic flows, and surface reconciliation options.
  • Plan storage and licensing: request persistent storage and confirm offline cache rights for tiles and routing data.

Predictions for the next 3 years (2026–2029)

Expect more web platform primitives to mature: broader Periodic Background Sync support, standardized background fetch for large tile downloads, and affordable WebTransport streams for low‑latency feeds. Binary release pipelines and edge delivery will continue to make per‑user precomputed route fragments cheap, enabling true offline navigation experiences in web apps at scale. For UI and auth implications, see recent writing on lightweight auth UIs and event‑driven microfrontends for HTML‑first sites.

Final notes

Offline navigation on the mobile web is no longer aspirational — it's practical. The combination of service workers, IndexedDB, and compact delta sync contracts gives you the building blocks for robust, user‑friendly map controls that handle routes and incident feeds like Waze, but with the discoverability and reach of the web.

Call to action

Ready to implement offline map controls in your app? Start with the service worker + delta endpoint stub above and run an internal pilot targeting the worst network areas your users experience. If you want a vetted production starter kit (service worker, IndexedDB layer, prefetch heuristics, and MapLibre integration) optimized for mobile fleets, contact our team or download the starter repo linked in the editor's notes.

Advertisement

Related Topics

#tutorial#maps#performance
j

javascripts

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-04T02:54:32.711Z