Beaucoup de développeurs traitent le mode hors-ligne comme une checkbox à cocher : enregistrer un service worker, mettre en cache quelques assets, et voilà. En réalité, une PWA qui fonctionne vraiment hors-ligne demande une pensée architecturale bien plus profonde. Le service worker n'est que la base. Ce qui fait la différence entre une PWA de démo et une PWA utilisable, c'est la stratégie de cache multi-niveaux, la gestion intelligente du stockage, et la synchronisation en arrière-plan qui ramène les données quand la connexion revient.
Service workers : comprendre les limites
Un service worker intercepte les requêtes et peut servir du contenu mis en cache. Mais il ne stocke rien par défaut. Le cache API du navigateur a des limites de taille (souvent 50 Mo par domaine), pas de mécanisme d'expiration natif, et aucune capacité à persister les données utilisateur au-delà des assets statiques. De plus, le service worker lui-même s'exécute sur un thread séparé et ne peut pas bloquer l'application principale. Cela signifie que si vous voulez que votre app fonctionne vraiment hors-ligne avec des données dynamiques (formulaires, listes, contenu), vous devez coupler le service worker avec IndexedDB ou LocalStorage pour la persistence, et avec une logique métier qui sait quand utiliser le cache et quand synchroniser.
Architecture : cache-first, network-first, stale-while-revalidate
La stratégie de cache dépend du type de contenu. Pour les assets statiques (JS, CSS, images), une approche cache-first fonctionne bien : vérifier le cache en premier, servir si disponible, sinon aller en ligne et mettre à jour le cache pour la prochaine fois. Pour les données dynamiques (API calls), network-first est plus prudent : essayer d'aller en ligne d'abord, utiliser le cache en fallback si hors-ligne. Une troisième approche, stale-while-revalidate, est efficace pour les données semi-statiques : servir immédiatement depuis le cache (même périmé), puis en arrière-plan vérifier la version fraîche et mettre à jour le cache si elle diffère.
Voici un exemple de service worker mixte :
self.addEventListener('fetch', event => {
const url = new URL(event.request.url);
if (url.pathname.startsWith('/api/')) {
// API : network-first avec 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 : la vraie persistence
Pour stocker des données utilisateur complexes hors-ligne (formulaires brouillon, listes, contenu article), IndexedDB est indispensable. C'est une base de données transactionnelle côté client, capable de stocker bien plus que LocalStorage (généralement plusieurs centaines de Mo), et avec des index pour des requêtes efficaces. LocalStorage, limité à ~5 Mo et synchrone, devient vite un goulot d'étranglement. IndexedDB est asynchrone, donc il n'expose pas le thread principal.
Voici comment structurer une couche de persistence :
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);
});
}
}
Synchronisation en arrière-plan : ramener les données
Quand l'utilisateur remplit un formulaire hors-ligne, celui-ci doit être stocké localement. Une fois reconnecté, il faut l'envoyer au serveur. La Background Sync API permet d'enregistrer une tâche de sync que le navigateur exécutera dès que la connexion revient, même si l'app est fermée. C'est bien mieux que de compter sur une logique au démarrage.
// Dans votre 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);
}
}
})()
);
}
});
// Dans votre app, quand vous enregistrez un brouillon
navigator.serviceWorker.ready.then(reg => {
reg.sync.register('sync-drafts');
});
Gestion du cache : expiration et nettoyage
Le cache du navigateur ne s'expire pas automatiquement. Si vous mettez en cache une version de votre app, elle restera tant que l'utilisateur ne la supprime pas manuellement. Cela pose un problème : les vieilles versions restent en mémoire, et les mises à jour tardent à être visibles. Il faut implémenter une logique d'expiration : versionnez vos caches (static-v1, static-v2), et au démarrage du service worker, supprimez les anciennes versions. Pour les données API, stockez un timestamp et rejettez les entrées trop vieilles.
Piège courant : oublier l'UX offline
Beaucoup d'équipes construisent une infrastructure hors-ligne robuste, puis oublient de l'exposer à l'utilisateur. Si je suis hors-ligne et que je tente de poster un commentaire, l'app doit clairement indiquer que c'est en attente de synchronisation, pas en erreur. Utilisez un indicateur visuel (badge, banneau), et un système d'état qui distingue « sauvegardé localement » de « synchronisé ». Sinon, l'utilisateur pense que ça a échoué, et reposte le même commentaire trois fois. De plus, tous les navigateurs ne supportent pas Background Sync ; prévoyez un fallback qui synchronise quand l'app revient au focus.
Conclusion : PWA offline, c'est un système
Une PWA robuste hors-ligne n'est pas un service worker avec un cache. C'est une architecture complète : service worker pour l'interception, IndexedDB pour la persistence, Background Sync pour la remise en ligne, et une UI qui communique clairement l'état. Commencez simple (cache-first pour les assets, network-first pour les API), puis intégrez IndexedDB et Background Sync au fur et à mesure. Testez régulièrement hors-ligne (le DevTools de Chrome simule bien la déconnexion) et mesurez la taille du cache pour éviter les dépassements. Votre utilisateur sur une connexion 3G en train de voyager vous remerciera.