← Retour au blog
PWAservice-workersoffline-firstIndexedDBbackground-sync

PWA: Beyond Service Workers, Building a Real Offline Strategy

Many developers treat offline mode as a checkbox: register a service worker, cache a few assets, done. In reality, a PWA that truly works offline demands far deeper architectural thinking. The service worker is just the foundation. What separates a demo PWA from a usable one is multi-layered caching strategy, intelligent storage management, and background synchronization that brings data back when connection returns.

Service Workers: Understanding the Limits

A service worker intercepts requests and can serve cached content. But it stores nothing by default. The browser's Cache API has size limits (often 50 MB per domain), no native expiration mechanism, and no ability to persist user data beyond static assets. Moreover, the service worker itself runs on a separate thread and cannot block the main application. This means if you want your app to truly work offline with dynamic data (forms, lists, content), you must pair the service worker with IndexedDB or LocalStorage for persistence, and with business logic that knows when to use cache and when to sync.

Architecture: Cache-First, Network-First, Stale-While-Revalidate

Caching strategy depends on content type. For static assets (JS, CSS, images), cache-first works well: check cache first, serve if available, otherwise go online and update cache for next time. For dynamic data (API calls), network-first is safer: try online first, fall back to cache if offline. A third approach, stale-while-revalidate, is efficient for semi-static data: serve immediately from cache (even if stale), then in the background check the fresh version and update cache if it differs.

Here's an example of a mixed service worker:

self.addEventListener('fetch', event => {

const url = new URL(event.request.url);

if (url.pathname.startsWith('/api/')) {

// API: network-first with fallback

event.respondWith(

fetch(event.request)

.then(response => {

if (!response.ok) throw new Error('Network failed');

const clone = response.clone();

caches.open('api-v1').then(cache => cache.put(event.request, clone));

return response;

})

.catch(() => caches.match(event.request))

);

} else {

// Assets: cache-first

event.respondWith(

caches.match(event.request).then(cached => {

if (cached) return cached;

return fetch(event.request).then(response => {

if (!response.ok) return response;

caches.open('static-v1').then(c => c.put(event.request, response.clone()));

return response;

});

})

);

}

});

IndexedDB: True Persistence

For storing complex user data offline (draft forms, lists, article content), IndexedDB is essential. It's a transactional client-side database capable of storing far more than LocalStorage (typically hundreds of MB), with indexes for efficient queries. LocalStorage, limited to ~5 MB and synchronous, quickly becomes a bottleneck. IndexedDB is asynchronous, so it doesn't block the main thread.

Here's how to structure a persistence layer:

export class OfflineStore {

private db: IDBDatabase;

async init() {

return new Promise((resolve, reject) => {

const req = indexedDB.open('app-db', 1);

req.onupgradeneeded = () => {

const db = req.result;

if (!db.objectStoreNames.contains('drafts')) {

db.createObjectStore('drafts', { keyPath: 'id' });

}

if (!db.objectStoreNames.contains('articles')) {

const store = db.createObjectStore('articles', { keyPath: 'id' });

store.createIndex('timestamp', 'timestamp');

}

};

req.onsuccess = () => { this.db = req.result; resolve(this.db); };

req.onerror = () => reject(req.error);

});

}

async saveDraft(id: string, data: any) {

const tx = this.db.transaction('drafts', 'readwrite');

return new Promise((resolve, reject) => {

const req = tx.objectStore('drafts').put({ id, ...data, saved: Date.now() });

req.onsuccess = () => resolve(req.result);

req.onerror = () => reject(req.error);

});

}

async getDrafts() {

const tx = this.db.transaction('drafts', 'readonly');

return new Promise((resolve, reject) => {

const req = tx.objectStore('drafts').getAll();

req.onsuccess = () => resolve(req.result);

req.onerror = () => reject(req.error);

});

}

}

Background Sync: Bringing Data Back

When a user fills a form offline, it must be stored locally. Once reconnected, it needs to be sent to the server. The Background Sync API lets you register a sync task that the browser will execute as soon as connection returns, even if the app is closed. That's far better than relying on startup logic.

// In your service worker

self.addEventListener('sync', event => {

if (event.tag === 'sync-drafts') {

event.waitUntil(

(async () => {

const store = new OfflineStore();

await store.init();

const drafts = await store.getDrafts();

for (const draft of drafts) {

try {

const response = await fetch('/api/drafts', {

method: 'POST',

headers: { 'Content-Type': 'application/json' },

body: JSON.stringify(draft)

});

if (response.ok) {

await store.deleteDraft(draft.id);

}

} catch (e) {

console.error('Sync failed, will retry:', e);

}

}

})()

);

}

});

// In your app, when you save a draft

navigator.serviceWorker.ready.then(reg => {

reg.sync.register('sync-drafts');

});

Cache Management: Expiration and Cleanup

Browser cache doesn't expire automatically. If you cache a version of your app, it stays until the user manually clears it. This poses a problem: old versions linger, and updates lag. You must implement expiration logic: version your caches (static-v1, static-v2), and on service worker startup, delete old versions. For API data, store a timestamp and reject entries that are too old.

Common Pitfall: Forgetting Offline UX

Many teams build robust offline infrastructure, then forget to surface it to the user. If I'm offline and try to post a comment, the app must clearly indicate it's pending sync, not errored. Use a visual indicator (badge, banner), and a state system that distinguishes 'saved locally' from 'synced'. Otherwise, the user thinks it failed and reposts the same comment three times. Also, not all browsers support Background Sync; plan a fallback that syncs when the app regains focus.

Conclusion: Offline PWA is a System

A robust offline PWA isn't a service worker with a cache. It's a complete architecture: service worker for interception, IndexedDB for persistence, Background Sync for resync, and UI that clearly communicates state. Start simple (cache-first for assets, network-first for APIs), then layer in IndexedDB and Background Sync gradually. Test regularly offline (Chrome DevTools simulates disconnection well) and monitor cache size to avoid overruns. Your user on 3G while traveling will thank you.

Développeur Angular & Mobile freelance — Strasbourg.

© 2026 Emilien Pons — Tous droits réservés.Conçu avec Angular, PrimeNG et ❤️