PWA : Progressive Web App / Architecture offline-first
Mode hors ligne PWA : Service Worker + IndexedDB en pratique
Le mode hors ligne d'une PWA repose sur deux couches indépendantes qui ne font pas la même chose : le Service Worker met en cache les fichiers de l'application, IndexedDB stocke les données saisies par l'utilisateur. Les deux sont nécessaires. La confusion entre les deux est la principale source d'erreur dans les implémentations offline-first.
Nous documentons ici l'architecture que nous utilisons dans nos projets PWA, avec le code réel issu de nos projets : vite-plugin-pwa + Workbox pour les fichiers, Dexie.js pour les données et une queue de synchronisation FIFO pour la remontée vers le backend.
ARCHITECTURE
Deux couches indépendantes, deux rôles distincts
La confusion vient du fait que les deux stockent des données dans le navigateur. Mais leurs rôles sont fondamentalement différents et aucune ne remplace l'autre.
Service Worker + Workbox : les fichiers de l'app
Le Service Worker intercepte les requêtes réseau et sert les fichiers (HTML, JS, CSS, images) depuis le cache local. Workbox gère les stratégies : CacheFirst pour l'app shell (les fichiers statiques précachés à l'installation), NetworkOnly pour les API dynamiques. Sans lui, l'app ne se charge pas sans connexion.
Options critiques à ne pas oublier
clientsClaim: true, le nouveau SW prend le contrôle immédiatementcleanupOutdatedCaches: true, purge les anciens caches hachés à chaque build
Le SW cache les fichiers de l'application, pas les données utilisateur.
IndexedDB + Dexie.js : les données utilisateur
IndexedDB est une base de données locale dans le navigateur. Dexie.js encapsule l'API native avec une interface claire, des migrations versionnées (indispensables en production) et un support TypeScript. Nos migrations sont versionnés et couvrent l'évolution du schéma depuis le premier déploiement.
Pattern de persistance
Écrire dans Dexie d'abord, ajouter une opération de synchro, synchroniser au retour du réseau. L'utilisateur ne voit jamais d'erreur réseau.
Sans IndexedDB, les données saisies disparaissent au rechargement.
LE FLUX OFFLINE-FIRST
Ce qui se passe du point de vue du technicien
L'utilisateur ne perçoit pas la complexité sous-jacente : il saisit, voit un indicateur de sync en attente et retrouve ses données synchronisées au retour au bureau.
Saisie hors ligne
Le technicien est en zone blanche. Il crée une intervention, prend des photos,
scanne un QR code. Chaque action est écrite dans Dexie avec syncStatus: 'pending'.
Une entrée est ajoutée à la syncQueue. L'interface reste fluide.
Retour du réseau
useNetwork détecte le changement online via VueUse et mesure
la latence active. Si la qualité est suffisante, la sync démarre automatiquement.
L'indicateur dans le header passe de "en attente" à "synchronisation en cours".
Synchronisation
La queue FIFO est traitée dans l'ordre. Chaque item (create, update, delete) appelle
le backend (Supabase, Neon ou autre BaaS ...). Les photos compressées
sont uploadées vers le stockage. syncStatus passe à 'synced'.
Note d'implémentation : notre synchronisation se déclenche quand l'application est ouverte et une connexion détectée. Ce n'est pas l'API Background Sync du navigateur (sync app fermée) : celle-ci n'est pas supportée sur iOS. Le choix d'une queue foreground est délibéré : elle fonctionne sur tous les appareils et reste observable par l'utilisateur via l'indicateur de statut.
Hors ligne
Débit 0 Mbit/s, latence indisponible. Les actions sont enregistrées dans Dexie, la queue de sync est suspendue.
Connexion lente
0.35 Mbit/s, latence 2 secondes (2G). Les photos sont différées, les données texte synchronisées en priorité.
En ligne
10 Mbit/s, latence 465 ms (4G). La queue s'exécute dans l'ordre chronologique, photos comprises.
TABLEAU DE BORD
Données locales, backend et file d'attente
L'écran de synchronisation affiche en temps réel les compteurs backend (via API) et locaux (Dexie), ainsi que le mode actif selon la qualité réseau : en connexion normale, texte et photos partent ensemble. En connexion lente, seules les données texte sont envoyées et les photos restent en file d'attente.
La FILE D'ATTENTE liste chaque opération dans l'ordre FIFO (création, modification, suppression). Le diagnostic de stockage décompose l'espace par couche : IndexedDB pour les données, Cache Storage pour les fichiers de l'app, Service Worker pour le manifest.
OPTIMISATION
Photos terrain : compression côté client avant upload
Sur réseau mobile dégradé, uploader une photo de 4 Mo c'est la différence entre une sync réussie et un timeout. Nous compressons systématiquement côté client avant de stocker dans Dexie.
La compression est faite avec browser-image-compression dans un Web Worker (useWebWorker: true) pour ne pas bloquer le thread principal. Si la compression n'apporte pas de gain, le fichier original est conservé.
3 à 5 Mo
Photo brute smartphone, JPEG natif pleine résolution.
150 à 300 Ko
Après compression : 1200px max, JPEG 75% via browser-image-compression.
50 photos
Environ 10 Mo par intervention, dans les quotas habituels.
Documentation développeurs
Code source architecture offline
Extraits réels des composables et de la configuration utilisés dans nos PWA. Chaque décision technique est commentée dans le code.
Configuration Workbox : vite-plugin-pwa
Options critiques : clientsClaim et cleanupOutdatedCaches
Configuration Workbox : vite-plugin-pwa
Options critiques : clientsClaim et cleanupOutdatedCaches
Extrait de vite.config.ts du POC terrain. Les deux options commentées sont indispensables en production et souvent omises dans les tutoriels.
VitePWA({
registerType: 'autoUpdate',
workbox: {
globPatterns: ['**/*.{js,css,html,svg,png,woff2}'],
navigateFallback: 'index.html',
// Sans clientsClaim, le nouveau SW s'installe mais ne prend pas le contrôle
// des onglets déjà ouverts : l'ancien SW (sans les nouveaux assets) reste actif
// jusqu'au rechargement suivant. Résultat : page "hors connexion" au refresh offline.
clientsClaim: true,
// Vite hache les fichiers à chaque build (ex: index-Bi_bQYCK.js).
// Sans cette option, l'ancien SW garde les URLs hachées de la version précédente
// en cache → les nouveaux fichiers sont introuvables → 404 au chargement offline.
cleanupOutdatedCaches: true,
runtimeCaching: [
{
// Les requêtes Supabase (API + Storage) passent toujours par le réseau.
// On ne met jamais en cache des données d'authentification ou des fichiers
// utilisateur qui peuvent changer à tout moment côté backend.
urlPattern: /^https:\/\/.*\.supabase\.co\//,
handler: 'NetworkOnly',
},
],
},
devOptions: {
// Active le Service Worker en mode développement pour tester l'installabilité.
// beforeinstallprompt ne se déclenche jamais sans SW actif.
enabled: true,
},
})
Stratégie choisie : CacheFirst pour l'app shell (fichiers statiques précachés par Workbox à l'installation), NetworkOnly pour toutes les requêtes vers Supabase. Pas de StaleWhileRevalidate sur les données utilisateur : on ne veut pas afficher des données périmées sur une application métier.
Schema Dexie avec migrations versionnées
extrait de db/index.ts du POC terrain
Schema Dexie avec migrations versionnées
extrait de db/index.ts du POC terrain
Chaque évolution du schéma déclenche une migration versionnée. Dexie applique automatiquement les migrations manquantes au démarrage de l'app : un utilisateur qui n'a pas ouvert l'app depuis 3 versions repassera par toutes les étapes dans l'ordre.
import Dexie, { type EntityTable } from 'dexie'
const db = new Dexie('pocTerrain') as Dexie & {
interventions: EntityTable<Intervention, 'uuid'>
syncQueue: EntityTable<SyncQueueItem, 'id'>
photos: EntityTable<InterventionPhoto, 'uuid'>
}
// v1 → schéma initial
db.version(1).stores({
interventions: 'uuid, syncStatus, createdAt',
syncQueue: '++id, type, status, itemId, createdAt, [itemId+type]',
})
// v3 → ajout table photos (aucun upgrade nécessaire, table vide)
db.version(3).stores({
interventions: 'uuid, syncStatus, createdAt, status',
syncQueue: '++id, type, status, itemId, createdAt, [itemId+type]',
photos: 'uuid, interventionUuid, createdAt',
})
// v5 → champ pinnedForOffline (cache hybride : fix une intervention hors ligne)
db.version(5).stores({
interventions: 'uuid, syncStatus, createdAt, status',
syncQueue: '++id, type, status, itemId, createdAt, [itemId+type]',
photos: 'uuid, interventionUuid, syncStatus, createdAt',
}).upgrade(tx =>
tx.table('interventions').toCollection().modify((item: Intervention) => {
if (item.pinnedForOffline === undefined) item.pinnedForOffline = false
})
)
// v6 → champ storageUrl optionnel sur les photos (aucun upgrade nécessaire)
db.version(6).stores({ /* identique v5 */ })
Index composé [itemId+type] sur syncQueue : permet de dédupliquer les opérations en queue avec une requête where('[itemId+type]').equals([id, 'delete']) au lieu d'un scan complet de la table, ce qui est important quand la queue grossit en zone sans réseau prolongée.
Queue de synchronisation FIFO : useSyncQueue.ts
Déduplication des entrées pour éviter les appels redondants au backend
Queue de synchronisation FIFO : useSyncQueue.ts
Déduplication des entrées pour éviter les appels redondants au backend
La queue est un singleton module-level : elle est chargée une seule fois au premier import du composable et partagée entre tous les composants qui l'utilisent. syncStatus (computed) reflète l'état réel en temps réel.
// Singleton module-level : chargé dès le premier import, partagé partout
const queue = ref<SyncQueueItem[]>([])
const syncStatus = computed<'ok' | 'pending' | 'error'>(() => {
if (queue.value.some(item => item.status === 'error')) return 'error'
if (queue.value.length > 0) return 'pending'
return 'ok'
})
async function enqueueCreate(itemId: string): Promise<void> {
await db.syncQueue.add({ type: 'create', status: 'pending', itemId, createdAt: now() })
await loadQueue()
}
async function enqueueUpdate(itemId: string): Promise<void> {
// Déduplication : si un create/update est déjà en queue pour cet item,
// inutile d'en empiler un autre : au moment du traitement on lira
// toujours la dernière version de l'item depuis Dexie.
const existing = await db.syncQueue.where({ itemId }).first()
if (!existing) {
await db.syncQueue.add({ type: 'update', status: 'pending', itemId, createdAt: now() })
}
await loadQueue()
}
async function enqueueDelete(itemId: string): Promise<void> {
// Déduplique uniquement contre un delete existant pour le même item.
// Un create/update déjà en queue reste (il sera traité avant, puis la
// suppression sera exécutée dans l'ordre FIFO).
const existingDelete = await db.syncQueue
.where('[itemId+type]').equals([itemId, 'delete']).first()
if (!existingDelete) {
await db.syncQueue.add({ type: 'delete', status: 'pending', itemId, createdAt: now() })
}
await loadQueue()
}
Soft-delete sur les photos : quand un technicien supprime une photo hors ligne, on passe syncStatus à 'pending_delete' dans Dexie sans supprimer l'enregistrement. La vraie suppression (Storage + Supabase) se fait à la prochaine synchronisation.
Détection qualité réseau : useNetwork.ts
Latence mesurée activement + Network Information API
Détection qualité réseau : useNetwork.ts
Latence mesurée activement + Network Information API
La détection navigator.onLine seule est insuffisante : un appareil peut être "online" sur un réseau EDGE à 50 ko/s. Notre composable mesure la latence réelle par un HEAD vers l'origine pour qualifier la connexion en 'offline', 'slow' ou 'normal'.
// VueUse gère les événements online/offline et l'API Network Information
const { isOnline, type, effectiveType, downlink } = useVueNetwork()
async function check(): Promise<NetworkStatus> {
const start = performance.now()
await fetch(window.location.origin + '/', {
method: 'HEAD',
cache: 'no-store',
signal: AbortSignal.timeout(5000),
})
const latency = Math.round(performance.now() - start)
return {
isOnline: isOnline.value,
latency, // ms, mesuré activement
connectionType: type.value, // 'wifi' | 'cellular' | 'ethernet'...
effectiveType: effectiveType.value, // 'slow-2g' | '2g' | '3g' | '4g'
downlink: downlink.value, // Mbit/s estimé
quality: latency > 2000 ? 'slow' : 'normal',
}
}
// Re-déclenche check() à chaque changement online/offline détecté par VueUse
watch(isOnline, () => { check() })
La valeur seuil de 2000 ms est configurable. En dessous de ce seuil, la sync se lance immédiatement. Au-dessus, l'app priorise les données texte et diffère les uploads photos.
FAQ
Les réponses à vos questions
Et si vous ne trouvez pas ce que vous cherchez, nous serons ravis de vous répondre en direct lors d'un rendez-vous entre humains !
Le Service Worker met en cache les fichiers de l'application : HTML, CSS, JavaScript, images. C'est ce qui permet à l'app de se charger sans réseau. IndexedDB stocke les données saisies par l'utilisateur : formulaires, photos, enregistrements. Les deux sont indépendants et complémentaires. Sans Service Worker, l'app ne se charge pas hors ligne. Sans IndexedDB, les données saisies disparaissent au rechargement. Une application offline-first a besoin des deux.
Oui, si l'espace disque de l'appareil est saturé, le navigateur peut vider le stockage non persistant. Pour l'éviter, l'API StorageManager permet de demander la persistance explicite : navigator.storage.persist(). Une fois accordée, seul une action manuel de l'utilisateur peut les supprimer. Dans nos projets terrain, on active cette option systématiquement au premier lancement et on affiche une alerte si le quota est refusé.
Dans notre implémentation, la synchronisation se déclenche quand l'application est ouverte et qu'une connexion est détectée. Ce n'est pas l'API Background Sync du navigateur (qui permettrait une sync app fermée) : cette API n'est pas supportée sur iOS. Le choix d'une queue foreground est délibéré : elle fonctionne sur tous les appareils, elle est testable et observable par l'utilisateur via un indicateur de statut visible dans l'interface.
Dans nos PWA, chaque intervention appartient à un seul technicien (UUID client-side). Les conflits n'arrivent pas car les périmètres sont disjoints. Pour des cas multi-utilisateurs sur les mêmes données, les stratégies courantes sont : last-write-wins (simple mais risqué), timestamp de dernière modification côté serveur (rejette les écritures périmées), ou CRDT (complexe, adapté aux éditeurs collaboratifs en temps réel). Le choix dépend du métier : pour les applications terrain mono-utilisateur, last-write-wins suffit.
Le quota IndexedDB dépend de l'espace disque disponible sur l'appareil. En pratique : Chrome alloue jusqu'à 60% de l'espace disponible, Safari est plus restrictif (environ 50 Mo par défaut sans persistance explicite). L'API StorageManager.estimate() retourne l'espace utilisé et le quota. Pour les applications photos intensives, on compresse systématiquement à 1200px / JPEG 75% (150-300 Ko par photo au lieu de 3-5 Mo). 50 photos compressées occupent environ 10 Mo.
Oui, si la contrainte offline est connue. Ajouter le mode hors ligne sur une application existante conçue pour être online est difficile : les composants supposent qu'une API est disponible, les validations sont côté serveur, les UUIDs sont générés par la base de données. Une architecture offline-first définit des UUIDs côté client (nous utilisons UUIDv7, trié chronologiquement), stocke d'abord dans Dexie puis synchronise en arrière-plan. Changer cette logique après coup demande une réécriture partielle.
Votre projet
GPS, photos, formulaires : vos techniciens saisissent sur le terrain, la sync se fait au retour au bureau. Discutons de l'architecture adaptée à vos cas d'usage.
POUR ALLER PLUS LOIN
Approfondir la stack PWA
GPS, caméra, scan QR, microphone et torche : les composables Vue.js que nous utilisons pour accéder aux capacités matérielles de l'appareil.
Configurer le manifest, gérer le prompt d'installation sur Android et iOS, détecter si la PWA est déjà installée en mode standalone.
La page commerciale qui décrit notre approche complète pour les applications de saisie et d'intervention sur le terrain.
Un autre projet PWA en production : carte Leaflet, scan QR, audio et mode hors ligne pour des parcours touristiques et patrimoniaux.
La page hub qui présente l'ensemble de la stack PWA, le comparatif avec le développement natif et les cas d'usage métier.
Vous avez un projet ?
Contactez-nous pour savoir comment nous pouvons vous aider.