COMP 305: Object-Oriented Software Design

University of San Diego, Fall 2025

Offline First Architecture

Offline First is a design philosophy that treats network connectivity as a progressive enhancement rather than a requirement. Applications are built to work fully offline, with network access enhancing the experience when available. This approach recognizes that networks are unreliable, slow, or nonexistent in many real-world scenarios.

Core Philosophy

Traditional web applications assume network connectivity and fail when it's unavailable. Offline First inverts this assumption: design as if the network doesn't exist, then add network features as an enhancement.

// TRADITIONAL APPROACH - Network Required
async function loadPosts() {
    try {
        const response = await fetch('/api/posts');
        const posts = await response.json();
        displayPosts(posts);
    } catch (error) {
        // Fails completely when offline
        showError('Network error. Please check your connection.');
    }
}

// OFFLINE FIRST APPROACH - Local Data First
async function loadPosts() {
    // 1. Show local data immediately
    const cachedPosts = await localDB.posts.getAll();
    if (cachedPosts.length > 0) {
        displayPosts(cachedPosts);
    }

    // 2. Try to fetch fresh data in background
    try {
        const response = await fetch('/api/posts');
        const posts = await response.json();

        // 3. Update local cache
        await localDB.posts.bulkPut(posts);

        // 4. Update display with fresh data
        displayPosts(posts);
    } catch (error) {
        // App still works with cached data
        console.log('Working offline with cached data');
    }
}

Key Technologies

1. Service Workers

Service Workers are JavaScript that runs in the background, separate from web pages, enabling offline functionality by intercepting network requests and serving cached responses.

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

// service-worker.js
const CACHE_NAME = 'my-app-v1';
const ASSETS_TO_CACHE = [
    '/',
    '/index.html',
    '/styles.css',
    '/app.js',
    '/offline.html'
];

// Install - Cache essential assets
self.addEventListener('install', (event) => {
    event.waitUntil(
        caches.open(CACHE_NAME)
            .then(cache => {
                console.log('Caching essential assets');
                return cache.addAll(ASSETS_TO_CACHE);
            })
    );
});

// Activate - Clean up old caches
self.addEventListener('activate', (event) => {
    event.waitUntil(
        caches.keys().then(cacheNames => {
            return Promise.all(
                cacheNames.map(cacheName => {
                    if (cacheName !== CACHE_NAME) {
                        console.log('Deleting old cache:', cacheName);
                        return caches.delete(cacheName);
                    }
                })
            );
        })
    );
});

// Fetch - Serve from cache, fall back to network
self.addEventListener('fetch', (event) => {
    event.respondWith(
        caches.match(event.request)
            .then(cachedResponse => {
                // Return cached version or fetch from network
                return cachedResponse || fetch(event.request);
            })
            .catch(() => {
                // If both cache and network fail, show offline page
                return caches.match('/offline.html');
            })
    );
});

2. IndexedDB for Local Storage

IndexedDB is a low-level API for storing significant amounts of structured data, including files and blobs, for offline use.

// Simple IndexedDB wrapper
class LocalDatabase {
    constructor(dbName, version = 1) {
        this.dbName = dbName;
        this.version = version;
        this.db = null;
    }

    async open() {
        return new Promise((resolve, reject) => {
            const request = indexedDB.open(this.dbName, this.version);

            request.onerror = () => reject(request.error);
            request.onsuccess = () => {
                this.db = request.result;
                resolve(this.db);
            };

            request.onupgradeneeded = (event) => {
                const db = event.target.result;

                // Create object stores
                if (!db.objectStoreNames.contains('posts')) {
                    const postsStore = db.createObjectStore('posts', { keyPath: 'id' });
                    postsStore.createIndex('createdAt', 'createdAt', { unique: false });
                }

                if (!db.objectStoreNames.contains('drafts')) {
                    db.createObjectStore('drafts', { keyPath: 'id', autoIncrement: true });
                }
            };
        });
    }

    async getAll(storeName) {
        const tx = this.db.transaction(storeName, 'readonly');
        const store = tx.objectStore(storeName);
        return new Promise((resolve, reject) => {
            const request = store.getAll();
            request.onsuccess = () => resolve(request.result);
            request.onerror = () => reject(request.error);
        });
    }

    async get(storeName, id) {
        const tx = this.db.transaction(storeName, 'readonly');
        const store = tx.objectStore(storeName);
        return new Promise((resolve, reject) => {
            const request = store.get(id);
            request.onsuccess = () => resolve(request.result);
            request.onerror = () => reject(request.error);
        });
    }

    async put(storeName, data) {
        const tx = this.db.transaction(storeName, 'readwrite');
        const store = tx.objectStore(storeName);
        return new Promise((resolve, reject) => {
            const request = store.put(data);
            request.onsuccess = () => resolve(request.result);
            request.onerror = () => reject(request.error);
        });
    }

    async delete(storeName, id) {
        const tx = this.db.transaction(storeName, 'readwrite');
        const store = tx.objectStore(storeName);
        return new Promise((resolve, reject) => {
            const request = store.delete(id);
            request.onsuccess = () => resolve(request.result);
            request.onerror = () => reject(request.error);
        });
    }
}

// Usage
const db = new LocalDatabase('my-app-db');
await db.open();

// Store data locally
await db.put('posts', {
    id: 1,
    title: 'My Post',
    content: 'Content here',
    createdAt: new Date()
});

// Retrieve data
const post = await db.get('posts', 1);
const allPosts = await db.getAll('posts');

3. Cache API for HTTP Responses

The Cache API stores HTTP requests and responses for fast retrieval, enabling offline access to previously loaded resources.

// Cache Strategies

// 1. Cache First (good for static assets)
async function cacheFirst(request) {
    const cached = await caches.match(request);
    if (cached) {
        return cached;
    }

    const response = await fetch(request);
    const cache = await caches.open('my-cache-v1');
    cache.put(request, response.clone());
    return response;
}

// 2. Network First (good for dynamic content)
async function networkFirst(request) {
    try {
        const response = await fetch(request);
        const cache = await caches.open('my-cache-v1');
        cache.put(request, response.clone());
        return response;
    } catch (error) {
        const cached = await caches.match(request);
        if (cached) {
            return cached;
        }
        throw error;
    }
}

// 3. Stale While Revalidate (good for frequently updated content)
async function staleWhileRevalidate(request) {
    const cached = await caches.match(request);

    // Fetch fresh version in background
    const fetchPromise = fetch(request).then(response => {
        const cache = caches.open('my-cache-v1');
        cache.then(c => c.put(request, response.clone()));
        return response;
    });

    // Return cached immediately, or wait for network
    return cached || fetchPromise;
}

// 4. Cache Only (offline-only resources)
async function cacheOnly(request) {
    const cached = await caches.match(request);
    if (!cached) {
        throw new Error('Not in cache');
    }
    return cached;
}

// 5. Network Only (always fresh, fail if offline)
async function networkOnly(request) {
    return fetch(request);
}

Sync Strategies

Background Sync

Background Sync allows web apps to defer actions until the user has stable connectivity, ensuring operations complete even if the user closes the tab.

// Register a sync event
async function saveDraft(draft) {
    // Save locally
    await localDB.put('drafts', draft);

    // Request background sync
    if ('serviceWorker' in navigator && 'sync' in navigator.serviceWorker.registration) {
        try {
            await navigator.serviceWorker.ready;
            await navigator.serviceWorker.registration.sync.register('sync-drafts');
            console.log('Background sync registered');
        } catch (error) {
            // Fallback: try to sync immediately
            console.error('Background sync failed:', error);
            syncDrafts();
        }
    } else {
        // Fallback for browsers without background sync
        syncDrafts();
    }
}

// Service Worker - Handle sync event
self.addEventListener('sync', (event) => {
    if (event.tag === 'sync-drafts') {
        event.waitUntil(syncDrafts());
    }
});

async function syncDrafts() {
    const drafts = await localDB.getAll('drafts');

    for (const draft of drafts) {
        try {
            // Upload to server
            const response = await fetch('/api/posts', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify(draft)
            });

            if (response.ok) {
                // Remove from local drafts
                await localDB.delete('drafts', draft.id);

                // Store as published post
                const post = await response.json();
                await localDB.put('posts', post);
            }
        } catch (error) {
            console.error('Failed to sync draft:', error);
            // Will retry on next sync
        }
    }
}

Optimistic UI Updates

Update the UI immediately when the user takes action, assuming success, then sync with the server in the background.

class OfflineFirstApp {
    async createPost(postData) {
        // 1. Generate temporary ID
        const tempId = 'temp-' + Date.now();
        const optimisticPost = {
            ...postData,
            id: tempId,
            status: 'pending',
            createdAt: new Date()
        };

        // 2. Update UI immediately
        this.addPostToUI(optimisticPost);

        // 3. Save locally
        await localDB.put('pending-posts', optimisticPost);

        // 4. Try to sync with server
        try {
            const response = await fetch('/api/posts', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify(postData)
            });

            if (response.ok) {
                const serverPost = await response.json();

                // 5. Replace optimistic post with server version
                this.updatePostInUI(tempId, serverPost);

                // 6. Update local storage
                await localDB.delete('pending-posts', tempId);
                await localDB.put('posts', serverPost);
            } else {
                throw new Error('Server error');
            }
        } catch (error) {
            // 7. Mark as failed in UI
            this.markPostAsFailed(tempId);

            // Keep in pending queue for later sync
            console.log('Will retry when online');
        }
    }

    async deletePost(postId) {
        // 1. Remove from UI immediately
        this.removePostFromUI(postId);

        // 2. Mark as deleted locally
        const post = await localDB.get('posts', postId);
        post.deletedAt = new Date();
        await localDB.put('deleted-posts', post);
        await localDB.delete('posts', postId);

        // 3. Try to delete on server
        try {
            await fetch(`/api/posts/${postId}`, { method: 'DELETE' });
            // Success - permanently deleted
            await localDB.delete('deleted-posts', postId);
        } catch (error) {
            // Will sync deletion later
            console.log('Delete queued for sync');
        }
    }

    async updatePost(postId, changes) {
        // 1. Apply changes to UI immediately
        this.updatePostInUI(postId, changes);

        // 2. Update local copy
        const post = await localDB.get('posts', postId);
        const updatedPost = { ...post, ...changes, updatedAt: new Date() };
        await localDB.put('posts', updatedPost);

        // 3. Track change for syncing
        await localDB.put('pending-updates', {
            id: postId,
            changes,
            timestamp: Date.now()
        });

        // 4. Try to sync immediately
        this.syncChanges();
    }

    async syncChanges() {
        const updates = await localDB.getAll('pending-updates');

        for (const update of updates) {
            try {
                await fetch(`/api/posts/${update.id}`, {
                    method: 'PATCH',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify(update.changes)
                });

                // Success - remove from pending
                await localDB.delete('pending-updates', update.id);
            } catch (error) {
                // Will retry later
                console.log('Update queued for later sync');
            }
        }
    }
}

Conflict Resolution

When syncing offline changes with the server, conflicts may occur if the server data has changed since the last sync.

// Conflict resolution strategies
class ConflictResolver {
    // 1. Last Write Wins (simple, may lose data)
    async lastWriteWins(localPost, serverPost) {
        if (localPost.updatedAt > serverPost.updatedAt) {
            // Local is newer, push to server
            await this.pushToServer(localPost);
            return localPost;
        } else {
            // Server is newer, use server version
            await this.saveLocally(serverPost);
            return serverPost;
        }
    }

    // 2. Server Wins (safe, but loses local changes)
    async serverWins(localPost, serverPost) {
        await this.saveLocally(serverPost);
        return serverPost;
    }

    // 3. Client Wins (risky, may overwrite important server changes)
    async clientWins(localPost, serverPost) {
        await this.pushToServer(localPost);
        return localPost;
    }

    // 4. Field-Level Merge (merge non-conflicting fields)
    async fieldMerge(localPost, serverPost) {
        const merged = { ...serverPost };

        // Compare each field
        for (const [key, localValue] of Object.entries(localPost)) {
            const serverValue = serverPost[key];

            // If field changed locally but not on server, use local
            if (localValue !== this.originalData[key] &&
                serverValue === this.originalData[key]) {
                merged[key] = localValue;
            }
            // If both changed, use server (or could prompt user)
            else if (localValue !== this.originalData[key] &&
                     serverValue !== this.originalData[key]) {
                // Conflict! For now, keep server value
                // In real app, might show conflict UI
                console.warn(`Conflict on field ${key}`);
            }
        }

        merged.updatedAt = new Date();
        await this.saveLocally(merged);
        await this.pushToServer(merged);
        return merged;
    }

    // 5. Manual Resolution (let user decide)
    async manualResolution(localPost, serverPost) {
        // Show UI asking user to choose
        const choice = await this.showConflictUI(localPost, serverPost);

        if (choice === 'local') {
            await this.pushToServer(localPost);
            return localPost;
        } else if (choice === 'server') {
            await this.saveLocally(serverPost);
            return serverPost;
        } else if (choice === 'merge') {
            const merged = await this.showMergeUI(localPost, serverPost);
            await this.saveLocally(merged);
            await this.pushToServer(merged);
            return merged;
        }
    }
}

Progressive Web Apps (PWAs)

PWAs combine service workers, web manifests, and modern web capabilities to create app-like experiences that work offline.

{
  "name": "My Offline-First App",
  "short_name": "OfflineApp",
  "description": "An app that works offline",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#2196F3",
  "orientation": "portrait-primary",
  "icons": [
    {
      "src": "/icons/icon-192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ],
  "categories": ["productivity"],
  "screenshots": [
    {
      "src": "/screenshots/home.png",
      "sizes": "1280x720",
      "type": "image/png"
    }
  ]
}
<!-- Include manifest in HTML -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Offline First App</title>

    <!-- PWA Manifest -->
    <link rel="manifest" href="/manifest.json">

    <!-- iOS Meta Tags -->
    <meta name="apple-mobile-web-app-capable" content="yes">
    <meta name="apple-mobile-web-app-status-bar-style" content="default">
    <meta name="apple-mobile-web-app-title" content="OfflineApp">
    <link rel="apple-touch-icon" href="/icons/icon-192.png">

    <!-- Theme Color -->
    <meta name="theme-color" content="#2196F3">
</head>
<body>
    <div id="app"></div>

    <script>
        // Register Service Worker
        if ('serviceWorker' in navigator) {
            window.addEventListener('load', () => {
                navigator.serviceWorker.register('/service-worker.js');
            });
        }

        // Show install prompt
        let deferredPrompt;
        window.addEventListener('beforeinstallprompt', (e) => {
            e.preventDefault();
            deferredPrompt = e;
            showInstallButton();
        });

        function installApp() {
            if (deferredPrompt) {
                deferredPrompt.prompt();
                deferredPrompt.userChoice.then((choiceResult) => {
                    if (choiceResult.outcome === 'accepted') {
                        console.log('App installed');
                    }
                    deferredPrompt = null;
                });
            }
        }
    </script>
</body>
</html>

Network Status Detection

// Detect and respond to network status
class NetworkManager {
    constructor() {
        this.online = navigator.onLine;
        this.setupListeners();
    }

    setupListeners() {
        window.addEventListener('online', () => {
            console.log('Back online!');
            this.online = true;
            this.onOnline();
        });

        window.addEventListener('offline', () => {
            console.log('Gone offline');
            this.online = false;
            this.onOffline();
        });
    }

    onOnline() {
        // Hide offline banner
        this.hideOfflineBanner();

        // Sync pending changes
        this.syncPendingData();

        // Notify user
        this.showNotification('Connection restored. Syncing data...');
    }

    onOffline() {
        // Show offline banner
        this.showOfflineBanner();

        // Notify user
        this.showNotification('You are offline. Changes will sync when reconnected.');
    }

    showOfflineBanner() {
        const banner = document.createElement('div');
        banner.id = 'offline-banner';
        banner.innerHTML = `
            <div class="offline-message">
                📡 You're offline. Working with cached data.
            </div>
        `;
        document.body.prepend(banner);
    }

    hideOfflineBanner() {
        const banner = document.getElementById('offline-banner');
        if (banner) {
            banner.remove();
        }
    }

    async syncPendingData() {
        // Sync all pending changes
        const pendingPosts = await localDB.getAll('pending-posts');
        const pendingUpdates = await localDB.getAll('pending-updates');
        const pendingDeletes = await localDB.getAll('deleted-posts');

        // Process all queued operations
        await this.processPendingOperations(
            pendingPosts,
            pendingUpdates,
            pendingDeletes
        );
    }

    // Check connection quality
    async checkConnectionQuality() {
        if ('connection' in navigator) {
            const connection = navigator.connection;
            const type = connection.effectiveType;

            // '4g', '3g', '2g', 'slow-2g'
            if (type === 'slow-2g' || type === '2g') {
                console.log('Slow connection detected');
                // Use lighter assets, reduce image quality, etc.
                this.useLowBandwidthMode();
            }
        }
    }
}

Real-World Example: Note-Taking App

class OfflineNotes {
    constructor() {
        this.db = new LocalDatabase('notes-app');
        this.networkManager = new NetworkManager();
        this.init();
    }

    async init() {
        await this.db.open();
        await this.loadNotes();
        this.setupEventListeners();
    }

    async loadNotes() {
        // Load from local DB first
        const localNotes = await this.db.getAll('notes');
        this.displayNotes(localNotes);

        // Try to fetch updates from server
        if (navigator.onLine) {
            try {
                const response = await fetch('/api/notes');
                const serverNotes = await response.json();

                // Merge with local notes
                await this.mergeNotes(serverNotes);
                this.displayNotes(serverNotes);
            } catch (error) {
                console.log('Using cached notes');
            }
        }
    }

    async createNote(content) {
        const note = {
            id: 'temp-' + Date.now(),
            content,
            createdAt: new Date(),
            synced: false
        };

        // Save locally immediately
        await this.db.put('notes', note);
        this.addNoteToUI(note);

        // Sync to server
        this.syncNote(note);
    }

    async updateNote(id, content) {
        const note = await this.db.get('notes', id);
        note.content = content;
        note.updatedAt = new Date();
        note.synced = false;

        // Update locally
        await this.db.put('notes', note);
        this.updateNoteInUI(note);

        // Sync to server
        this.syncNote(note);
    }

    async deleteNote(id) {
        // Remove from UI
        this.removeNoteFromUI(id);

        // Move to deleted queue
        const note = await this.db.get('notes', id);
        await this.db.put('deleted-notes', note);
        await this.db.delete('notes', id);

        // Sync deletion
        this.syncDeletion(id);
    }

    async syncNote(note) {
        if (!navigator.onLine) {
            console.log('Offline - will sync later');
            return;
        }

        try {
            const url = note.id.startsWith('temp-')
                ? '/api/notes'
                : `/api/notes/${note.id}`;

            const method = note.id.startsWith('temp-') ? 'POST' : 'PUT';

            const response = await fetch(url, {
                method,
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify(note)
            });

            if (response.ok) {
                const serverNote = await response.json();

                // Update with server ID and mark as synced
                if (note.id.startsWith('temp-')) {
                    await this.db.delete('notes', note.id);
                    this.removeNoteFromUI(note.id);
                }

                serverNote.synced = true;
                await this.db.put('notes', serverNote);
                this.updateNoteInUI(serverNote);
            }
        } catch (error) {
            console.error('Sync failed:', error);
        }
    }

    async syncDeletion(id) {
        if (!navigator.onLine) return;

        try {
            await fetch(`/api/notes/${id}`, { method: 'DELETE' });
            await this.db.delete('deleted-notes', id);
        } catch (error) {
            console.log('Delete will retry later');
        }
    }
}

Benefits

Challenges

Best Practices

Key Takeaways

See Also