Local-first software : applications hors ligne et sync CRDT
1. Aperçu
La plupart des applications actuelles considèrent le serveur comme le détenteur des données « vraies », tandis que le client affiche seulement. Perdre la connexion rend l’application presque inutilisable. Local-first software inverse cette approche : les données résident d’abord sur l’appareil de l’utilisateur, le serveur aide uniquement à la synchronisation.
Cet article explique ce qu’est le local-first, pourquoi il retient l’attention, et le rôle de CRDT (Conflict-free Replicated Data Type) pour fusionner les données sans verrous ni « gagnant unique ». Après cette lecture, vous pourrez implémenter quelques CRDT basiques en Java 21, construire un endpoint de synchronisation avec Spring Boot 3, et savoir quand ne pas choisir cette approche.
2. Qu’est-ce que le local-first et pourquoi attire-t-il l’attention
Une application local-first a généralement les caractéristiques suivantes :
- Lecture/écriture instantanée sur l’appareil : chaque opération d’écriture va dans un stockage local (SQLite, IndexedDB, fichier…), sans attendre un aller-retour réseau.
- Fonctionnement hors ligne : sans connexion, vous pouvez toujours modifier les données. Une fois reconnecté, la synchronisation reprend.
- Collaboration multi-appareils, multi-utilisateurs : les répliques peuvent être modifiées en même temps et convergent automatiquement vers le même état.
- L’utilisateur possède ses données : si le serveur disparaît, les données restent sur l’appareil.
Plusieurs raisons expliquent pourquoi cette approche est discutée davantage :
- Attentes en matière d’expérience utilisateur : les utilisateurs s’attendent à ce que l’application réponde instantanément, sans spinner après chaque clic.
- Appareils plus puissants : les téléphones et ordinateurs portables peuvent maintenant exécuter une base de données locale et la logique de fusion des données.
- Écosystème plus mature : des bibliothèques CRDT comme Automerge et Yjs, ainsi que de nombreux moteurs de synchronisation pour SQLite, rendent l’implémentation pratique plus facile qu’auparavant.
- Confidentialité et souveraineté des données : conserver les données sur la machine de l’utilisateur réduit la dépendance envers un serveur centralisé.
Le défi majeur reste la synchronisation. Quand deux appareils modifient les données hors ligne et se reconnectent, qui a raison ?
3. Comment les CRDT résolvent les conflits
Un CRDT est une structure de données conçue de sorte que chaque réplique, après avoir reçu toutes les mises à jour, converge toujours vers le même état, indépendamment de l’ordre de réception. Pas besoin d’arbitrage serveur, pas besoin de verrous.
Il y a deux familles principales :
- State-based (CvRDT) : les répliques s’envoient l’état complet et appellent
merge. - Operation-based (CmRDT) : les répliques envoient chaque opération. Le canal de transmission doit garantir que chaque opération est livrée une seule fois.
Cet article utilise le state-based car il est plus facile à comprendre et à illustrer. Pour converger, la fonction merge doit satisfaire trois propriétés :
Commutativité : merge(a, b) == merge(b, a)
Associativité : merge(merge(a, b), c) == merge(a, merge(b, c))
Idempotence : merge(a, a) == a
Grâce à ces trois propriétés, les messages qui arrivent en retard, dans le désordre ou en double ne faussent pas le résultat.
4. Implémentation de quelques CRDT basiques
Les exemples ci-dessous n’utilisent que le JDK. La partie Spring Boot à la section 5 nécessitera des dépendances supplémentaires.
4.1. G-Counter : compteur croissant uniquement
Supposons que vous vouliez compter le nombre de vues d’une Order sur plusieurs appareils. Chaque réplique n’incrémente que sa propre cellule. Lors de la fusion, on prend la valeur maximale de chaque cellule.
import java.util.HashMap;
import java.util.Map;
public final class GCounter {
private final Map<String, Long> counts = new HashMap<>();
public void increment(String replicaId) {
counts.merge(replicaId, 1L, Long::sum);
}
public long value() {
return counts.values().stream().mapToLong(Long::longValue).sum();
}
public void merge(GCounter other) {
other.counts.forEach((id, n) -> counts.merge(id, n, Math::max));
}
}
increment ne touche que la cellule de replicaId, donc deux répliques ne s’écrasent jamais mutuellement. merge utilise Math::max, et le maximum est commutatif, associatif et idempotent. Essayez rapidement :
GCounter phone = new GCounter();
GCounter laptop = new GCounter();
phone.increment("phone");
phone.increment("phone");
laptop.increment("laptop");
phone.merge(laptop);
laptop.merge(phone);
phone.merge(laptop); // fusion répétée ne fausse pas le résultat
System.out.println(phone.value() + " " + laptop.value());
3 3
Pour supporter la décrémentation, vous utiliseriez un PN-Counter : deux G-Counter, l’un pour l’incrémentation et l’autre pour la décrémentation, la valeur étant la différence.
4.2. LWW-Register : valeur « dernière écriture gagne »
Pour un champ unique comme Customer.displayName, le plus simple est de garder la valeur avec l’horodatage le plus récent. Si l’horodatage est identique, on utilise replicaId pour un résultat toujours déterministe.
public record LwwRegister<T>(T value, long timestamp, String replicaId) {
public LwwRegister<T> merge(LwwRegister<T> other) {
if (other.timestamp != timestamp) {
return other.timestamp > timestamp ? other : this;
}
return other.replicaId.compareTo(replicaId) > 0 ? other : this;
}
}
var a = new LwwRegister<>("Lan", 100, "phone");
var b = new LwwRegister<>("Lan Nguyễn", 105, "laptop");
System.out.println(a.merge(b).value() + " | " + b.merge(a).value());
Lan Nguyễn | Lan Nguyễn
Note : Les horloges des appareils divergent généralement. Une machine qui court quelques minutes d’avance « gagnera » à chaque écriture. En pratique, on utilise souvent un Hybrid Logical Clock ou une horloge de Lamport au lieu du temps physique pur.
LWW est simple mais perdra silencieusement l’une des deux modifications. Pour du texte collaboratif, cette approche n’est pas acceptable. Vous auriez alors besoin d’un CRDT pour les chaînes de caractères, comme RGA ou les algorithmes utilisés par Automerge et Yjs.
4.3. OR-Set : ensemble d’ajout/suppression
Prenons une liste de courses partagée. Une implémentation naïve se casserait si un appareil supprime « Lait » tandis que l’autre vient de le rajouter. Observed-Remove Set résout ce problème en attachant une balise unique à chaque add. La commande remove supprime uniquement les balises que cette réplique a déjà vues.
import java.util.*;
public final class ORSet {
private final Map<String, Set<String>> adds = new HashMap<>();
private final Map<String, Set<String>> removes = new HashMap<>();
public void add(String item) {
adds.computeIfAbsent(item, k -> new HashSet<>()).add(UUID.randomUUID().toString());
}
public void remove(String item) {
removes.computeIfAbsent(item, k -> new HashSet<>())
.addAll(adds.getOrDefault(item, Set.of()));
}
public Set<String> elements() {
Set<String> result = new TreeSet<>();
adds.forEach((item, tags) -> {
Set<String> alive = new HashSet<>(tags);
alive.removeAll(removes.getOrDefault(item, Set.of()));
if (!alive.isEmpty()) result.add(item);
});
return result;
}
public void merge(ORSet other) {
union(adds, other.adds);
union(removes, other.removes);
}
private static void union(Map<String, Set<String>> target, Map<String, Set<String>> source) {
source.forEach((k, v) -> target.computeIfAbsent(k, x -> new HashSet<>()).addAll(v));
}
public ORSetState toState() {
return new ORSetState(adds, removes);
}
public static ORSet fromState(ORSetState state) {
ORSet set = new ORSet();
union(set.adds, state.adds());
union(set.removes, state.removes());
return set;
}
}
import java.util.Map;
import java.util.Set;
public record ORSetState(Map<String, Set<String>> adds, Map<String, Set<String>> removes) {}
Analyse du fonctionnement :
addgénère un UUID unique à chaque ajout, même quand on rajoute un élément existant.removecopie les balises existantes de l’élément versremoves. Les balises ajoutées par une autre réplique mais non encore vues par celle-ci restent vivantes.elementsretourne un élément qui a au moins une balise non supprimée. Résultat : quand ajout et suppression se produisent simultanément, l’ajout gagne (add-wins).mergeest juste une union d’ensembles, elle satisfait donc les trois propriétés de la section 3.ORSetStateest un record simple pour que Jackson le sérialise en JSON lors de la synchronisation.
5. Synchronisation via serveur avec Spring Boot
Dans le modèle local-first, le serveur ne décide pas qui a raison. C’est juste une réplique toujours en ligne, aidant les appareils à échanger leur état. Ajoutez la dépendance web (la version est gérée par spring-boot-starter-parent) :
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
Le contrôleur reçoit l’état du client, le fusionne avec celui du serveur, puis retourne l’état fusionné :
import org.springframework.web.bind.annotation.*;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@RestController
@RequestMapping("/lists/{listId}")
public class ShoppingListSyncController {
private final Map<String, ORSet> store = new ConcurrentHashMap<>();
@PostMapping("/sync")
public synchronized ORSetState sync(@PathVariable String listId,
@RequestBody ORSetState clientState) {
ORSet serverCopy = store.computeIfAbsent(listId, id -> new ORSet());
serverCopy.merge(ORSet.fromState(clientState));
return serverCopy.toState();
}
}
Le côté client fait l’opération inverse : envoie l’état, reçoit l’état retourné, puis appelle merge sur sa copie locale. Le client peut envoyer-recevoir plusieurs fois car merge est idempotent, donc la réessai en cas de défaillance réseau est sûr. Essayez avec curl :
curl -s -X POST localhost:8080/lists/family/sync \
-H "Content-Type: application/json" \
-d '{"adds":{"Lait":["t1"],"Œuf":["t2"]},"removes":{"Œuf":["t2"]}}'
{"adds":{"Lait":["t1"],"Œuf":["t2"]},"removes":{"Œuf":["t2"]}}
Le client calcule elements() à partir de l’état retourné et obtient [Lait].
Note :
synchronizedetConcurrentHashMapne conviennent que pour l’illustration. En production, vous devriez stocker l’état dans une base de données et envoyer uniquement les modifications (delta) au lieu de l’état complet. Vous devez également ajouter l’authentification et l’autorisation pour chaquelistId.
6. Tester le comportement de convergence
Le test le plus crucial est : deux répliques modifient simultanément, la fusion dans n’importe quel ordre doit produire le même résultat.
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class ORSetTest {
@Test
void concurrentAddAndRemove_convergeWithAddWins() {
ORSet phone = new ORSet();
phone.add("Lait");
ORSet laptop = ORSet.fromState(phone.toState()); // synchronisation initiale
phone.remove("Lait"); // hors ligne : le téléphone supprime
laptop.add("Lait"); // hors ligne : l'ordinateur portable rajoute
laptop.add("Pain");
ORSet a = ORSet.fromState(phone.toState());
a.merge(laptop);
ORSet b = ORSet.fromState(laptop.toState());
b.merge(phone);
assertThat(a.elements()).isEqualTo(b.elements())
.containsExactly("Pain", "Lait");
}
@Test
void merge_isIdempotent() {
ORSet list = new ORSet();
list.add("Œuf");
list.merge(ORSet.fromState(list.toState()));
assertThat(list.elements()).containsExactly("Œuf");
}
}
Le premier test vérifie la commutativité et la règle add-wins. « Lait » subsiste car l’ordinateur portable l’ajoute avec une balise que le téléphone n’a jamais vue. Le deuxième test vérifie l’idempotence. Vous devriez ajouter des tests basés sur les propriétés, c’est-à-dire générer aléatoirement des séquences d’opérations et des ordres de fusion, pour détecter les erreurs de convergence difficiles à voir.
7. Erreurs courantes et quand ne pas l’utiliser
Erreurs courantes :
- Utiliser l’horloge système pour LWW sans traiter les décalages d’horloge. Résultat : les données « nouvelles » sont écrasées par les données « anciennes ».
- Métadonnées qui gonflent : OR-Set garde indéfiniment les balises des éléments supprimés (tombstones). Vous avez besoin d’un mécanisme de nettoyage, ce qui exige généralement de s’assurer que toutes les répliques ont reçu l’opération de suppression.
- Confondre convergence et correction métier : CRDT garantit uniquement que toutes les répliques sont identiques, pas que le résultat plaît à l’utilisateur. Si deux personnes changent le titre, LWW perdra toujours une version.
- Pas de migration de schéma : les données persistent longtemps sur l’appareil, donc les anciennes et nouvelles versions de l’app doivent pouvoir lire l’état l’une de l’autre.
Quand ne pas utiliser local-first et CRDT :
- Contraintes globales obligatoires : le solde du compte ne peut pas être négatif, l’inventaire ne peut pas être survendu, chaque siège ne peut être réservé qu’une fois. Ces invariants nécessitent une coordination centralisée (transactions, verrous), que les CRDT ne peuvent pas maintenir.
- Les données doivent être strictement contrôlées : la réglementation interdit le stockage sur les appareils, ou l’accès doit être révoqué immédiatement.
- Application CRUD simple et toujours en ligne : la complexité dépasse les bénéfices. Une API REST avec optimistic locking suffit.
- Données très volumineuses ou partagées par trop d’utilisateurs : synchroniser l’état complet sur chaque appareil n’est pas faisable.
En pratique, c’est généralement une approche hybride : la collaboration (notes, listes, brouillons) utilise local-first. Les transactions (paiements via PaymentService) passent par le serveur avec des contraintes strictes.
8. Conclusion
Le local-first place d’abord les données sur l’appareil de l’utilisateur, permettant à l’application de réagir instantanément et de fonctionner hors ligne. Les CRDT sont la base technique qui permet à plusieurs répliques de se modifier simultanément et de converger automatiquement, grâce à la commutativité, l’associativité et l’idempotence de la fonction merge.
Dans cet article, vous avez implémenté G-Counter, LWW-Register et OR-Set en Java 21, construit un endpoint de synchronisation avec Spring Boot, et écrit des tests vérifiant la convergence. Pour un produit réel, envisagez d’utiliser une bibliothèque CRDT éprouvée plutôt que d’écrire la vôtre. N’appliquez le local-first que pour les données qui en bénéficient vraiment, et gardez les opérations métier nécessitant des contraintes globales côté serveur.