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
- Reliability: App works regardless of network conditions
- Performance: Instant loading from local cache
- User Experience: No "network error" messages disrupting work
- Resilience: Graceful degradation when network is poor
- Data Safety: Local-first prevents data loss
- Accessibility: Works in areas with poor connectivity
- Reduced Server Load: Fewer redundant requests
Challenges
- Complexity: Managing sync, conflicts, and state is difficult
- Storage Limits: Browsers limit IndexedDB and cache storage
- Conflict Resolution: Merging offline changes can be complex
- Testing: Hard to test all offline/online scenarios
- Security: Sensitive data stored locally needs encryption
- Browser Support: Service Workers not available in all browsers
- Cache Invalidation: Knowing when to update cached data
Best Practices
- Cache the application shell (HTML, CSS, JS) for instant loading
- Use appropriate caching strategies for different resource types
- Provide clear visual indicators of online/offline status
- Show sync status for user-generated content
- Implement retry logic with exponential backoff
- Handle storage quota exceeded errors gracefully
- Clear old cached data to manage storage limits
- Test thoroughly with DevTools offline mode and throttling
- Consider CRDT (Conflict-free Replicated Data Types) for complex sync
- Encrypt sensitive data stored locally
Key Takeaways
- Offline First treats network as enhancement, not requirement
- Service Workers intercept requests and serve cached responses
- IndexedDB stores structured data for offline access
- Cache API stores HTTP responses with various strategies
- Optimistic UI updates make apps feel instant
- Background Sync ensures operations complete when connectivity returns
- Conflict resolution strategies handle simultaneous edits
- PWAs combine these technologies for app-like experiences
- Network awareness enables adaptive UX based on connection quality
See Also
- Progressive Enhancement - Network as progressive enhancement
- Single Page Application - SPAs can leverage offline-first
- Client-Server Architecture - Understanding the network layer
- Event-Driven Architecture - Sync events when online