Pourquoi concevoir une application qui fonctionne sans connexion
Une application web installable hors-ligne ne se résume pas à un site classique avec un manifeste JSON et un service worker collé à la dernière minute. Il s’agit d’une architecture pensée dès la première ligne de code pour rester utile lorsque la connectivité devient incertaine : trois barres qui chutent à une, latence qui grimpe de cent à mille millisecondes, perte totale du signal pendant plusieurs minutes. Sur des appareils Android d’entrée de gamme connectés à des réseaux mobiles intermittents, ce comportement n’est pas un cas limite : c’est la norme.
L’enjeu est triple. D’abord, la résilience : l’utilisateur doit pouvoir consulter ce qu’il a déjà vu et déclencher de nouvelles actions même hors connexion. Ensuite, la frugalité : chaque requête réseau coûte de la donnée mobile facturée au mégaoctet, et chaque octet économisé compte. Enfin, la fluidité perçue : afficher instantanément depuis un cache local plutôt qu’attendre une réponse serveur transforme l’expérience, même quand le réseau est disponible.
Ce guide pose les fondations d’une architecture installable et hors-ligne. Il couvre les quatre piliers techniques — service workers, stockage local riche, synchronisation différée, stratégies de cache — et leurs mécanismes d’audit. Chaque sous-sujet renvoie vers un tutoriel pas à pas dédié.
Les trois fondations d’une application installable
Le service worker, intermédiaire réseau programmable
Le service worker est un script JavaScript qui s’exécute dans un thread distinct du document principal et intercepte toutes les requêtes réseau émises par les pages de son origine. Il agit comme un proxy local : il peut répondre depuis un cache, transformer la requête, la différer ou la rejeter. Il survit à la fermeture des onglets et peut être réveillé par le navigateur pour traiter des événements de synchronisation ou des notifications push.
Trois propriétés sont cruciales pour comprendre son cycle de vie. Premièrement, l’enregistrement se fait depuis la page (navigator.serviceWorker.register('/sw.js')) mais le script tourne ensuite indépendamment. Deuxièmement, l’installation et l’activation sont deux phases distinctes : un nouveau service worker s’installe en arrière-plan pendant que l’ancien continue à servir les onglets ouverts, et il ne devient actif qu’après fermeture de tous les clients (sauf appel explicite à self.skipWaiting()). Troisièmement, l’événement fetch est synchronisé : si vous ne répondez pas avec event.respondWith(), le navigateur procède à la requête réseau par défaut.
Une erreur classique consiste à enregistrer le service worker depuis un sous-répertoire : sa portée (scope) sera alors limitée à ce répertoire. Pour couvrir l’ensemble du site, le fichier sw.js doit être servi depuis la racine du domaine avec le bon en-tête Service-Worker-Allowed si nécessaire.
Le manifeste, contrat d’installation
Le fichier manifest.webmanifest (ou manifest.json) décrit comment le système d’exploitation doit présenter l’application une fois installée : nom, icônes de différentes résolutions, couleurs de la barre de statut, mode d’affichage (standalone pour masquer l’interface du navigateur), orientation préférée. Sans manifeste valide, aucune installation ne sera proposée à l’utilisateur, peu importe la qualité du service worker.
Les critères d’installabilité appliqués par Chromium sont stricts : icône de 192×192 pixels au minimum (et de préférence une icône maskable), start_url chargeable hors-ligne, propriétés name, display et icons présentes. Une seule de ces conditions manquante et la bannière d’installation reste invisible. Le panneau Application des DevTools de Chrome affiche un diagnostic ligne par ligne — c’est le premier endroit où regarder lorsqu’aucun prompt d’installation n’apparaît.
Le stockage local, mémoire persistante du client
Une application qui doit survivre à la perte du réseau a besoin d’un stockage qui survit au rafraîchissement, à la fermeture du navigateur et même à la mise en veille de l’appareil. Le navigateur expose trois APIs principales : Cache Storage pour les réponses HTTP complètes, IndexedDB pour les données structurées, et localStorage pour les chaînes courtes. Les deux premières sont les outils sérieux ; localStorage est synchrone, plafonné à environ 5 Mo et bloque le thread principal — à proscrire pour autre chose que des préférences utilisateur de quelques kilo-octets.
Le quota disponible varie selon les navigateurs. Chrome accorde à une origine jusqu’à 60 % de l’espace disque total, et le navigateur lui-même peut consommer jusqu’à 80 % du disque pour l’ensemble des origines. Sur un appareil de 32 Go avec 20 Go libres, cela représente théoriquement une dizaine de gigaoctets accessibles à une seule application — largement suffisant pour des milliers d’enregistrements et des centaines de mégaoctets d’images mises en cache. En mode incognito, ce quota chute à environ 5 % et les données sont effacées à la fermeture de la session.
L’architecture App Shell
Le patron App Shell sépare la coque applicative — squelette HTML minimal, CSS critique, JavaScript de bootstrap — du contenu dynamique. La coque est mise en cache de façon agressive au premier chargement, puis servie instantanément depuis le service worker à chaque visite ultérieure. Le contenu, lui, est récupéré soit depuis un cache spécifique avec une stratégie adaptée, soit depuis le réseau quand l’utilisateur réclame quelque chose de nouveau.
Concrètement, le service worker maintient deux ou trois caches distincts. Un cache shell-v1 contient les fichiers indispensables au démarrage : index.html, le CSS de base, le bundle JavaScript principal, les polices, l’icône d’application. Ce cache est rempli pendant l’événement install via cache.addAll(). Un cache pages-v1 accumule les pages déjà visitées en stratégie stale-while-revalidate. Un cache assets-v1 capture images, vidéos et autres ressources volumineuses avec une politique de purge basée sur la taille.
Cette séparation présente deux avantages décisifs. Premièrement, elle permet de versionner indépendamment la coque (mise à jour à chaque déploiement) et les actifs (cache long sans invalidation manuelle). Deuxièmement, elle limite l’effet rouleau-compresseur d’un cache géant unique qu’il faudrait purger entièrement à chaque mise à jour.
Les quatre stratégies de cache
Lorsqu’une requête arrive sur le service worker, quatre stratégies dominent la littérature. Choisir la bonne pour chaque type de ressource est le cœur du métier.
Cache First répond depuis le cache si la ressource s’y trouve, sinon va sur le réseau. Idéal pour les actifs immuables versionnés par URL (par exemple main.a3f8c2.js). La requête réseau n’a lieu qu’une seule fois dans la vie du cache.
Network First tente le réseau d’abord avec un délai d’expiration court, puis tombe sur le cache si la requête échoue. Adapté au contenu fréquemment mis à jour mais dont l’utilisateur tolère une version légèrement plus ancienne en cas de coupure — fil d’actualité, profil utilisateur.
Stale While Revalidate répond immédiatement depuis le cache (réponse possiblement obsolète) et lance en arrière-plan une requête réseau qui rafraîchit le cache. La page suivante bénéficiera de la nouvelle version. C’est le meilleur compromis pour la plupart des ressources mi-statiques mi-dynamiques.
Network Only court-circuite tout cache. Réservé aux endpoints sensibles (paiement, authentification, mutations critiques) où une réponse obsolète serait pire qu’une erreur réseau franche.
La règle d’or : aucune stratégie ne convient à tout. Une application bien conçue assigne explicitement une stratégie par type de ressource — HTML pour la navigation, JS et CSS bundlés, images, requêtes API JSON, médias volumineux. Le détail de chaque stratégie et leurs cas limites font l’objet d’un tutoriel dédié.
Stockage structuré : pourquoi IndexedDB s’impose
Cache Storage stocke des objets Request/Response entiers, ce qui convient parfaitement à de la mise en cache HTTP. Mais dès qu’il faut chercher, filtrer ou trier des enregistrements applicatifs — une liste de commandes, des messages d’une conversation, des fiches produit — il faut une vraie base de données. IndexedDB est cette base. Elle est transactionnelle, asynchrone, indexée, et capable de stocker n’importe quel type cloneable de JavaScript : objets, blobs, fichiers, ArrayBuffer.
L’API native d’IndexedDB est notoirement verbeuse. Ouvrir une base avec gestion de version, créer un magasin d’objets, ajouter un index, exécuter une requête : chaque opération demande de manipuler des IDBRequest avec des callbacks onsuccess/onerror, parfois imbriqués sur plusieurs niveaux. Les abstractions modernes comme Dexie.js réduisent ce code de plusieurs centaines de lignes à quelques dizaines, sans renoncer aux performances ni à la sémantique transactionnelle.
Quand utiliser quoi ? Cache Storage pour tout ce qui passe par fetch() et qui pourra être servi tel quel comme réponse HTTP. IndexedDB pour les données métier : entités, agrégats, brouillons, files d’attente d’actions à synchroniser. Les deux cohabitent dans la même origine, partagent le quota global, et sont accessibles depuis le service worker comme depuis la page.
Synchronisation différée : Background Sync et alternatives
Quand l’utilisateur soumet un formulaire hors connexion, deux mauvaises réponses se présentent : afficher une erreur sèche, ou prétendre que tout s’est bien passé sans rien faire. La bonne réponse est de stocker localement la requête, accuser réception à l’utilisateur, puis tenter de l’envoyer dès que la connectivité revient. C’est le rôle de l’API Background Sync.
Le mécanisme est élégant : la page enregistre un événement de synchronisation sous un nom (par exemple sync-orders), le navigateur prend la responsabilité de réveiller le service worker dès qu’une connexion réseau est détectée, et le handler sync du service worker exécute la logique d’envoi. Si l’envoi échoue, le navigateur reprogramme la tentative.
Une limite importante doit être soulignée : Background Sync n’est implémenté que par les navigateurs Chromium (Chrome, Edge, Opera, Samsung Internet, Brave). Firefox et Safari ne le prennent pas en charge à ce jour, et aucune annonce de support n’a été faite. Pour ces navigateurs, il faut implémenter une stratégie de repli : enregistrer la file d’attente en IndexedDB, écouter l’événement online sur l’objet window, et déclencher manuellement le flush. La logique métier reste identique ; seul le déclencheur change.
Performance perçue et budget réseau
Une application installable doit viser des cibles de performance plus strictes qu’un site classique, parce que l’utilisateur compare l’expérience à celle d’une application native. Les seuils retenus par l’industrie sont les suivants : First Contentful Paint sous 1,8 seconde, Largest Contentful Paint sous 2,5 secondes, Total Blocking Time sous 200 millisecondes, Cumulative Layout Shift sous 0,1. Ces chiffres sont mesurés au 75ᵉ centile sur une condition réseau de référence — typiquement le profil Lighthouse mobile par défaut, qui simule un Moto G Power avec le réseau « Slow 4G » (1,6 Mbps en téléchargement, 750 Kbps en envoi, 150 ms de latence) et un ralentissement CPU de 4×.
Pour tenir ces cibles, trois leviers principaux. Le budget JavaScript doit rester sous 170 Ko compressés en gzip pour le bundle initial. Au-delà, le parsing seul dépasse les 200 ms sur les appareils d’entrée de gamme. Le budget images impose un format moderne (AVIF ou WebP), une résolution adaptée via srcset, et un lazy loading natif (loading="lazy") sur tout ce qui se trouve sous la ligne de flottaison. Le budget fonts recommande au maximum deux familles avec deux graisses chacune, chargées en font-display: swap pour éviter le blocage du rendu.
L’audit de ces métriques se fait avec Lighthouse, intégré dans Chrome DevTools. Important : la catégorie PWA dédiée a été retirée de Lighthouse à partir de la version 12.0. Les audits d’installabilité existent toujours individuellement (manifest valide, service worker enregistré, icône maskable) mais ne forment plus un score agrégé. Le détail de la nouvelle procédure d’audit est couvert dans un tutoriel séparé.
Vue d’ensemble : flux de données complet
Pour visualiser comment toutes ces briques s’assemblent, suivons une action concrète : l’utilisateur ouvre l’application, consulte une fiche, modifie un champ, soumet le formulaire, perd la connexion entre-temps.
Étape 1 — Démarrage. Le navigateur sert index.html depuis le cache shell-v1. Le bundle JavaScript est lui aussi servi depuis le cache. La page apparaît en moins de 500 millisecondes même sans réseau. Le service worker, déjà actif depuis la session précédente, intercepte toutes les requêtes.
Étape 2 — Lecture de la fiche. Le code appelle fetch('/api/orders/42'). Le service worker applique une stratégie stale-while-revalidate : il regarde dans le cache api-v1, trouve une réponse vieille de deux heures, la renvoie immédiatement à la page (qui affiche la fiche), et lance en arrière-plan une requête réseau pour rafraîchir le cache. Si la connexion est trop lente, le cache reste l’unique source ; sinon, le rafraîchissement aboutit silencieusement.
Étape 3 — Modification locale. L’utilisateur modifie un champ. Le composant met à jour son état local en mémoire et, si la modification est significative, écrit un brouillon dans IndexedDB via Dexie. Aucune requête réseau n’est émise à ce stade.
Étape 4 — Soumission. L’utilisateur clique sur « Enregistrer ». Le code tente fetch('/api/orders/42', { method: 'PUT', body: ... }). La connexion est tombée entre-temps. La requête échoue. Le code intercepte l’erreur, écrit la mutation dans une file d’attente IndexedDB (pending-mutations), enregistre un événement Background Sync (registration.sync.register('flush-mutations')), et affiche à l’utilisateur « Modification enregistrée, sera envoyée à la reconnexion ».
Étape 5 — Reconnexion. Quelques minutes plus tard, le réseau revient. Le navigateur réveille le service worker et déclenche l’événement sync avec le tag flush-mutations. Le handler ouvre IndexedDB, parcourt la file pending-mutations, et tente d’envoyer chaque mutation au serveur. Les succès sont retirés de la file, les échecs sont marqués pour une nouvelle tentative.
Étape 6 — Résolution. Le serveur répond OK. Le service worker met à jour le cache api-v1 avec la nouvelle réponse canonique, et envoie un message à toutes les pages ouvertes (client.postMessage({type: 'sync-complete', ...})) pour qu’elles puissent rafraîchir leur état UI.
Ce flux n’utilise aucune dépendance exotique : tout se fait avec les APIs standard du navigateur, plus Dexie pour confort. Aucune intervention serveur spécifique n’est nécessaire au-delà des en-têtes de cache habituels.
Tutoriels pas à pas
Chaque pilier technique évoqué ci-dessus fait l’objet d’un guide pratique détaillé. Suivre les six dans l’ordre permet d’aboutir à une application installable hors-ligne complète et auditée.
- Service Workers avec Workbox 7 — configuration complète de la version 7, génération du service worker via Workbox CLI, intégration avec un bundler moderne.
- IndexedDB avec Dexie.js — schéma déclaratif, migrations, requêtes indexées, gestion transactionnelle, observabilité avec les hooks.
- Background Sync API — enregistrement, gestion du tag, stratégie de repli pour les navigateurs non compatibles, idempotence côté serveur.
- Stratégies de cache — implémentation des cinq stratégies, gestion de la taille via expiration, plugins Workbox utiles.
- Audit avec Lighthouse — protocole post version 12, audits d’installabilité résiduels, alternatives PWABuilder, profilage CPU et réseau.
Erreurs fréquentes à éviter
| Erreur | Cause | Solution |
|---|---|---|
| Service worker actif mais aucune ressource servie hors-ligne | Aucun handler fetch, ou handler qui ne répond qu’au réseau |
Implémenter event.respondWith(caches.match(event.request).then(...)) avec fallback |
| Cache qui explose le quota | Mise en cache de réponses opaques cross-origin (images CDN, fontes externes) | Chrome inflige ~7 Mo par réponse opaque ; limiter ces caches avec un plugin d’expiration par taille |
| Nouvelle version jamais activée chez les utilisateurs | Oubli de self.skipWaiting() ou de clients.claim() |
Ajouter les deux au service worker, ou prévoir un prompt explicite « Nouvelle version disponible » |
| Pas de bannière d’installation | Manifest invalide ou critère manquant | Panneau Application des DevTools, section Manifest : suivre les diagnostics |
| IndexedDB se bloque après une migration | Connexion ouverte dans un autre onglet pendant le changement de version | Écouter versionchange et fermer proprement la connexion existante |
| Background Sync ne se déclenche pas | Navigateur non Chromium, ou registration appelée hors d’un contexte sécurisé | Mettre en place une stratégie de repli via window.addEventListener('online', ...) |
Questions fréquentes
Faut-il un certificat HTTPS pour qu’un service worker fonctionne ?
Oui, sauf en développement local sur localhost où le navigateur fait une exception. Sur tout autre nom d’hôte, HTTPS est obligatoire. Let’s Encrypt fournit gratuitement des certificats valides.
Le service worker continue-t-il à tourner quand l’onglet est fermé ?
Le service worker est mis en veille très rapidement quand il n’a plus de travail. Il est réveillé à la demande lors d’un événement fetch, sync, push ou message. Il ne consomme pas de CPU en arrière-plan.
Combien de données peut-on réellement stocker ?
Plusieurs gigaoctets sur un appareil moderne. La méthode navigator.storage.estimate() retourne le quota disponible et l’usage actuel. Le navigateur peut purger des origines en cas de pression disque, sauf si l’origine est persistante (demande explicite via navigator.storage.persist(), souvent accordée si l’application est installée).
Est-ce que ça remplace une application native Android ou iOS ?
Pour beaucoup de cas d’usage, oui. Sur Android, une application installable a accès à la majorité des APIs (notifications, géolocalisation, capteurs, paiement). Sur iOS, certaines limitations subsistent — notifications push limitées, pas de Background Sync, gestion du stockage parfois agressive — mais l’écart se réduit de version en version.
Comment gérer les conflits quand la même donnée est modifiée sur deux appareils hors-ligne ?
Le client ne peut pas résoudre seul ce problème. Le serveur doit appliquer une stratégie : dernière écriture gagnante, fusion automatique, ou détection de conflit avec retour à l’utilisateur. Inclure un identifiant de version (ETag, timestamp serveur, numéro de révision) dans chaque mutation envoyée permet au serveur de détecter le conflit.
Faut-il utiliser un framework ou peut-on tout faire à la main ?
Pour un petit projet ou pour apprendre, tout à la main est formateur. Pour un projet de production, Workbox automatise la génération du service worker, la précache du shell et la configuration des stratégies. Le code généré reste lisible et débogable.
La mise à jour du service worker pose-t-elle problème aux utilisateurs ?
Par défaut, un nouveau service worker s’installe sans interrompre la session en cours et ne devient actif qu’après fermeture de tous les onglets de l’origine. C’est sûr mais lent à propager. Pour forcer l’activation immédiate, combiner skipWaiting() et clients.claim() en acceptant le risque que des onglets ouverts reçoivent soudain une nouvelle version du shell.
Compatibilité navigateurs et stratégie de dégradation
Construire une application qui repose sur Service Worker, Cache Storage, IndexedDB et Background Sync demande de connaître l’état réel du support navigateur. Service Worker et Cache Storage sont disponibles dans Chrome, Edge, Firefox, Safari et Samsung Internet en versions récentes — la base est solide. IndexedDB est universelle depuis longtemps avec quelques différences mineures de performances. Le manifeste d’application est interprété par tous les moteurs avec des nuances : iOS Safari respecte le format mais ignore certains champs (scope, display: standalone partiellement) et l’installation passe par un partage explicite « Ajouter à l’écran d’accueil ».
Background Sync reste le maillon faible : seuls les navigateurs basés sur Chromium l’implémentent. Concrètement, sur Safari iOS — souvent imposé même quand l’utilisateur préfère un autre navigateur, du fait de la politique d’Apple — le flush des mutations doit reposer sur un écouteur online côté page, complété par une revérification au prochain lancement de l’application installée. Notifications push : disponibles sur Chromium et Firefox depuis longtemps, et sur Safari iOS uniquement pour les applications installées sur l’écran d’accueil. Le principe directeur reste l’amélioration progressive : la version de base — affichage du contenu en cache et soumission différée via écoute online — fonctionne partout, et les APIs avancées (Background Sync, Periodic Sync, Push) s’ajoutent là où elles sont disponibles sans rompre le scénario par défaut.
Pour aller plus loin
Les documentations de référence à garder ouvertes pendant l’implémentation :