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édiatement
  • cleanupOutdatedCaches: 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.

1

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.

2

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".

3

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.

PWA en mode hors ligne : débit 0 Mbit/s

Hors ligne

Débit 0 Mbit/s, latence indisponible. Les actions sont enregistrées dans Dexie, la queue de sync est suspendue.

PWA avec connexion lente 2G : 0.35 Mbit/s

Connexion lente

0.35 Mbit/s, latence 2 secondes (2G). Les photos sont différées, les données texte synchronisées en priorité.

PWA avec connexion 4G active : 10 Mbit/s

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.

Tableau de bord de synchronisation PWA avec file d'attente et diagnostic de stockage Tableau de bord de synchronisation PWA avec file d'attente et diagnostic de stockage

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é.

Interface de compression de photos dans une PWA terrain Interface de compression de photos dans une PWA terrain

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

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

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

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

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

Besoin d'une application qui fonctionne en zone blanche ?

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

APIs mobiles dans une 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.

Manifest et installation A2HS

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.

Application mobile terrain sur mesure

La page commerciale qui décrit notre approche complète pour les applications de saisie et d'intervention sur le terrain.

Application de visite guidée

Un autre projet PWA en production : carte Leaflet, scan QR, audio et mode hors ligne pour des parcours touristiques et patrimoniaux.

PWA : présentation générale

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.