Back

Architecture Local-First pour les Progressive Web Apps

Architecture Local-First pour les Progressive Web Apps

Les service workers déplacent l’application vers l’appareil de l’utilisateur ; le local-first déplace les données. Cette seule distinction explique pourquoi le shell de votre PWA se charge instantanément hors ligne, tandis que chaque liste, enregistrement et formulaire reste bloqué sur un fetch() qui échoue dès que le réseau tombe. Le shell est mis en cache ; les données, non. Ils résident à des couches différentes, et c’est dans cet écart que la plupart des implémentations de « support hors ligne » dans les PWA s’effondrent silencieusement.

Cet article s’adresse aux développeurs qui ont déjà mis en production une PWA — le manifeste est valide, le service worker met les assets en cache, l’application s’installe sur l’écran d’accueil — et qui suspectent que le terme « local-first » désigne quelque chose de plus que le travail offline-first déjà accompli. C’est effectivement le cas. Le local-first est une architecture de données, et non un mécanisme d’exécution. La suite de cet article sépare clairement ces couches, montre à quoi ressemble une PWA local-first en tant que pile que l’on enrichit plutôt que l’on reconstruit, passe en revue les options de stockage et de synchronisation, et fournit un guide spécifique aux PWA pour déterminer quand ce pattern justifie sa complexité.

Points clés à retenir

  • Les service workers mettent en cache les assets et servent le shell hors ligne ; le local-first est une architecture de données dans laquelle l’appareil détient la copie principale des données, et le serveur, lorsqu’il est présent, joue le rôle d’un pair de synchronisation plutôt que d’un gardien.
  • Une PWA local-first repose sur trois couches composables : un service worker (assets et shell hors ligne), une base de données locale (lectures et écritures de données), et un moteur de synchronisation (cohérence éventuelle avec le serveur) — vous ajoutez les deuxième et troisième couches sans remplacer la première.
  • IndexedDB constitue le plancher pratique pour le stockage local des données ; SQLite compilé en WebAssembly et persisté via OPFS représente le plafond actuel, offrant une base de données relationnelle complète dans le navigateur.
  • Le last-write-wins au niveau du champ résout la majorité des conflits dans les applications typiques ; les textes collaboratifs nécessitent des CRDTs, et les conflits sémantiques (deux utilisateurs réservant la même salle) requièrent une validation côté serveur lors de la synchronisation.
  • Le local-first convient aux PWA installables de prise de notes, aux applications de service terrain et aux outils d’édition collaborative ; il n’apporte rien aux tableaux de bord affichant des données générées par le serveur, ni aux cas nécessitant des garanties ACID.

Pourquoi les données de votre PWA sollicitent encore le réseau

Une PWA dotée d’un shell mis en cache mais dont la couche de données dépend de fetch() n’est hors ligne qu’au sens le plus étroit du terme — le chrome se charge sans réseau, puis chaque composant nécessitant des données s’arrête. Le service worker peut restituer le HTML, le CSS et le JavaScript depuis la Cache API sans toucher au réseau, de sorte que l’application s’affiche hors ligne. Mais les données que ces composants affichent proviennent toujours d’une requête réseau, et un ServiceWorker interceptant un fetch() pour des données utilisateur dynamiques n’a rien d’utile à retourner lorsque ces données n’ont jamais été mises en cache ou ont changé depuis.

C’est la raison structurelle pour laquelle le travail offline-first dans les PWA s’arrête au shell. Les stratégies de mise en cache — cache-first, network-first, stale-while-revalidate — répondent à la question quelle version d’un asset servir et dans quelle mesure elle peut être périmée. Elles ne répondent pas à la question où vivent les données de l’utilisateur et qui en détient la copie faisant autorité. La Cache API stocke des réponses HTTP indexées par requête. Ce n’est pas une base de données que l’on interroge, dans laquelle on écrit et depuis laquelle on lit au sein d’un composant. Pour combler cet écart, vous avez besoin d’un véritable store de données local et d’une stratégie pour le maintenir cohérent avec le serveur. C’est cela, le local-first.

Le local-first n’est pas l’offline-first, et aucun des deux n’est un service worker

Local-first, offline-first, PWA et mise en cache par service worker sont quatre concepts distincts qui se composent mais ne sont pas interchangeables. Les confondre est la source d’erreur la plus répandue dans ce domaine.

TermeCe que c’estCouche concernée
PWAUn modèle de distribution : application web installable, pilotée par un manifeste, avec support des notifications pushPackaging
Service workerUn mécanisme d’exécution qui intercepte les requêtes et sert les assets mis en cacheRéseau / exécution
Offline-firstUn objectif de conception : dégradation gracieuse en l’absence de réseau, le serveur restant la source de véritéComportement
Local-firstUne architecture de données : l’appareil détient la copie principale ; le serveur est un pair de synchronisationDonnées

L’offline-first signifie que l’application gère gracieusement la perte du réseau, mais le serveur reste la source de vérité — le cache local est une copie de commodité qui s’efface devant la version distante. Le local-first inverse cette relation : l’appareil de l’utilisateur détient la copie principale des données, l’application lit et écrit dans une base de données locale, et le serveur, lorsqu’il est présent, est un nœud parmi d’autres qui se réconcilie en arrière-plan. Cette séparation nette est importante car elle indique ce que vous pouvez réutiliser. Votre service worker et votre framework ne changent pas. Vous ajoutez une couche de données en dessous d’eux.

Le guide local-first de Plainvanilla intègre le backend-for-frontend dans le service worker dans le cadre de son architecture. Il s’agit d’une implémentation possible, pas d’une définition. Gardez les couches distinctes : le local-first concerne l’emplacement des données et la copie faisant autorité ; le service worker est un mécanisme disponible dans le même environnement d’exécution.

Ce que signifie réellement le local-first

Le local-first est une architecture de données dans laquelle l’appareil de l’utilisateur détient la copie principale des données de l’application, l’application lit et écrit dans une base de données locale, et le serveur, lorsqu’il est présent, est un pair de synchronisation doté d’une autorité particulière plutôt qu’un gardien devant approuver chaque lecture et écriture. Du point de vue de l’utilisateur, l’application lit et écrit dans une base de données locale de manière synchrone, tandis que la synchronisation avec le serveur ou d’autres appareils s’effectue de façon asynchrone en arrière-plan. Le changement architectural est une reclassification du client : il cesse d’être une vue légère qui demande l’autorisation d’afficher des données pour devenir un participant à part entière qui possède un réplica.

La référence canonique est l’essai de 2019 Local-First Software: You Own Your Data, in Spite of the Cloud d’Ink & Switch, qui énonce sept idéaux — rapidité, multi-appareils, hors ligne, collaboratif, pérenne, privé et contrôlé par l’utilisateur. La propriété qui change fondamentalement la façon d’écrire le code est la première : puisque les lectures et les écritures transitent par une base de données locale, il n’y a pas d’indicateur isLoading sur une lecture, et les écritures peuvent mettre à jour l’état local immédiatement. L’écriture locale est l’état, tandis que la synchronisation et la résolution des conflits s’effectuent en arrière-plan.

Dans le code des composants, cela réduit une séquence fetch-and-cache à une simple requête directe. Au lieu d’un useQuery qui retourne { data, isLoading, error }, une requête local-first s’abonne à la base de données locale et se re-rend lors de chaque modification :

// Local-first: the query reads the local DB and re-renders on change.
// No isLoading, no error boundary for the read, no cache invalidation.
function TaskList({ projectId }) {
  const tasks = useLiveTasks(projectId); // subscribes to local DB
  return (
    <ul>
      {tasks.map((t) => (
        <li key={t.id}>{t.title}</li>
      ))}
    </ul>
  );
}

Le hook useLiveTasks est fourni par le moteur de synchronisation ou la couche de requêtes que vous choisissez ; c’est la forme qui importe. Une écriture est un simple insert dans le store local, l’abonnement se déclenche et l’interface se met à jour. La synchronisation avec le serveur s’effectue dès que le réseau le permet.

Les trois couches d’une PWA local-first

Une PWA local-first repose sur trois couches composables : un service worker qui met en cache les assets et sert le shell hors ligne, une base de données locale (IndexedDB ou SQLite persisté via OPFS) qui contient les données de l’utilisateur et gère toutes les lectures et écritures, et un moteur de synchronisation qui réconcilie la base de données locale avec le serveur en arrière-plan. Chaque couche est responsable d’une seule préoccupation et ignore les détails internes des autres.

CoucheRôleResponsabilitésHors périmètre
Couche 3 — Moteur de synchronisationRéconcilie la base de données locale avec le serveur.Résolution des conflits, cohérence éventuelle, push/pull en arrière-plan.Rendu, mise en cache des assets.
Couche 2 — Base de données localeContient les données de l’utilisateur. IndexedDB ou SQLite via OPFS (WASM).Toutes les lectures et écritures effectuées par l’interface.Le réseau, le shell hors ligne.
Couche 1 — Service workerMet en cache les assets et sert le shell hors ligne.Mise en cache des assets, service du shell hors ligne, cycle de vie install/activate, push.Les données utilisateur.

Adopter le local-first dans une PWA existante ne nécessite pas de remplacer le service worker ni le framework applicatif — cela implique d’ajouter une couche de données que l’application lit et écrit localement, ainsi qu’un moteur de synchronisation qui maintient cette couche cohérente avec le serveur. La couche 1 existe déjà dans votre PWA. Vous introduisez les couches 2 et 3 sous les composants qui appellent actuellement fetch(), et vous recâblez ces composants pour qu’ils lisent depuis la base de données locale.

Où vivent les données : introduction au stockage local

localStorage n’est pas la solution. Il est synchrone, ce qui bloque le thread principal ; il ne stocke que des chaînes de caractères ; et selon la documentation MDN sur la Web Storage API, sa capacité est faible et spécifique au navigateur — suffisant pour une préférence de thème, inadapté à une couche de données.

IndexedDB constitue le plancher — asynchrone, disponible dans tous les navigateurs modernes, et capable de stocker bien plus que localStorage, bien que le quota de stockage soit basé sur l’origine et spécifique au navigateur plutôt qu’un plafond fixe. Son API native est bas niveau et verbeuse — une seule écriture nécessite l’ouverture d’une transaction, le ciblage d’un object store et la mise en place de callbacks de requête — c’est pourquoi la plupart des applications ont recours à une bibliothèque d’abstraction.

Le plafond est SQLite compilé en WebAssembly, persisté dans l’Origin Private File System (OPFS). SQLite sur OPFS vous offre une base de données relationnelle complète dans le navigateur — transactions, index et une interface SQL familière s’exécutant localement sur le client. OPFS prend en charge un accès fichier synchrone haute performance via createSyncAccessHandle(), disponible uniquement dans un Web Worker — exactement le pattern d’accès dont SQLite a besoin. La distribution officielle SQLite WebAssembly documente OPFS comme backend de persistance supporté.

Dans le code, une écriture avec SQLite-over-WASM ressemble à du SQL côté serveur — une seule instruction contre un store relationnel :

// SQLite (WASM) via the official sqlite-wasm package: ordinary SQL.
await db.exec({
  sql: "INSERT INTO tasks (id, title, done) VALUES (?, ?, 0)",
  bind: [crypto.randomUUID(), "Review draft"],
});

La compatibilité navigateur d’OPFS est large fin 2025 — consultez le tableau de compatibilité MDN pour la File System API pour connaître l’état actuel par navigateur, qui fait autorité à vérifier par rapport à votre matrice de support plutôt qu’une liste de versions figée, car elle évolue. Un écueil courant en production est que le comportement d’OPFS peut différer subtilement entre les moteurs de navigateur et les contextes d’intégration ; il vaut donc la peine de conserver un chemin de repli basé sur IndexedDB pour les navigateurs et environnements où le support OPFS est indisponible, limité ou se comporte différemment de votre plateforme cible principale.

Le paysage des moteurs de synchronisation

Le moteur de synchronisation est la couche qui distingue le local-first de l’offline-first : il réconcilie le réplica local avec le serveur et les autres appareils. Vous n’avez pas besoin de le construire vous-même. Plusieurs moteurs, en production ou en émergence, occupent des niches différentes, et le bon choix dépend de la forme de votre application et de votre backend existant. Il n’existe pas de standard web pour la synchronisation, donc chaque moteur définit son propre protocole — gardez la couche de synchronisation abstraite afin qu’un changement de moteur reste faisable.

  • PowerSync — réplique un backend Postgres vers une base de données SQLite cliente avec un chemin d’écriture en retour ; particulièrement adapté si vous exploitez déjà Postgres et souhaitez ajouter le support hors ligne sans rearchitecturer le serveur.
  • Electric — un moteur de synchronisation Postgres construit autour de « Shapes » qui définissent quelles données chaque client reçoit. Particulièrement adapté aux applications nécessitant une réplication partielle et une synchronisation de données par utilisateur sur un backend Postgres existant.
  • Replicache et Zero (tous deux de Rocicorp) — des systèmes de synchronisation pilotés par les requêtes qui privilégient les expériences utilisateur local-first à la réplication au niveau des lignes. Particulièrement adaptés aux applications construites autour de mutations côté client, de mises à jour optimistes et de réconciliation côté serveur.
  • Triplit — une base de données full-stack avec la synchronisation intégrée, de sorte que la base de données client et serveur forment un seul modèle mental plutôt que deux ; convient aux projets greenfield qui souhaitent la synchronisation par défaut.
  • Yjs et Automerge — des bibliothèques CRDT pour l’édition collaborative ; le bon choix lorsque plusieurs utilisateurs éditent simultanément le même texte enrichi ou document structuré et que vous avez besoin d’une fusion au niveau du caractère.

Pour la plupart des PWA métier qui synchronisent des enregistrements appartenant à l’utilisateur, un moteur de réplication de lignes sur votre base de données existante est plus adapté qu’une bibliothèque CRDT. Faites appel à Yjs ou Automerge spécifiquement lorsque l’édition collaborative de texte en temps réel est au cœur du produit, et non comme solution générale aux conflits.

Résoudre les conflits

Lorsque deux réplicas modifient les mêmes données sans avoir connaissance des modifications de l’autre, le moteur de synchronisation doit les réconcilier. Les conflits se répartissent en trois catégories, chacune avec une stratégie de résolution différente.

  1. Conflits structurels — last-write-wins au niveau du champ. Le last-write-wins au niveau du champ gère la majorité des conflits dans les applications typiques : si deux utilisateurs modifient des champs différents du même enregistrement hors ligne, les deux modifications sont conservées ; s’ils modifient le même champ, c’est l’horodatage le plus récent qui l’emporte. Il s’agit d’une règle empirique largement utilisée dans la communauté local-first plutôt que d’une constante mesurée — Designing Data-Intensive Applications de Martin Kleppmann couvre en profondeur les compromis de cohérence du last-write-wins.

  2. Conflits de texte collaboratif — CRDTs. Lorsque deux utilisateurs saisissent dans le même paragraphe, le last-write-wins efface les frappes de l’un d’eux. Les Conflict-free Replicated Data Types fusionnent les modifications concurrentes au niveau du caractère, de sorte que les deux ensembles de caractères apparaissent de manière cohérente. Yjs et Automerge implémentent cela ; les mécanismes de fusion diffèrent, mais la garantie est la même — les modifications concurrentes convergent sans coordinateur central.

  3. Conflits sémantiques — validation côté serveur. Certains conflits fusionnent proprement au niveau structurel mais produisent un résultat qui viole un invariant métier. Exemple : deux utilisateurs hors ligne réservent chacun la même salle de réunion à 14h. Les deux écritures ciblent des enregistrements différents, donc une fusion au niveau du champ accepte les deux — structurellement correct, mais une double réservation. Les conflits sémantiques, où les données fusionnent proprement mais le résultat viole un invariant métier, requièrent une validation côté serveur lors de la synchronisation. Le pattern qui évite la perte de données consiste à accepter l’écriture conflictuelle, signaler la violation et la présenter à l’utilisateur comme une notification à résoudre, plutôt que de la rejeter silencieusement — un rejet laisse le client avec des enregistrements que le serveur refuse de reconnaître.

Les bugs les plus difficiles dans les PWA local-first ne sont pas les échecs de synchronisation — ceux-ci apparaissent dans les logs. Ce sont les écrasements silencieux : une écriture optimiste réussit localement, la synchronisation se termine sans erreur, et la modification de l’utilisateur est discrètement remplacée par une version distante qui a gagné la course au last-write-wins. L’échec est invisible dans les logs serveur, la surveillance des erreurs et les traces réseau ; il ne devient lisible que dans un outil qui enregistre la séquence des états de l’interface — la mise à jour optimiste et le retour arrière silencieux qui suit. Le replay de session est l’un des rares endroits où ce décalage apparaît comme une séquence observable plutôt qu’une simple ligne de log.

Quand le local-first convient spécifiquement à votre PWA

Le local-first convient à une PWA lorsque les données gérées par l’application appartiennent à l’utilisateur et bénéficient d’une interaction locale instantanée ; il n’apporte rien lorsque les données sont générées par le serveur ou soumises à des garanties transactionnelles fortes. La question déterminante n’est pas la catégorie de l’application dans l’absolu — c’est de savoir si les données ont leur place sur l’appareil.

Cas d’usage PWALe local-first aide-t-il ?Pourquoi
Application de prise de notes installableOuiDonnées appartenant à l’utilisateur, lectures/écritures instantanées, l’édition hors ligne est la valeur fondamentale ; le shell installable et les données locales se renforcent mutuellement.
Application de service terrain sur connectivité instableOuiLe travail s’effectue là où le réseau est faible ; les écritures doivent réussir hors ligne et se synchroniser dès que la couverture est rétablie.
Outil d’édition collaborativeOuiL’édition concurrente est le cœur du produit ; la synchronisation basée sur les CRDTs permet la fusion en temps réel sans aller-retour réseau à chaque frappe.
Tableau de bord analytiqueNonLe serveur génère les données ; mettre le shell en cache est utile, mais le local-first n’apporte rien à des données que l’utilisateur ne possède pas et ne modifie pas.
Tunnel de paiement e-commerceNonLe paiement et l’inventaire nécessitent des garanties ACID et une base de données unique faisant autorité ; la cohérence éventuelle peut survendre des stocks ou effectuer des doubles débits.
Fil d’actualité socialNonLe fil est classé et géré par le serveur ; le client le consomme plutôt qu’il ne le crée.

Le pattern s’aligne avec les points forts des PWA précisément là où l’utilisateur crée et possède les données. Pour une PWA de prise de notes, le shell installable, l’écriture hors ligne et la synchronisation en arrière-plan composent une expérience proche d’une application native. Pour un tableau de bord, le service worker peut mettre le shell en cache pour un chargement plus rapide, mais les chiffres proviennent toujours du serveur — le local-first résout un problème que ce cas d’usage n’a pas.

Vous ajoutez une couche de données, vous ne reconstruisez pas l’application

Intégrer le local-first dans une PWA existante signifie ajouter deux couches, et non reconstruire ce que vous avez : une base de données locale et un slot de moteur de synchronisation sous les composants qui tournent actuellement sur fetch(), tandis que le service worker et le framework restent en place. Vous ne remplacez pas le travail PWA déjà accompli — vous le complétez, en déplaçant les données vers l’utilisateur de la même façon que le service worker a déjà déplacé l’application. La prochaine étape concrète est modeste : choisissez une fonctionnalité dont les données appartiennent à l’utilisateur et qu’il modifie, appuyez-la sur IndexedDB ou SQLite via OPFS, câblez son composant pour qu’il lise depuis ce store, et laissez un moteur de synchronisation la réconcilier en arrière-plan. Cette seule fonctionnalité vous dira, plus vite que toute lecture supplémentaire, si le reste de votre application a sa place là aussi.

FAQ

En général oui, mais son rôle change. Dans une architecture local-first, le serveur cesse d'être un gardien qui approuve chaque lecture et écriture pour devenir un pair de synchronisation qui réconcilie la base de données locale entre les appareils, persiste une copie durable et applique la validation côté serveur pour les conflits sémantiques tels que les doubles réservations. Les applications nécessitant des garanties ACID, des paiements ou un état partagé faisant autorité requièrent toujours un vrai backend ; seul le chemin de lecture et d'écriture à travers l'interface se déplace vers la base de données locale.

Non. Ils opèrent à des couches différentes et se composent plutôt que de se concurrencer. Le service worker met en cache les assets et sert le shell hors ligne ; le local-first ajoute une base de données locale et un moteur de synchronisation sous les composants qui appellent actuellement fetch. Intégrer le local-first dans une PWA existante signifie conserver le service worker et le framework en place et introduire les couches de données et de synchronisation en dessous, sans réécrire la couche 1.

Choisissez les CRDTs lorsque l'édition concurrente du même texte enrichi ou document structuré est le produit lui-même, car le last-write-wins efface les frappes d'un utilisateur lorsque deux personnes saisissent dans le même paragraphe. Les Conflict-free Replicated Data Types fusionnent les modifications concurrentes au niveau du caractère, de sorte que les deux ensembles de caractères convergent sans coordinateur central. Pour la plupart des PWA métier synchronisant des enregistrements appartenant à l'utilisateur, le last-write-wins au niveau du champ sur un moteur de réplication de lignes est plus adapté ; réservez Yjs ou Automerge pour le texte collaboratif en temps réel.

Il s'agit d'un écrasement silencieux. Une écriture optimiste réussit localement, la synchronisation se termine sans erreur, et une version distante gagne la course au last-write-wins, remplaçant discrètement la modification locale. L'échec est invisible dans les logs serveur, qui indiquent un succès, dans la surveillance des erreurs, qui ne lève aucune exception, et dans les traces réseau, qui montrent que la requête a abouti. Il ne devient lisible que dans un outil qui enregistre la séquence des états de l'interface, capturant à la fois la mise à jour optimiste et le retour arrière qui suit.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data. Check our GitHub repo and join the thousands of developers in our community.

OpenReplay