Offline Sync
Architecture offline-first de l'app mobile : queue de synchronisation, gestion des conflits et stratégie de retry.
Overview
YaniPay mobile implémente une architecture offline-first permettant aux utilisateurs d'effectuer certaines actions même sans connexion internet. Les opérations sont mises en queue et synchronisées automatiquement dès que la connexion est rétablie.
Architecture Offline-First
Offline Queue
Stockage local des opérations en attente
Auto-Sync
Synchronisation automatique à la reconnexion
Conflict Resolution
Résolution intelligente des conflits
Opérations Offline Supportées
- • Lecture: Solde, historique, cartes fidélité (données cachées)
- • Écriture limitée: Transferts entre comptes propres, notes
- • Non supporté offline: Paiements externes, KYC, modifications profil
Pourquoi le Mode Offline ?
Dans un contexte mobile, la connectivité n'est jamais garantie. Une architecture offline-first assure une expérience utilisateur fluide quelle que soit la qualité du réseau.
Bénéfices de l'architecture offline-first
- Disponibilité garantie: L'app fonctionne même en avion, en métro ou en zone de couverture faible
- Performance perçue: Les données locales s'affichent instantanément, sans attendre le réseau
- Résilience réseau : Les timeouts et erreurs réseau sont gérés automatiquement par le système de retry
- Économie de batterie : Moins de requêtes réseau = moins de consommation radio
NetInfo pour la détection réseau
@react-native-community/netinfopour détecter les changements de connectivité. L'app écoute ces événements pour déclencher automatiquement la synchronisation à la reconnexion.Cas d'Utilisation
Découvrez comment différents profils bénéficient du mode offline.
Thomas
Voyageur fréquent
Consulte son solde en avion
Thomas consulte son solde et historique même en mode avion grâce au cache local. À l'atterrissage, l'app se synchronise automatiquement.
Marie
Commerçante en zone rurale
Accepte un paiement avec réseau faible
Marie peut voir son dernier solde connu même avec un réseau 2G instable. Les opérations critiques sont mises en queue jusqu'à la reconnexion.
Lucas
Étudiant économe
Transfert programmé sur plusieurs appareils
Lucas programme un transfert sur son téléphone mais vérifie l'état sur sa tablette. Le sync bidirectionnel maintient la cohérence entre appareils.
Sophie
Utilisateur multi-device
Résolution de conflit automatique
Sophie modifie une note sur son iPhone pendant qu'elle est offline. Quand elle reconnecte, le système fusionne intelligemment les changements.
Flux de Synchronisation
Vue d'ensemble du système de synchronisation bidirectionnel.
Architecture globale
Processus de synchronisation
Séquence détaillée du sync push/pull à la reconnexion.
Opérations Non-Supportées Offline
Architecture
L'architecture de synchronisation repose sur trois composants principaux.
// Architecture de synchronisation
/*
┌─────────────────────────────────────────────────────────────┐
│ Mobile App │
├─────────────────────────────────────────────────────────────┤
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ UI Layer │ │ Services │ │ Offline Queue │ │
│ │ │◄─┤ Layer │◄─┤ (AsyncStorage) │ │
│ └─────────────┘ └──────┬──────┘ └──────────┬──────────┘ │
│ │ │ │
│ ┌───────────────────────▼─────────────────────▼──────────┐│
│ │ Sync Manager ││
│ │ - Network detection ││
│ │ - Queue processing ││
│ │ - Conflict resolution ││
│ └────────────────────────┬───────────────────────────────┘│
└───────────────────────────┼─────────────────────────────────┘
│
▼
┌───────────────┐
│ Backend API │
│ /sync/push │
│ /sync/pull │
└───────────────┘
*/Offline Queue
Le système de queue stocke les opérations en attente de synchronisation.
import AsyncStorage from '@react-native-async-storage/async-storage';
import NetInfo from '@react-native-community/netinfo';
interface QueuedOperation {
id: string;
type: 'transfer' | 'note' | 'preference';
payload: Record<string, unknown>;
createdAt: string;
retryCount: number;
maxRetries: number;
priority: 'high' | 'normal' | 'low';
}
const QUEUE_KEY = '@offline_queue';
export const offlineQueue = {
// Ajoute une opération à la queue
async enqueue(operation: Omit<QueuedOperation, 'id' | 'createdAt' | 'retryCount'>): Promise<string> {
const queue = await this.getQueue();
const newOperation: QueuedOperation = {
...operation,
id: `op_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
createdAt: new Date().toISOString(),
retryCount: 0,
};
queue.push(newOperation);
// Trie par priorité
queue.sort((a, b) => {
const priorityOrder = { high: 0, normal: 1, low: 2 };
return priorityOrder[a.priority] - priorityOrder[b.priority];
});
await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(queue));
// Tente de sync immédiatement si online
this.attemptSync();
return newOperation.id;
},
// Récupère la queue complète
async getQueue(): Promise<QueuedOperation[]> {
const data = await AsyncStorage.getItem(QUEUE_KEY);
return data ? JSON.parse(data) : [];
},
// Supprime une opération de la queue
async dequeue(operationId: string): Promise<void> {
const queue = await this.getQueue();
const filtered = queue.filter(op => op.id !== operationId);
await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(filtered));
},
// Incrémente le retry count
async incrementRetry(operationId: string): Promise<boolean> {
const queue = await this.getQueue();
const index = queue.findIndex(op => op.id === operationId);
if (index === -1) return false;
queue[index].retryCount++;
// Supprime si max retries atteint
if (queue[index].retryCount >= queue[index].maxRetries) {
await this.dequeue(operationId);
await this.logFailedOperation(queue[index]);
return false;
}
await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(queue));
return true;
},
// Tente la synchronisation
async attemptSync(): Promise<void> {
const networkState = await NetInfo.fetch();
if (!networkState.isConnected) return;
const queue = await this.getQueue();
if (queue.length === 0) return;
for (const operation of queue) {
try {
await this.processOperation(operation);
await this.dequeue(operation.id);
} catch (error) {
const shouldRetry = await this.incrementRetry(operation.id);
if (!shouldRetry) {
console.error('Operation failed permanently:', operation.id);
}
}
}
},
};Sync Protocol
Protocole de synchronisation bidirectionnelle avec le backend.
Pull: Récupérer les changements serveur
// Request
{
"lastSyncTimestamp": "2026-02-08T14:00:00Z",
"deviceId": "550e8400-e29b-41d4-a716-446655440000",
"entities": ["wallet", "transactions", "cards", "loyalty"]
}
// Response (200 OK)
{
"syncTimestamp": "2026-02-08T14:30:00Z",
"changes": {
"wallet": {
"updated": [
{ "id": "wallet_123", "balance": 250.00, "updatedAt": "..." }
],
"deleted": []
},
"transactions": {
"updated": [
{ "id": "txn_456", "amount": -15.00, "merchant": "Café Paris", ... }
],
"deleted": ["txn_old_789"]
},
"cards": {
"updated": [],
"deleted": []
},
"loyalty": {
"updated": [
{ "id": "loyalty_123", "points": 1500, "tier": "gold", ... }
],
"deleted": []
}
},
"hasMore": false
}Push: Envoyer les changements locaux
// Request
{
"deviceId": "550e8400-e29b-41d4-a716-446655440000",
"operations": [
{
"id": "op_123",
"type": "transfer",
"payload": {
"fromAccountId": "acc_123",
"toAccountId": "acc_456",
"amount": 50.00,
"description": "Remboursement"
},
"clientTimestamp": "2026-02-08T14:25:00Z"
}
]
}
// Response (200 OK)
{
"results": [
{
"operationId": "op_123",
"status": "success",
"serverData": {
"transactionId": "txn_server_789",
"processedAt": "2026-02-08T14:30:05Z"
}
}
],
"conflicts": []
}
// Response avec conflits (200 OK)
{
"results": [],
"conflicts": [
{
"operationId": "op_123",
"conflictType": "insufficient_funds",
"serverState": { "balance": 30.00 },
"clientState": { "requestedAmount": 50.00 },
"resolution": "rejected",
"message": "Solde insuffisant pour effectuer ce transfert"
}
]
}Conflict Resolution
Stratégies de résolution des conflits entre données locales et serveur.
| Type de Conflit | Stratégie | Action |
|---|---|---|
| Solde insuffisant | Server wins | Rejette l'opération, notifie l'utilisateur |
| Version obsolète | Server wins | Remplace données locales par serveur |
| Modification concurrente | Last write wins | Timestamp le plus récent l'emporte |
| Entité supprimée | Delete wins | Supprime localement, abandonne les changements |
// Gestionnaire de conflits
async function handleConflict(conflict: SyncConflict): Promise<void> {
switch (conflict.conflictType) {
case 'insufficient_funds':
// Notifie l'utilisateur
showNotification({
type: 'error',
title: 'Transfert impossible',
message: conflict.message,
});
// Supprime de la queue
await offlineQueue.dequeue(conflict.operationId);
break;
case 'version_conflict':
// Server wins - met à jour les données locales
await localCache.update(conflict.entity, conflict.serverState);
break;
case 'concurrent_modification':
// Compare les timestamps
if (conflict.serverState.updatedAt > conflict.clientState.updatedAt) {
await localCache.update(conflict.entity, conflict.serverState);
} else {
// Retry avec les nouvelles données serveur comme base
await offlineQueue.enqueue({
...conflict.originalOperation,
payload: mergeChanges(conflict.serverState, conflict.clientChanges),
});
}
break;
case 'entity_deleted':
// Supprime localement
await localCache.delete(conflict.entity);
await offlineQueue.dequeue(conflict.operationId);
showNotification({
type: 'info',
title: 'Élément supprimé',
message: 'Cet élément a été supprimé sur un autre appareil.',
});
break;
}
}Retry Strategy
Stratégie de retry avec exponential backoff pour les opérations échouées.
// Configuration du retry
const retryConfig = {
// Délais entre les tentatives (exponential backoff)
delays: [1000, 2000, 5000, 10000, 30000], // 1s, 2s, 5s, 10s, 30s
// Nombre maximum de tentatives par priorité
maxRetries: {
high: 10, // Opérations critiques (transferts)
normal: 5, // Opérations standard
low: 3, // Opérations non-critiques (notes)
},
// Erreurs qui ne doivent pas être retryées
nonRetryableErrors: [
'INSUFFICIENT_FUNDS',
'INVALID_ACCOUNT',
'UNAUTHORIZED',
'VALIDATION_ERROR',
],
// Erreurs qui peuvent être retryées
retryableErrors: [
'NETWORK_ERROR',
'TIMEOUT',
'SERVER_ERROR',
'SERVICE_UNAVAILABLE',
],
};
// Implémentation du retry avec backoff
async function retryWithBackoff(
operation: QueuedOperation,
attempt: number = 0
): Promise<void> {
try {
await processOperation(operation);
await offlineQueue.dequeue(operation.id);
} catch (error) {
const maxRetries = retryConfig.maxRetries[operation.priority];
if (attempt >= maxRetries) {
await handlePermanentFailure(operation, error);
return;
}
if (isNonRetryableError(error)) {
await handlePermanentFailure(operation, error);
return;
}
const delay = retryConfig.delays[Math.min(attempt, retryConfig.delays.length - 1)];
console.log(`Retry ${attempt + 1}/${maxRetries} in ${delay}ms for ${operation.id}`);
await sleep(delay);
await retryWithBackoff(operation, attempt + 1);
}
}Network Detection
L'app utilise @react-native-community/netinfo pour détecter les changements de connectivité et déclencher automatiquement la synchronisation à la reconnexion.
AsyncStorage pour la Persistance
@react-native-async-storage/async-storage pour persister la queue et le cache. Cette librairie gère automatiquement la sérialisation JSON et offre une API async/await propre.Références
Ressources officielles pour approfondir les patterns offline-first.