· Hernán Pérez Rodal · Engineering  · 7 min read

Architecture offline-first : capture de données en zones rurales avec connectivité intermittente

En Amérique latine, le premier maillon productif a souvent du signal 3G ou pire. Nous racontons comment nous avons conçu Captia pour fonctionner sans connexion — et quels tradeoffs nous avons acceptés avec l'eventual consistency.

En Amérique latine, le premier maillon productif a souvent du signal 3G ou pire. Nous racontons comment nous avons conçu Captia pour fonctionner sans connexion — et quels tradeoffs nous avons acceptés avec l'eventual consistency.

TL;DR — La plupart des apps présupposent la connectivité. Dans les champs latino-américains, vous ne pouvez pas. Captia, notre app mobile de capture de données, est offline-first nativement : les opérateurs enregistrent des CTEs sans signal et synchronisent plus tard. Ce post raconte comment nous l’avons conçu, quels tradeoffs nous avons acceptés, et comment nous résolvons les conflicts quand ils apparaissent.

Le problème : la connectivité N’EST PAS une feature optionnelle

FSMA 204 et EUDR exigent que les événements soient capturés là où ils se produisent. Pour les producteurs primaires (champ, pêche, bétail), ça signifie :

  • Zones rurales avec 3G intermittent ou sans signal
  • Usines de transformation où la couverture meurt à certains étages
  • Opérateurs se déplaçant entre lots/silos/chambres

Demander à un opérateur de “revenir quand il aura du signal” pour enregistrer un événement est la garantie qu’il ne s’enregistre pas. En production réelle, soit vous capturez offline — soit vous ne capturez pas.

Darwin suit des millions d’événements par an, la majorité d’opérations en Amérique latine. Nous ne pouvions pas présupposer la connectivité. Nous avons tout conçu à l’envers.

Principes que nous avons appliqués

1. Offline est le default, online est un bonus

L’app s’utilise de la même façon avec ou sans connexion. Il n’y a pas de “mode offline” avec fonctionnalité réduite. L’utilisateur ne doit pas penser à l’état du réseau.

Créer événement → sauver local → (quand il y a du réseau) → sync

L’opérateur ne voit jamais “erreur : pas de connexion”. L’app fonctionne, tout simplement.

2. Base de données locale comme source de vérité temporaire

Chaque device a une DB locale (SQLite via WatermelonDB) qui est la source de vérité jusqu’à ce que le sync se fasse. Tout ce que l’opérateur voit sort de là, pas du serveur.

Ça change la façon de concevoir les queries : il n’y a pas de “GET /events”. Il y a “lis mon store local”. Le sync est un processus séparé en background.

3. Événements comme first-class citizens

Au lieu de “form submissions”, nous modélisons tout comme des événements immuables avec timestamp :

type CriticalTrackingEvent = {
  id: string;            // UUID généré localement
  type: 'harvest' | 'processing' | 'shipment' | ...;
  timestamp: Date;       // quand ça S'EST PASSÉ, pas quand ça a été enregistré
  deviceId: string;
  operatorId: string;
  payload: EventPayload;
  syncStatus: 'pending' | 'synced' | 'conflicted';
}

Les événements ne s’éditent pas — ils se corrigent avec de nouveaux événements (pattern event sourcing). Ça simplifie la synchronisation et c’est cohérent avec le fonctionnement du compliance (personne n‘“édite” un CTE du passé, on émet une correction).

4. UUIDs générés sur le client, pas sur le serveur

Chaque événement a un UUID v7 généré sur le device avant le sync. Ça permet :

  • Référencer l’événement offline depuis d’autres événements
  • Attacher des photos/documents qui lient par UUID
  • Synchroniser dans n’importe quel ordre sans dependency issues

Le stack

  • Mobile : React Native (Expo) pour iOS + Android
  • Local DB : WatermelonDB (SQLite avec réactivité)
  • Sync layer : Custom — basé sur operational transform (OT) light, pas CRDTs
  • Backend : FastAPI reçoit des batches d’événements, valide, ancre les critical events on-chain
  • Firebase (Firestore) : pour certaines metadata dynamiques (catalogue de produits, règles) qui ont besoin d’update near-realtime

Les tradeoffs réels

Eventual consistency, pas strong consistency

Deux opérateurs sur des lots différents peuvent enregistrer des événements “en même temps” (selon leurs horloges) et l’ordre qui arrive au serveur peut ne pas respecter le wall-clock.

Pour le compliance, ça va parce que :

  • Chaque événement a un timestamp du device (horloge locale)
  • Chaque événement a un timestamp du serveur au moment de la réception
  • La traçabilité se reconstruit par lot (pas par ordre global)

Exception : les événements qui exigent une sérialisation globale (ex : transfert de custody) utilisent du locking via serveur — s’il n’y a pas de réseau, l’événement reste en “pending lock” et se complète au sync.

Conflict resolution : last-write-wins avec audit

Quand deux devices éditent la même ressource (rare mais ça arrive), nous appliquons du last-write-wins basé sur le server timestamp à la réception. Mais :

  • Les deux versions restent enregistrées dans l’audit trail
  • Le conflict est marqué dans l’UI de l’opérateur pour review humaine si ça importe
  • Pour le compliance, on garde la preuve des deux événements

Ce n’est pas parfait, mais c’est transparent — nous ne silencions pas la data, nous la marquons.

Les données sensibles ne vont pas offline

Il y a des choses que nous ne gardons pas sur le device :

  • Prix et terms commerciaux
  • Infos d’autres clients
  • Credentials expirées

Pour celles-là, s’il n’y a pas de réseau, l’opération est refusée. Trade-off acceptable parce que ce sont des edge cases opérationnels, pas le flux core de capture.

Sync protocol : 80 % du travail

Quand on récupère la connectivité :

  1. Push local → server : batch d’événements pending, avec checksum
  2. Server validates : schema, auth, business rules (batch atomic)
  3. Server responds : { accepted: [...], rejected: [...], conflicts: [...] }
  4. Client reconciles : marque localement comme synced, conflict, ou retry
  5. Pull server → local : deltas que le serveur a (autres devices qui ont déjà synchronisé)

Détails critiques :

  • Batching par taille + temps — nous regroupons les événements pour réduire les requests mais sans attendre trop
  • Exponential backoff avec jitter — si ça échoue, on retente sans DDoS le serveur
  • Checkpoints pendant le batch — si le batch échoue au milieu, on reprend depuis le dernier événement confirmé (on ne ré-envoie pas tout)
  • Observabilité client + server — logs de quand l’événement s’est produit, quand on a tenté le sync, quand ça s’est complété, combien de temps ça a pris

Ce qui n’a pas marché

V0 : sync automatique agressif — l’app détectait le signal et synchronisait immédiatement en background. Problème : ça consommait les data plans chers des opérateurs ruraux. Nous sommes passés à sync on-demand + WiFi-only par défaut avec option d’override.

CRDTs complets — nous avons évalué les CRDTs pour multi-device collab. Over-engineering pour notre cas (la plupart des événements sont single-device, single-user). Nous sommes restés avec eventual consistency + locks sélectifs.

Long-running transactions offline — certains flux tentaient de faire des “transactions multi-événement atomiques” offline. Résultat : l’UI se bloquait si quelque chose échouait. Nous avons simplifié à des événements atomiques par unité minimale.

Ce qui a marché

WatermelonDB comme local DB — performance excellente même avec des centaines de milliers d’événements sur device.

UUIDs v7 — naturellement triables par timestamp, collision impossible en pratique, servent de primary key tant local que remote.

Push notifications du serveur quand il y a du sync pending — l’opérateur sait qu’il doit ouvrir l’app et se connecter au WiFi du bureau.

“Mark as synced” local après confirmation du serveur — nous prévenons les ghost data (événements que le serveur a mais que le device croit qu’il n’a pas encore).

Tooling de debug client — écran caché qui montre quels événements sont pending, quand le sync a été tenté, quelles erreurs sont apparues. Les opérateurs et notre équipe support l’utilisent daily.

Lessons learned

  1. Offline-first est de l’architecture, pas une feature — le mettre après coûte 5x plus
  2. Event sourcing simplifie tout — immuables + timestamps + sync éventuel
  3. Les UUIDs client-side sont indispensables pour l’offline
  4. L’eventual consistency est acceptable en compliance si votre audit trail est explicite
  5. Conflict resolution honnête > silencieux — marquez les conflicts, ne cachez pas la data

Et maintenant ?

Nous explorons le sync peer-to-peer via Bluetooth/WiFi local pour quand plusieurs opérateurs sont dans une zone sans internet mais proches entre eux. Des cas comme des usines de pêche en haute mer ou des coopératives de producteurs en zones reculées.

Si vous construisez pour des contextes avec connectivité fragile, mon conseil est : concevez comme s’il n’y avait jamais de connexion. Ce qui vient par le réseau après est bonus. L’inverse ne marche jamais.


Vous avez besoin de capture de données dans des opérations avec connectivité fragile ? Parlons-en — c’est une de nos spécialités.

Compartir:
Back to Blog

Related Posts

View All Posts »