Skip to main content

PWA + Firebase Offline Mode: Building Apps That Work Anywhere

· ChibiCart Team · 15 min read
ChibiCart PWA offline mode - Chibi character shopping with phone showing synced shopping list, demonstrating offline capability

TL;DR

Combining PWA service workers with Firebase's offline persistence creates apps that feel instant and work without internet. Service workers cache your app shell, Firebase caches your data, and users never see a loading spinner. In this guide, we'll show you exactly how ChibiCart achieves sub-second loads and seamless offline shopping—with real code you can steal.

The "Grocery Store Basement" Problem

We've all been there. You're in Costco, Target, or some massive warehouse store. You pull out your phone to check your shopping list and... nothing. No signal. You're three floors underground surrounded by steel shelving and concrete walls.

Your options with a typical app:

  • 😤 Stare at a loading spinner
  • 🚶 Walk to the store entrance for signal
  • 🤔 Try to remember what you needed
  • 📝 Screenshot your list next time (pro tip that shouldn't be necessary)

This is exactly why we built ChibiCart as an offline-first app. Your shopping list should be available the moment you need it—zero network required.

The Two-Layer Offline Strategy

ChibiCart's offline capability comes from two technologies working together:

LayerWhat It CachesTechnology
App ShellHTML, CSS, JS, images, fontsService Worker (Workbox)
User DataShopping lists, items, settingsFirebase Firestore

Together, they ensure that everything you need is already on your device before you lose signal.

Layer 1: Service Worker Caching (The App Shell)

Service workers are JavaScript files that sit between your app and the network. They can intercept requests and serve cached responses—even when offline.

What We Cache

// next.config.js - PWA configuration
const withPWA = require('next-pwa')({
  dest: 'public',
  register: true,
  skipWaiting: true,
  runtimeCaching: [
    // Static assets - cache first
    {
      urlPattern: /\.(js|css|woff2?)$/i,
      handler: 'CacheFirst',
      options: {
        cacheName: 'static-assets',
        expiration: { maxEntries: 100, maxAgeSeconds: 30 * 24 * 60 * 60 }
      }
    },
    // Images - cache first with fallback
    {
      urlPattern: /\.(png|jpg|jpeg|svg|gif|webp)$/i,
      handler: 'CacheFirst',
      options: {
        cacheName: 'images',
        expiration: { maxEntries: 200, maxAgeSeconds: 7 * 24 * 60 * 60 }
      }
    },
    // API calls - network first, fallback to cache
    {
      urlPattern: /\/api\//i,
      handler: 'NetworkFirst',
      options: {
        cacheName: 'api-cache',
        networkTimeoutSeconds: 10
      }
    }
  ]
});

Caching Strategies Explained

🏃 Cache First (Static Assets)

Check cache first, only hit network if not cached. Perfect for JS, CSS, fonts—things that rarely change.

🌐 Network First (API Calls)

Try network first, fall back to cache if offline. Good for data that should be fresh when possible.

🔄 Stale While Revalidate (Dynamic Content)

Serve cached version immediately, update cache in background. Best for content that can be slightly stale.

Real result: After your first visit, ChibiCart loads in under 500ms—even on slow 3G. The entire app shell is already on your device.

Layer 2: Firebase Firestore Offline Persistence

Service workers handle the app itself. But what about your actual data—your shopping lists, items, history? That's where Firebase Firestore shines.

Enable Offline Persistence

// lib/firebase.ts - Enable Firestore offline persistence
import { initializeFirestore, persistentLocalCache } from 'firebase/firestore';

const db = initializeFirestore(app, {
  localCache: persistentLocalCache({
    // Optional: configure cache size (default is 40MB)
    // cacheSizeBytes: 100 * 1024 * 1024 // 100MB
  })
});

// That's it! Firestore now automatically:
// ✅ Caches all reads locally
// ✅ Queues writes when offline
// ✅ Syncs when connection restored

With just these few lines, Firestore becomes your offline database. Every document you read is cached locally. Every write you make offline is queued and synced when you're back online.

How Offline Writes Work

When you add an item to your shopping list while offline:

  1. Firestore immediately updates the local cache
  2. Your UI reflects the change instantly
  3. The write is queued in IndexedDB
  4. When online, Firestore syncs automatically
  5. If there's a conflict, Firestore resolves it (last-write-wins by default)
Real scenario: You're in that Costco basement. You check off "milk" and add "eggs" to your list. Both actions happen instantly in the app. When you walk out and get signal, Firestore silently syncs—your partner sees the updates on their phone within seconds.

Handling Real-Time Sync with Shared Lists

ChibiCart supports shared shopping lists—multiple people editing the same list. This gets interesting with offline mode.

Real-Time Listeners That Work Offline

// Real-time listener with offline support
const unsubscribe = onSnapshot(
  collection(db, 'lists', listId, 'items'),
  { includeMetadataChanges: true },
  (snapshot) => {
    const items = snapshot.docs.map(doc => ({
      id: doc.id,
      ...doc.data(),
      // Track if data is from cache or server
      isFromCache: snapshot.metadata.fromCache
    }));
    
    setItems(items);
    
    // Optional: show sync status
    if (snapshot.metadata.hasPendingWrites) {
      showSyncIndicator('Syncing...');
    } else if (snapshot.metadata.fromCache) {
      showSyncIndicator('Offline');
    } else {
      hideSyncIndicator();
    }
  }
);

The includeMetadataChanges option lets you track whether data is from cache or the server. We use this to show a subtle "offline" indicator—but the app works identically either way.

Performance Wins: The Numbers

Here's what this architecture achieves for ChibiCart:

MetricFirst VisitRepeat VisitOffline
App Load Time~1.5s~400ms~300ms
Data Load Time~800ms~50ms (cache)~10ms
Interaction Ready~2s~500ms~400ms
Works Without Network

The key insight: Repeat visits are faster than first visits because everything is already cached. Offline is actually the fastest experience because there's zero network latency.

Best Practices We Learned

1. Optimistic UI Updates

Don't wait for the server. Update the UI immediately, sync in background.

// ❌ Bad: Wait for server
const addItem = async (item) => {
  await addDoc(collection(db, 'items'), item);
  // User waits for network round-trip
};

// ✅ Good: Optimistic update
const addItem = async (item) => {
  // UI updates immediately via Firestore's local cache
  addDoc(collection(db, 'items'), item);
  // Firestore handles sync automatically
};

2. Pre-cache Critical Routes

// sw.js - Pre-cache important pages
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open('app-shell').then((cache) => {
      return cache.addAll([
        '/',
        '/home',
        '/shopping-items',
        '/offline.html',  // Fallback page
        // Critical assets
        '/icons/icon-192x192.png',
        '/manifest.json'
      ]);
    })
  );
});

3. Handle Cache Size Wisely

Mobile devices have limited storage. Be strategic about what you cache.

// Limit cache entries and age
{
  urlPattern: /\.webp$/i,
  handler: 'CacheFirst',
  options: {
    cacheName: 'product-images',
    expiration: {
      maxEntries: 100,           // Keep max 100 images
      maxAgeSeconds: 7 * 24 * 60 * 60,  // Expire after 7 days
      purgeOnQuotaError: true    // Delete old entries if storage full
    }
  }
}

4. Show Offline Status (Subtly)

Users should know they're offline, but it shouldn't be alarming.

// useOnlineStatus hook
const useOnlineStatus = () => {
  const [isOnline, setIsOnline] = useState(navigator.onLine);
  
  useEffect(() => {
    const handleOnline = () => setIsOnline(true);
    const handleOffline = () => setIsOnline(false);
    
    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);
    
    return () => {
      window.removeEventListener('online', handleOnline);
      window.removeEventListener('offline', handleOffline);
    };
  }, []);
  
  return isOnline;
};
ChibiCart approach: We show a tiny cloud icon with a slash when offline. The app works exactly the same—we just let users know their changes will sync later.

Common Gotchas (And How We Solved Them)

⚠️ Gotcha #1: Authentication State

Firebase Auth tokens expire. If a user is offline for too long, they might need to re-authenticate when back online. Solution: Use onAuthStateChanged to detect auth issues and prompt gracefully.

⚠️ Gotcha #2: Large Offline Queues

If users make many changes offline, syncing can cause a burst of writes. Monitor your Firebase usage and consider batching writes for heavy users.

⚠️ Gotcha #3: Service Worker Updates

Old service workers can cache outdated code. Use skipWaiting() and notify users when a new version is available. We show a subtle "Update available" toast.

The User Experience Wins

All this technical work translates into tangible UX improvements:

  • Instant loads - App opens immediately, no spinners
  • Works in dead zones - Basements, elevators, rural areas
  • Saves data - Less network usage, better for limited plans
  • Battery efficient - Less radio usage when cached
  • Feels native - Responsive like a native app
  • Reliable sharing - Shared lists sync when possible

Quick Implementation Checklist

  • ☐ Add service worker with appropriate caching strategies
  • ☐ Enable Firestore offline persistence
  • ☐ Pre-cache critical routes and assets
  • ☐ Implement optimistic UI updates
  • ☐ Add online/offline status detection
  • ☐ Show sync status for user awareness
  • ☐ Handle service worker updates gracefully
  • ☐ Test with Chrome DevTools offline mode
  • ☐ Test with Lighthouse PWA audit

The Bottom Line

Offline-first isn't just about working without internet. It's about performance.

When your app loads from cache instead of the network, everything is faster. Users don't wait. They don't see loading spinners. They just... use your app.

PWA service workers + Firebase offline persistence is a powerful combination. For ChibiCart, it means:

  • Users never lose their shopping lists - even in Costco basements
  • The app feels instant - sub-second loads on repeat visits
  • Shared lists work seamlessly - sync when you can, work offline when you can't

That's what modern web apps should feel like. That's what users deserve.

Experience Offline-First Shopping

ChibiCart works anywhere—even in that basement aisle. Try it free.

Written by the ChibiCart Team
Building shopping lists that work everywhere 🛒📴✨