ESC
Type to search guides, tutorials, and reference documentation.
Verified by Garnet Grid

Progressive Web Apps: Building Offline-Capable Web Applications

Transform web applications into installable, offline-capable experiences with Service Workers, Web App Manifests, and caching strategies. Covers cache patterns, background sync, push notifications, and the performance benefits that make PWAs competitive with native apps.

A Progressive Web App bridges the gap between a website and a native application. It loads instantly on repeat visits because assets are cached locally. It works offline because a service worker intercepts network requests and serves cached responses. It is installable on the home screen because a web app manifest tells the OS how to present it.

The result is a web application that feels native — without the App Store.


The PWA Foundation

Three technologies make a web app “progressive”:

1. Web App Manifest

A JSON file that tells the browser how to install and display the app:

{
  "name": "Order Dashboard",
  "short_name": "Orders",
  "start_url": "/dashboard",
  "display": "standalone",
  "background_color": "#1a1a2e",
  "theme_color": "#e94560",
  "icons": [
    { "src": "/icons/192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icons/512.png", "sizes": "512x512", "type": "image/png" }
  ]
}
<link rel="manifest" href="/manifest.json">

2. Service Worker

A JavaScript file that runs in a separate thread and intercepts network requests:

// sw.js
const CACHE_NAME = 'v1.2.0';
const PRECACHE_URLS = [
  '/',
  '/dashboard',
  '/styles/main.css',
  '/scripts/app.js',
  '/icons/192.png',
];

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

self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((keys) =>
      Promise.all(
        keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))
      )
    )
  );
});

3. HTTPS

Service workers only run on HTTPS (or localhost for development). This is a hard requirement — no exceptions.


Caching Strategies

The service worker’s fetch handler determines what gets cached and how:

Cache First (Static Assets)

Serve from cache immediately. Fall back to network only if not cached:

self.addEventListener('fetch', (event) => {
  if (isStaticAsset(event.request.url)) {
    event.respondWith(
      caches.match(event.request).then((cached) => {
        return cached || fetch(event.request).then((response) => {
          const clone = response.clone();
          caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
          return response;
        });
      })
    );
  }
});

Best for: CSS, JavaScript bundles, fonts, images — anything with a versioned filename.

Network First (API Data)

Try the network. Fall back to cache if offline:

if (isApiRequest(event.request.url)) {
  event.respondWith(
    fetch(event.request)
      .then((response) => {
        const clone = response.clone();
        caches.open('api-cache').then((cache) => cache.put(event.request, clone));
        return response;
      })
      .catch(() => caches.match(event.request))
  );
}

Best for: API responses, user-specific data, search results.

Stale While Revalidate (Content)

Serve from cache immediately, then update the cache in the background:

if (isContentPage(event.request.url)) {
  event.respondWith(
    caches.match(event.request).then((cached) => {
      const fetchPromise = fetch(event.request).then((response) => {
        caches.open(CACHE_NAME).then((cache) => cache.put(event.request, response.clone()));
        return response;
      });
      return cached || fetchPromise;
    })
  );
}

Best for: Blog posts, product pages, any content that changes occasionally but is fine to show slightly stale.


Offline Experience Design

Caching strategies handle technical offline capability. UX design handles the human experience.

Offline Indicators

Always communicate connectivity state:

window.addEventListener('online', () => {
  document.getElementById('offline-banner').hidden = true;
  syncPendingData();
});

window.addEventListener('offline', () => {
  document.getElementById('offline-banner').hidden = false;
});

Queuing Offline Actions

When a user performs an action offline (submitting a form, creating a record), queue it and sync when connectivity returns:

async function submitOrder(orderData) {
  try {
    const response = await fetch('/api/orders', {
      method: 'POST',
      body: JSON.stringify(orderData),
    });
    return response.json();
  } catch (error) {
    // Queue for background sync
    await saveToIndexedDB('pending-orders', orderData);
    showNotification('Order saved. Will submit when you are back online.');
  }
}

Background Sync

The Background Sync API lets the service worker retry failed requests when connectivity returns:

// In the app
navigator.serviceWorker.ready.then((registration) => {
  registration.sync.register('sync-orders');
});

// In the service worker
self.addEventListener('sync', (event) => {
  if (event.tag === 'sync-orders') {
    event.waitUntil(syncPendingOrders());
  }
});

async function syncPendingOrders() {
  const pending = await getFromIndexedDB('pending-orders');
  for (const order of pending) {
    await fetch('/api/orders', { method: 'POST', body: JSON.stringify(order) });
    await removeFromIndexedDB('pending-orders', order.id);
  }
}

Performance Benefits

PWAs deliver measurable performance improvements:

Instant Loading

After the first visit, all static assets are served from the local cache — zero network latency:

First visit:  Load time ~2.5s (network)
Repeat visit: Load time ~0.3s (cache)

Reduced Data Usage

Cached assets are not re-downloaded. On mobile networks where bandwidth is metered, this translates directly to cost savings for users.

Reliable on Flaky Networks

Service workers intercept requests before they hit the network. On slow connections, cached responses arrive instantly while the network request runs in the background.


Push Notifications

Push notifications re-engage users even when the browser is closed:

// Request permission
const permission = await Notification.requestPermission();

// Subscribe to push
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
  userVisibleOnly: true,
  applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
});

// Send subscription to server
await fetch('/api/push/subscribe', {
  method: 'POST',
  body: JSON.stringify(subscription),
});

Best practice: Request notification permission only after the user has engaged with the app — never on first load.


Anti-Patterns

Anti-PatternImpactFix
Caching everything indefinitelyStale content, storage bloatVersion caches, expire old versions
No offline fallback pageWhite screen when offlineCache a custom offline.html
Ignoring cache invalidationUsers see outdated appUpdate CACHE_NAME with each deploy
Permission prompt on first loadUsers deny and never see notificationsAsk after meaningful engagement
Service worker with no updatesBug fixes never reach usersImplement skip-waiting + clients.claim

PWAs are not about checking boxes for installability. They are about delivering a fast, reliable, engaging experience on any device with a browser — and that means thoughtful caching, graceful offline handling, and performance-first architecture.

Jakub Dimitri Rezayev
Jakub Dimitri Rezayev
Founder & Chief Architect • Garnet Grid Consulting

Jakub holds an M.S. in Customer Intelligence & Analytics and a B.S. in Finance & Computer Science from Pace University. With deep expertise spanning D365 F&O, Azure, Power BI, and AI/ML systems, he architects enterprise solutions that bridge legacy systems and modern technology — and has led multi-million dollar ERP implementations for Fortune 500 supply chains.

View Full Profile →