Quand j'ai commencé à migrer une application web vers une PWA offline-first, je me suis rapidement confrontée à une réalité simple : la synchronisation n'est pas qu'une question de transferts réseau. C'est surtout une question de confiance — faire en sorte que l'utilisateur puisse travailler hors-ligne sans craindre de perdre son travail, et revenir en ligne sans se retrouver avec des données corrompues ou des comportements surprenants. Dans cet article, je partage mon approche pour gérer les conflits et mettre en place une stratégie de rollback pragmatique, basée sur des choix de conception concrets et des patterns éprouvés.

Pourquoi l'offline-first change tout

Avec une PWA offline-first, l'app prend la décision inverse d'une app classique : elle privilégie le stockage et l'expérience locale, puis tente de propager les changements au serveur. Cela a des conséquences directes :

  • Les opérations locales doivent être persistées et résilientes (IndexedDB, localForage, ou SQLite sur mobile).
  • La logique de synchronisation doit résoudre des conflits sémantiques (deux utilisateurs modifient le même objet) et des conflits techniques (rétrogradation d'un schéma, mises à jour partielles).
  • L'UX doit rester claire : l'utilisateur doit savoir si ses modifications sont "synchronisées", "en attente", ou "en erreur".

Modèles de résolution de conflits

Voici les patterns que j'utilise selon les cas d'usage :

  • Last Write Wins (LWW) : simple et performant, il garde la version avec l'horodatage le plus récent. Idéal pour des données où la perte d'une petite modification n'est pas critique (ex. flags, préférences).
  • Merge applicatif : l'application combine les changements champs-par-champs selon des règles métier. Utile pour des objets composites (ex. fiche contact) où différentes propriétés peuvent être fusionnées.
  • CRDTs / OT : pour les données collaboratives en temps réel (éditeur de texte, listes partagées). Ces structures convergentes évitent les conflits complexes mais ajoutent de la complexité d'implémentation.
  • Server-authoritative with conflict response : le serveur refuse la modification et renvoie l'état courant avec un code d'erreur. L'app présente alors un diff et propose une action (accept, merge ou rollback).

Mon workflow favori : optimiste + reconciliation côté serveur

J'adore l'approche optimiste pour l'UX : l'action est appliquée immédiatement côté client, l'interface réagit vite, et la synchronisation se fait en tâche de fond. Mais elle nécessite un mécanisme de reconciliation solide :

  • Chaque opération locale reçoit un clientId unique et un numéro de version (ou vecteur de version).
  • Les opérations sont stockées dans une queue persistante (IndexedDB) et envoyées au serveur en FIFO ou par batch.
  • Le serveur applique les opérations en validant les versions. En cas de conflit, il renvoie une réponse structurée avec : l'état serveur, le diff, et un code d'action recommandé.
  • L'application réconcilie : soit elle applique le state server (rollback ou merge automatique), soit elle demande l'intervention de l'utilisateur.

Gérer les conflits : expérience utilisateur

Un aspect souvent sous-estimé est la communication. Voici mes principes UX :

  • Afficher l'état de synchronisation visible et compréhensible (icône + texte : "En attente", "Synchronisé", "Erreur").
  • En cas de conflit, afficher un écran de comparaison avec mise en évidence des différences et des options claires : Conserver ma version, Prendre la version serveur, Fusionner.
  • Préférer les actions guidées : proposer une fusion automatique quand c'est possible, et ne demander manuellement qu'en dernier ressort.
  • Conserver l'historique local pour permettre un rollback facile (voir section suivante).

Stratégies de rollback pragmatiques

Le rollback est la clé pour restaurer la confiance : si quelque chose tourne mal, l'utilisateur doit pouvoir revenir en arrière. Voici les techniques que j'ai mises en place :

  • Snapshots et checkpoints : avant d'appliquer une série d'opérations locales critiques, je crée un snapshot compact de l'entité. En cas d'échec majeur, je restaure le snapshot.
  • Opérations compensatoires : pour certaines actions (ex. transfert d'argent interne à une app), au lieu d'un rollback brut, j'exécute une opération inverse validée par le serveur.
  • Undo local : une file d'undo locale permet à l'utilisateur d'annuler ses actions récentes avant synchronisation. Cela évite d'envoyer des opérations à annuler côté serveur.
  • Versioning & tombstones : conserver les versions antérieures (ou des "tombstones" pour delete) simplifie la reconstruction et la résolution manuelle si nécessaire.

Comparaison rapide des approches

Approche Complexité Cas d'usage Rollback
LWW Faible Flags, préférences Simple (restauration d'anciennes versions)
Merge applicatif Moyenne Objets composites Snapshots & rollbacks ciblés
CRDT / OT Élevée Édition collaborative Convergence native (moins de rollback)
Server-authoritative Moyenne Données critiques / validation serveur Compensation + restauration depuis serveur

Implémentation technique : points concrets

Quelques conseils techniques que j'applique systématiquement :

  • Stocker les opérations dans IndexedDB avec métadonnées : {idOp, entityId, opType, payload, clientTs, baseVersion}.
  • Envoyer les opérations par batch atomique quand possible, avec des retries exponentiels et backoff.
  • Utiliser des vecteurs de version simples (version monotone par entité) ou des vector clocks pour les scénarios multi-client.
  • Exposer une API serveur de reconciliation qui renvoie toujours : {status, serverState, conflictInfo}. Ne renvoyez jamais une erreur vide.
  • Conserver un log local lisible (audit) pour faciliter le debugging et offrir la possibilité d'un rollback manuel si nécessaire.

Exemples concrets que j'ai rencontrés

Sur un projet de gestion de tâches partagées, deux utilisateurs pouvaient éditer la même tâche hors-ligne. Nous avons adopté une stratégie mixte :

  • Fusion champ-par-champ pour les champs simples (titre, description), LWW pour la priorité, et CRDT pour l'ordre des sous-tâches.
  • Snapshots avant les opérations de suppression massives.
  • Un écran de résolution de conflit qui s'ouvrait seulement si la fusion automatique n'était pas possible.

Résultat : moins de lourdeur pour l'utilisateur, et beaucoup moins d'interventions manuelles côté support.

Tests et monitoring

Ne négligez pas les tests :

  • Simulez des scénarios réseau instables avec des outils (Chrome DevTools, toxiproxy).
  • Écrivez des tests d'intégration pour la reconciliation serveur-client.
  • Monitorez les taux de conflits et la fréquence des rollbacks pour adapter vos règles de fusion.

Mettre en place une synchronisation offline-first demande certes un peu d'effort, mais c'est un investissement qui transforme l'expérience utilisateur. En combinant des choix techniques clairs, une UX transparente et des mécanismes de rollback robustes, on peut offrir une PWA qui inspire confiance — et c'est souvent ce qui différencie une bonne application d'une application que les gens abandonnent.