Progressive Web Apps: Offline-First Web Applications

Traditional web applications fail catastrophically when network connections drop. Users see error messages, lose unsaved work, and abandon tasks. Offline-first architecture flips this model:...

Key Insights

  • Offline-first architecture treats network connectivity as an enhancement rather than a requirement, dramatically improving user experience and application resilience
  • Service workers act as programmable network proxies that intercept requests and serve cached responses, enabling sophisticated caching strategies tailored to different resource types
  • Combining service workers, IndexedDB, and Background Sync API creates truly resilient applications that queue user actions offline and synchronize seamlessly when connectivity returns

Introduction to Offline-First Architecture

Traditional web applications fail catastrophically when network connections drop. Users see error messages, lose unsaved work, and abandon tasks. Offline-first architecture flips this model: applications work by default using local data and sync with servers when connectivity allows.

This isn’t just about handling airplane mode. Modern users experience spotty connectivity constantly—in elevators, subways, rural areas, or during network congestion. An offline-first approach treats the network as a progressive enhancement rather than a dependency.

The mindset shift is fundamental. Instead of asking “how do we handle offline scenarios?”, we ask “how do we build an application that works offline by default?” This means storing data locally first, queuing mutations, and synchronizing in the background. The result is applications that feel instant and never lose user data.

Service Workers: The Foundation of Offline Functionality

Service workers are JavaScript files that run separately from your web page, acting as programmable proxies between your application and the network. They intercept network requests and can serve responses from cache, the network, or generate them programmatically.

The service worker lifecycle has three key phases: registration, installation, and activation. During installation, you cache critical assets. During activation, you clean up old caches. Once active, the service worker intercepts fetch events for all pages in its scope.

// app.js - Register the service worker
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js')
    .then(registration => {
      console.log('Service Worker registered:', registration.scope);
    })
    .catch(error => {
      console.error('Service Worker registration failed:', error);
    });
}

// sw.js - Service worker installation
const CACHE_NAME = 'app-v1';
const STATIC_ASSETS = [
  '/',
  '/styles.css',
  '/app.js',
  '/offline.html'
];

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

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

Caching Strategies for Different Resource Types

Not all resources should be cached identically. Static assets benefit from aggressive caching, while API responses need freshness. Here are three essential strategies:

Cache First prioritizes cached responses, falling back to the network. Perfect for static assets that rarely change—CSS, JavaScript bundles, images, fonts.

// Cache First strategy for static assets
self.addEventListener('fetch', event => {
  const { request } = event;
  
  // Apply Cache First to static assets
  if (request.destination === 'style' || 
      request.destination === 'script' ||
      request.destination === 'image') {
    event.respondWith(
      caches.match(request)
        .then(cached => {
          if (cached) return cached;
          
          return fetch(request).then(response => {
            return caches.open(CACHE_NAME).then(cache => {
              cache.put(request, response.clone());
              return response;
            });
          });
        })
    );
  }
});

Network First tries the network, falling back to cache only when offline. Ideal for API calls where fresh data matters but offline access is still valuable.

// Network First strategy for API calls
self.addEventListener('fetch', event => {
  const { request } = event;
  
  if (request.url.includes('/api/')) {
    event.respondWith(
      fetch(request)
        .then(response => {
          // Cache successful responses
          if (response.ok) {
            const responseClone = response.clone();
            caches.open(CACHE_NAME).then(cache => {
              cache.put(request, responseClone);
            });
          }
          return response;
        })
        .catch(() => {
          // Network failed, try cache
          return caches.match(request)
            .then(cached => {
              if (cached) return cached;
              // Return offline fallback
              return caches.match('/offline.html');
            });
        })
    );
  }
});

Stale-While-Revalidate serves cached content immediately while fetching fresh content in the background. Great for content that updates occasionally but where stale data is acceptable temporarily.

IndexedDB for Offline Data Storage

LocalStorage is synchronous and limited to 5-10MB. For offline-first applications handling structured data, IndexedDB provides asynchronous access to hundreds of megabytes. Use a wrapper library like idb to avoid IndexedDB’s verbose API.

import { openDB } from 'idb';

// Initialize database
async function initDB() {
  return openDB('TodoDB', 1, {
    upgrade(db) {
      if (!db.objectStoreNames.contains('todos')) {
        const store = db.createObjectStore('todos', { 
          keyPath: 'id', 
          autoIncrement: true 
        });
        store.createIndex('synced', 'synced');
      }
    }
  });
}

// Save todo offline
async function saveTodo(todo) {
  const db = await initDB();
  const id = await db.add('todos', {
    ...todo,
    synced: false,
    createdAt: Date.now()
  });
  return id;
}

// Retrieve all todos
async function getTodos() {
  const db = await initDB();
  return db.getAll('todos');
}

// Get unsynced todos for background sync
async function getUnsyncedTodos() {
  const db = await initDB();
  const index = db.transaction('todos').store.index('synced');
  return index.getAll(false);
}

Use IndexedDB for user-generated content, offline queues, and any data that needs to persist across sessions. Reserve localStorage for simple key-value preferences.

Background Sync and Data Synchronization

The Background Sync API queues tasks when offline and executes them when connectivity returns—even if the user has closed your application. This ensures no data loss from offline interactions.

// app.js - Register a background sync
async function submitForm(formData) {
  // Save to IndexedDB immediately
  await saveTodo(formData);
  
  // Register background sync
  if ('serviceWorker' in navigator && 'sync' in ServiceWorkerRegistration.prototype) {
    const registration = await navigator.serviceWorker.ready;
    await registration.sync.register('sync-todos');
  } else {
    // Fallback: sync immediately if Background Sync not supported
    await syncTodos();
  }
}

// sw.js - Handle sync event
self.addEventListener('sync', event => {
  if (event.tag === 'sync-todos') {
    event.waitUntil(syncTodos());
  }
});

async function syncTodos() {
  const db = await openDB('TodoDB', 1);
  const unsynced = await db.getAllFromIndex('todos', 'synced', false);
  
  const syncPromises = unsynced.map(async todo => {
    try {
      const response = await fetch('/api/todos', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(todo)
      });
      
      if (response.ok) {
        // Mark as synced
        await db.put('todos', { ...todo, synced: true });
      }
    } catch (error) {
      console.error('Sync failed for todo:', todo.id);
      throw error; // Retry sync later
    }
  });
  
  return Promise.all(syncPromises);
}

Building a Complete Offline-First Todo App

Here’s how these pieces fit together in a minimal but complete implementation:

// todo-app.js
class OfflineTodoApp {
  constructor() {
    this.db = null;
    this.init();
  }
  
  async init() {
    this.db = await openDB('TodoDB', 1, {
      upgrade(db) {
        const store = db.createObjectStore('todos', { 
          keyPath: 'id', 
          autoIncrement: true 
        });
        store.createIndex('synced', 'synced');
      }
    });
    
    // Register service worker
    if ('serviceWorker' in navigator) {
      await navigator.serviceWorker.register('/sw.js');
    }
    
    // Listen for online/offline events
    window.addEventListener('online', () => this.handleOnline());
    window.addEventListener('offline', () => this.handleOffline());
    
    this.render();
  }
  
  async addTodo(text) {
    const todo = {
      text,
      completed: false,
      synced: false,
      createdAt: Date.now()
    };
    
    // Save locally first
    const id = await this.db.add('todos', todo);
    
    // Trigger background sync
    if ('serviceWorker' in navigator) {
      const registration = await navigator.serviceWorker.ready;
      await registration.sync.register('sync-todos');
    }
    
    this.render();
  }
  
  async render() {
    const todos = await this.db.getAll('todos');
    const unsyncedCount = todos.filter(t => !t.synced).length;
    
    // Update UI with offline indicator
    document.getElementById('sync-status').textContent = 
      unsyncedCount > 0 ? `${unsyncedCount} items pending sync` : 'All synced';
  }
  
  handleOnline() {
    document.body.classList.remove('offline');
    navigator.serviceWorker.ready.then(reg => 
      reg.sync.register('sync-todos')
    );
  }
  
  handleOffline() {
    document.body.classList.add('offline');
  }
}

const app = new OfflineTodoApp();

Testing and Debugging Offline Functionality

Chrome DevTools provides excellent tools for testing offline scenarios. Open DevTools, navigate to the Application tab, and use the Service Workers section to inspect registration, force updates, and simulate offline mode.

Testing checklist:

// Test offline functionality systematically:
// 1. Open DevTools → Application → Service Workers
// 2. Check "Offline" checkbox
// 3. Verify app still loads and functions
// 4. Add data while offline
// 5. Check Application → IndexedDB to verify local storage
// 6. Uncheck "Offline" to go back online
// 7. Verify background sync triggers
// 8. Check Network tab to confirm sync requests

// Common pitfalls to avoid:
// - Forgetting to update CACHE_NAME when changing assets
// - Caching API responses indefinitely
// - Not handling sync failures gracefully
// - Ignoring cache size limits (50MB+ may prompt user)

Use the Cache Storage viewer to inspect cached resources. Clear storage between tests to ensure clean states. Test on actual mobile devices with real network conditions—throttling in DevTools doesn’t perfectly simulate flaky connections.

Offline-first architecture requires upfront investment but delivers applications that feel professional and resilient. Users notice when apps work seamlessly regardless of connectivity, and that reliability builds trust and engagement.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.