Offline-First Development Strategies

Module 25: Frontend Frameworks & State Management

Understanding the Offline-First Philosophy

Offline-first is not just a technical approach but a design philosophy that treats offline functionality as a core feature rather than an error condition. It acknowledges that network connectivity is inconsistent and designs applications to work seamlessly regardless of connection status.

flowchart TD A[Traditional Approach] --> B{Network Available?} B -->|Yes| C[Application Works] B -->|No| D[Error Message] E[Offline-First Approach] --> F[Application Works] F --> G{Network Available?} G -->|Yes| H[Sync & Enhance] G -->|No| I[Continue with Cached Data] style E fill:#bbf,stroke:#333,stroke-width:2px style F fill:#bbf,stroke:#333,stroke-width:2px style I fill:#bbf,stroke:#333,stroke-width:2px

The Elevator Analogy

Imagine your application as an elevator in a building:

Key Principles

Client-Side Storage Technologies

A successful offline-first strategy requires effective client-side storage. Here are the main technologies available:

Cache API

Used by service workers to store HTTP responses. Great for static assets and API responses.


// Store in cache
caches.open('my-cache').then(cache => {
  cache.put('/api/data', new Response(JSON.stringify(data), {
    headers: { 'Content-Type': 'application/json' }
  }));
});

// Retrieve from cache
caches.open('my-cache').then(cache => {
  return cache.match('/api/data');
}).then(response => {
  if (response) {
    return response.json();
  }
});
            

IndexedDB

A powerful low-level API for client-side storage of significant amounts of structured data. Perfect for application data that needs to be queried or indexed.


// Open database
const request = indexedDB.open('MyDatabase', 1);

request.onupgradeneeded = event => {
  const db = event.target.result;
  const store = db.createObjectStore('customers', { keyPath: 'id' });
  store.createIndex('name', 'name', { unique: false });
  store.createIndex('email', 'email', { unique: true });
};

// Store data
request.onsuccess = event => {
  const db = event.target.result;
  const transaction = db.transaction(['customers'], 'readwrite');
  const store = transaction.objectStore('customers');
  
  store.add({
    id: 1,
    name: 'John Doe',
    email: 'john@example.com',
    orders: [...]
  });
  
  transaction.oncomplete = () => {
    console.log('Transaction completed');
  };
};

// Query data
function getCustomerByEmail(email) {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open('MyDatabase', 1);
    
    request.onsuccess = event => {
      const db = event.target.result;
      const transaction = db.transaction(['customers'], 'readonly');
      const store = transaction.objectStore('customers');
      const index = store.index('email');
      
      const query = index.get(email);
      
      query.onsuccess = () => {
        resolve(query.result);
      };
      
      query.onerror = () => {
        reject(query.error);
      };
    };
  });
}
            

LocalStorage / SessionStorage

Simple key-value storage with a synchronous API. Limited to storing strings. Good for small amounts of data and settings.


// Store data
localStorage.setItem('user_preferences', JSON.stringify({
  theme: 'dark',
  fontSize: 'medium'
}));

// Retrieve data
const preferences = JSON.parse(localStorage.getItem('user_preferences'));
            

WebSQL (Deprecated)

A database API that provides SQL-based access to a database. Being phased out in favor of IndexedDB.

Comparison Chart

Storage Technology Capacity API Type Data Types Best For
Cache API Large (~50-60% of disk) Asynchronous Response objects Network responses, assets
IndexedDB Large (~50-60% of disk) Asynchronous Almost any JavaScript type Application data, complex objects
LocalStorage Small (~5MB) Synchronous Strings only Settings, small data items
SessionStorage Small (~5MB) Synchronous Strings only Temporary session data

Data Synchronization Patterns

When building offline-first applications, you need strategies for keeping local and remote data in sync:

Optimistic UI Updates

Update the UI immediately on user action, before the server confirms the change.

sequenceDiagram participant User participant UI participant LocalDB participant Server User->>UI: Takes action UI->>LocalDB: Update local data UI->>User: Show success UI UI->>Server: Send request alt Success Server->>UI: Confirm success else Failure Server->>UI: Return error UI->>LocalDB: Revert change UI->>User: Show error + revert UI end

// Example: Adding a todo item with optimistic UI
function addTodo(todoText) {
  // Generate a temporary ID
  const tempId = 'temp_' + Date.now();
  
  // Create the todo object
  const todo = {
    id: tempId,
    text: todoText,
    completed: false,
    createdAt: new Date().toISOString(),
    pending: true // Flag indicating it's not yet synced
  };
  
  // Update UI immediately
  displayTodo(todo);
  
  // Save to local storage
  return saveToIndexedDB(todo)
    .then(() => {
      // Try to sync with server
      if (navigator.onLine) {
        return syncTodo(todo);
      } else {
        // Register for background sync
        return registerForBackgroundSync('todos-sync');
      }
    })
    .catch(error => {
      // Handle error, possibly remove from UI
      removeTodoFromUI(tempId);
      showError('Failed to save todo');
      throw error;
    });
}

function syncTodo(todo) {
  return fetch('/api/todos', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify(todo)
  })
  .then(response => response.json())
  .then(serverTodo => {
    // Update the local record with the server-generated ID
    return updateLocalTodo(todo.id, {
      id: serverTodo.id,
      pending: false
    }).then(() => {
      // Update the UI with the new ID
      updateTodoInUI(todo.id, serverTodo.id);
      return serverTodo;
    });
  });
}
            

Queue-Based Synchronization

Maintain a queue of changes that need to be synced to the server.

flowchart TD A[User Action] --> B[Add to Local DB] B --> C[Add to Sync Queue] C --> D{Online?} D -->|Yes| E[Process Queue] D -->|No| F[Wait for Connectivity] F --> G[Online Event] --> E E --> H[Success] --> I[Remove from Queue] E --> J[Failure] --> K[Retry Policy] K --> L[Max Retries?] L -->|No| M[Increase Backoff] --> E L -->|Yes| N[Move to Failed Queue / Alert User]

// Define a sync queue in IndexedDB
function createSyncQueueStore(db) {
  if (!db.objectStoreNames.contains('syncQueue')) {
    const store = db.createObjectStore('syncQueue', { keyPath: 'id' });
    store.createIndex('status', 'status', { unique: false });
    store.createIndex('timestamp', 'timestamp', { unique: false });
  }
}

// Add an operation to the sync queue
function addToSyncQueue(operation) {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open('MyOfflineDB', 1);
    
    request.onsuccess = event => {
      const db = event.target.result;
      const transaction = db.transaction(['syncQueue'], 'readwrite');
      const store = transaction.objectStore('syncQueue');
      
      const queueItem = {
        id: Date.now().toString(),
        operation: operation,
        status: 'pending',
        timestamp: Date.now(),
        retries: 0
      };
      
      const addRequest = store.add(queueItem);
      
      addRequest.onsuccess = () => {
        // If we're online, process the queue immediately
        if (navigator.onLine) {
          processQueue();
        }
        resolve();
      };
      
      addRequest.onerror = () => {
        reject(addRequest.error);
      };
    };
  });
}

// Process the sync queue
function processQueue() {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open('MyOfflineDB', 1);
    
    request.onsuccess = event => {
      const db = event.target.result;
      const transaction = db.transaction(['syncQueue'], 'readwrite');
      const store = transaction.objectStore('syncQueue');
      const index = store.index('status');
      
      const getPendingRequest = index.getAll('pending');
      
      getPendingRequest.onsuccess = () => {
        const pendingItems = getPendingRequest.result;
        
        if (pendingItems.length === 0) {
          resolve();
          return;
        }
        
        // Process each item in the queue
        const processPromises = pendingItems.map(item => {
          return processQueueItem(item, store);
        });
        
        Promise.all(processPromises)
          .then(resolve)
          .catch(reject);
      };
    };
  });
}

// Process a single queue item
function processQueueItem(item, store) {
  const { operation } = item;
  
  return fetch(operation.url, {
    method: operation.method,
    headers: operation.headers,
    body: operation.body
  })
  .then(response => {
    if (!response.ok) {
      throw new Error(`Request failed with status ${response.status}`);
    }
    return response.json();
  })
  .then(data => {
    // Success - remove from queue
    store.delete(item.id);
    return data;
  })
  .catch(error => {
    // Update retry count and status
    item.retries += 1;
    
    if (item.retries >= 5) {
      item.status = 'failed';
    } else {
      item.status = 'pending';
      // Exponential backoff
      const backoffTime = Math.pow(2, item.retries) * 1000;
      item.nextRetry = Date.now() + backoffTime;
    }
    
    store.put(item);
    throw error;
  });
}

// Listen for online events to process the queue
window.addEventListener('online', () => {
  processQueue().catch(error => {
    console.error('Error processing sync queue:', error);
  });
});
            

Conflict Resolution Strategies

When local and remote data diverge, you need strategies to resolve conflicts.


// Example: Last Write Wins conflict resolution
function syncItem(item) {
  return fetch(`/api/items/${item.id}`, {
    headers: {
      'If-Modified-Since': new Date(item.lastSynced).toUTCString()
    }
  })
  .then(response => {
    if (response.status === 304) {
      // Server hasn't changed, safe to upload our changes
      return uploadItem(item);
    } else {
      // Server has newer version, need to resolve conflict
      return response.json().then(serverItem => {
        if (new Date(serverItem.updatedAt) > new Date(item.updatedAt)) {
          // Server version is newer
          return updateLocalItem(serverItem);
        } else {
          // Our version is newer
          return uploadItem(item);
        }
      });
    }
  });
}
            

Application Shell Architecture

A design pattern for offline-first applications where the core "shell" of your app is cached separately from the content.

flowchart TD A[Application Shell] --> B[HTML Skeleton] A --> C[CSS Styles] A --> D[JavaScript] A --> E[Core Assets] F[Content] --> G[API Data] F --> H[User-Generated Content] F --> I[Dynamic Resources] J[User Opens App] --> K[Load Cached Shell] K --> L[Shell Renders Immediately] L --> M[Request Dynamic Content] M --> N[Display Content When Available] style A fill:#bbf,stroke:#333,stroke-width:2px style J fill:#f9f,stroke:#333,stroke-width:2px style K fill:#f9f,stroke:#333,stroke-width:2px style L fill:#f9f,stroke:#333,stroke-width:2px

Implementing the App Shell


// Service worker install event - cache the app shell
self.addEventListener('install', event => {
  event.waitUntil(
    caches.open('app-shell-v1').then(cache => {
      return cache.addAll([
        '/',
        '/styles/main.css',
        '/scripts/app.js',
        '/scripts/router.js',
        '/scripts/ui-components.js',
        '/images/logo.svg',
        '/images/icons/menu.svg',
        '/images/icons/search.svg',
        '/fonts/roboto.woff2',
        '/offline.html'
      ]);
    })
  );
});

// Service worker fetch event - serve shell from cache, content from network
self.addEventListener('fetch', event => {
  const url = new URL(event.request.url);
  
  // App shell assets - cache first strategy
  if (
    url.pathname.startsWith('/styles/') ||
    url.pathname.startsWith('/scripts/') ||
    url.pathname.startsWith('/images/') ||
    url.pathname.startsWith('/fonts/') ||
    url.pathname === '/'
  ) {
    event.respondWith(
      caches.match(event.request).then(response => {
        return response || fetch(event.request).then(fetchResponse => {
          // Add the network response to cache for next time
          return caches.open('app-shell-v1').then(cache => {
            cache.put(event.request, fetchResponse.clone());
            return fetchResponse;
          });
        });
      })
    );
  }
  
  // API requests - network first with fallback to cache
  else if (url.pathname.startsWith('/api/')) {
    event.respondWith(
      fetch(event.request)
        .then(response => {
          // Clone the response to store in cache
          const responseToCache = response.clone();
          
          caches.open('api-cache-v1').then(cache => {
            cache.put(event.request, responseToCache);
          });
          
          return response;
        })
        .catch(() => {
          // If network fails, try to return from cache
          return caches.match(event.request).then(cachedResponse => {
            if (cachedResponse) {
              return cachedResponse;
            }
            
            // If we don't have a cached response for this specific API,
            // return a generic offline response for APIs
            return new Response(JSON.stringify({
              error: 'You are offline',
              offline: true,
              timestamp: Date.now()
            }), {
              headers: { 'Content-Type': 'application/json' }
            });
          });
        })
    );
  }
  
  // Other requests - network first
  else {
    event.respondWith(
      fetch(event.request)
        .catch(() => {
          return caches.match(event.request).then(cachedResponse => {
            if (cachedResponse) {
              return cachedResponse;
            }
            
            // If it's a navigation request, show the offline page
            if (event.request.mode === 'navigate') {
              return caches.match('/offline.html');
            }
            
            // For other requests, just return a simple error
            return new Response('Offline', {
              status: 503,
              statusText: 'Service Unavailable'
            });
          });
        })
    );
  }
});
            

Network Status Detection and Management

Detecting Network Status

Modern browsers provide APIs to detect and respond to network status changes:


// Check current online status
const isOnline = navigator.onLine;

// Listen for online/offline events
window.addEventListener('online', () => {
  console.log('Application is online');
  document.body.classList.remove('offline');
  syncData();
});

window.addEventListener('offline', () => {
  console.log('Application is offline');
  document.body.classList.add('offline');
  showOfflineNotification();
});
            

Network Quality Detection

For more advanced applications, you can detect network quality using the Network Information API:


// Check if Network Information API is available
if ('connection' in navigator) {
  const connection = navigator.connection;
  
  // Log connection info
  console.log('Connection type:', connection.type);
  console.log('Effective connection type:', connection.effectiveType);
  console.log('Downlink:', connection.downlink, 'Mbps');
  console.log('Round-trip time:', connection.rtt, 'ms');
  console.log('Save data mode:', connection.saveData);
  
  // React to connection changes
  connection.addEventListener('change', () => {
    console.log('Connection changed:', connection.effectiveType);
    
    // Adjust quality based on connection
    if (connection.effectiveType === '4g') {
      loadHighQualityAssets();
    } else {
      loadLowQualityAssets();
    }
    
    // If in save-data mode, be extra conservative
    if (connection.saveData) {
      minimizeDataUsage();
    }
  });
}
            

UI Considerations for Network Status

Always communicate network status changes to users in a non-intrusive way:


// Define UI elements for network status
const statusIndicator = document.querySelector('.network-status-indicator');
const syncButton = document.querySelector('.manual-sync-button');
const offlineBanner = document.querySelector('.offline-banner');

// Update UI based on network status
function updateNetworkUI(isOnline) {
  if (isOnline) {
    statusIndicator.classList.remove('offline');
    statusIndicator.classList.add('online');
    statusIndicator.setAttribute('title', 'Online');
    
    offlineBanner.classList.add('hidden');
    
    // Only show sync button if we have pending changes
    checkPendingChanges().then(hasPending => {
      syncButton.classList.toggle('hidden', !hasPending);
    });
  } else {
    statusIndicator.classList.remove('online');
    statusIndicator.classList.add('offline');
    statusIndicator.setAttribute('title', 'Offline');
    
    offlineBanner.classList.remove('hidden');
    syncButton.classList.add('hidden');
  }
}

// Initial setup
updateNetworkUI(navigator.onLine);

// Listen for changes
window.addEventListener('online', () => updateNetworkUI(true));
window.addEventListener('offline', () => updateNetworkUI(false));

// Handle manual sync
syncButton.addEventListener('click', () => {
  syncButton.disabled = true;
  syncButton.textContent = 'Syncing...';
  
  syncData()
    .then(() => {
      syncButton.textContent = 'Synced!';
      setTimeout(() => {
        syncButton.textContent = 'Sync';
        syncButton.disabled = false;
        
        // Hide if no more pending changes
        checkPendingChanges().then(hasPending => {
          syncButton.classList.toggle('hidden', !hasPending);
        });
      }, 2000);
    })
    .catch(error => {
      syncButton.textContent = 'Sync Failed';
      console.error('Sync error:', error);
      setTimeout(() => {
        syncButton.textContent = 'Try Again';
        syncButton.disabled = false;
      }, 2000);
    });
});
            

Real-World Examples

Google Maps Offline Mode

Google Maps allows users to download map regions for offline use:

Spotify Offline Mode

Spotify allows premium users to download playlists and albums for offline listening:

Notion Offline Mode

Notion provides full editing capabilities while offline:

Testing Offline-First Applications

Manual Testing Techniques

Automated Testing


// Example: Testing offline behavior with Jest
test('should store data locally when offline', async () => {
  // Mock fetch to simulate being offline
  global.fetch = jest.fn().mockImplementation(() => {
    throw new Error('Network error');
  });
  
  // Set navigator.onLine to false
  Object.defineProperty(navigator, 'onLine', {
    writable: true,
    value: false
  });
  
  // Mock IndexedDB
  const mockIDB = {
    add: jest.fn().mockResolvedValue(true)
  };
  
  // Call the function to test
  await saveData({ id: 1, name: 'Test Item' });
  
  // Verify data was stored locally
  expect(mockIDB.add).toHaveBeenCalledWith({
    id: 1,
    name: 'Test Item',
    pendingSync: true
  });
  
  // Verify fetch was not called
  expect(fetch).not.toHaveBeenCalled();
});
            

Common Pitfalls and Solutions

Storage Limits

Browsers limit how much data you can store. To handle this:


// Request storage estimate
if (navigator.storage && navigator.storage.estimate) {
  navigator.storage.estimate().then(estimate => {
    const totalBytes = estimate.quota;
    const usedBytes = estimate.usage;
    const percentUsed = (usedBytes / totalBytes) * 100;
    
    console.log(`Using ${usedBytes} out of ${totalBytes} bytes (${percentUsed.toFixed(2)}%)`);
    
    // Alert user if storage is running low
    if (percentUsed > 80) {
      showStorageWarning();
    }
  });
}
            

Data Synchronization Failures

Sync can fail for various reasons. To handle this:

Cached Data Freshness

Offline data can become stale. To address this:


// Check if cache is fresh
function isCacheFresh(cachedData, maxAgeMs = 3600000) { // Default 1 hour
  if (!cachedData || !cachedData.timestamp) {
    return false;
  }
  
  const age = Date.now() - cachedData.timestamp;
  return age < maxAgeMs;
}

// Fetch with freshness check
async function fetchData(url, maxAgeMs) {
  try {
    // Try to get from cache first
    const cachedData = await getCachedData(url);
    
    if (cachedData) {
      // If cache is fresh, use it immediately
      if (isCacheFresh(cachedData, maxAgeMs)) {
        // But still update in background if online
        if (navigator.onLine) {
          fetchAndUpdateCache(url).catch(console.error);
        }
        return cachedData.data;
      }
    }
    
    // If no cache or stale cache, try network
    if (navigator.onLine) {
      return await fetchAndUpdateCache(url);
    }
    
    // If offline and we have cached data (even if stale), use it
    if (cachedData) {
      return cachedData.data;
    }
    
    // No cached data and offline
    throw new Error('No data available offline');
  } catch (error) {
    console.error('Error fetching data:', error);
    throw error;
  }
}
            

Best Practices

Design Principles

Technical Implementation

User Experience

Practice Activities

Activity 1: Offline-First Todo App

Create a simple todo application that works completely offline using IndexedDB for storage and implements background sync when the user comes back online.

Activity 2: Offline Content Reader

Build a content reader (like a blog or news reader) that caches articles for offline reading and tracks reading progress locally.

Activity 3: Network-Aware UI

Enhance an existing web app by adding network status detection and appropriate UI feedback. Implement different behaviors based on connection quality.

Activity 4: Conflict Resolution

Create a collaborative note-taking app where multiple users can edit the same document. Implement a conflict resolution strategy for when offline changes conflict.

Additional Resources