Skip to content

Sync Lifecycle

The sync lifecycle describes how data flows through Locorda - from local changes to remote sync, how offline changes are queued, and how errors are handled.

Sync in Locorda is automatic and bidirectional:

  • Local → Remote: Your changes are queued and uploaded when connected
  • Remote → Local: Remote changes are downloaded and merged automatically

Both directions flow through the same callback pattern, ensuring your UI always shows the merged state.

When you make a local change (repository.save(task)):

1. 📱 Your app calls repository.save(task)
↓ (repository forwards to syncEngine.save<Task>(task))
2. 🔧 SyncEngine merges & stores in Locorda's local storage
3. 📞 onUpdate callback (registered via hydrateWithCallbacks)
→ your app saves to its own database
4. 🎨 UI updates via StreamBuilder
[Change is queued for sync when connected]

Key insight: You don’t directly update your database in save(). The sync engine calls your onUpdate callback (which you registered with hydrateWithCallbacks() during initialization), which updates your database. This keeps everything consistent.

The callback pattern ensures:

  1. Consistency: Local and remote changes handled identically
  2. Conflict-free: CRDT merge happens before callback
  3. Single source of truth: Only one code path for database updates
  4. Testability: Easy to mock and test

Sync process runs automatically in a background worker thread:

1. 🔄 Worker checks remote storage for changes
2. 📥 Downloads new/updated data
3. 🔧 SyncEngine merges with local storage (resolves conflicts)
4. 📤 Uploads merged state + queued local changes to remote
5. 📞 onUpdate callback → your app saves to its database
6. 🎨 UI updates via StreamBuilder

Key insight: Remote changes go through the same onUpdate callback as local changes. Your database and UI don’t need to know where changes came from.

Sync can be triggered by:

  1. Automatic periodic sync (default behavior)
  2. Manual sync request (user presses sync button)

Locorda is offline-first - your app works fully offline, and changes sync when you reconnect.

When offline:

  1. Local changes are queued: Saved to Locorda’s local storage
  2. UI updates immediately: Via onUpdate callbacks
  3. No remote sync: Worker waits for connection
  4. No data loss: All changes persisted locally

When you reconnect:

  1. Sync process runs automatically
  2. Downloads remote changes (what others did while you were offline)
  3. Merges with your queued local changes (using CRDT algorithms)
  4. Both remote and local changes flow through onUpdate
  5. UI shows the final merged state

When offline changes from multiple devices are merged:

Device A (offline): Device B (offline):
task.title = "Buy milk" task.title = "Buy bread"
↓ ↓
Both reconnect
Sync merges using CRDT rules (for example last-write-wins by timestamp)
Final merged state: task.title = "Buy bread" (if B's timestamp is later)

See Conflict Resolution for details on merge strategies.

Your app can be in one of these sync states:

StateDescriptionUser Can
idleNo sync in progress, waiting for triggerMake local changes (queued)
syncingActively syncing dataEverything (sync in background)
successLast sync completed successfullyEverything
errorLast sync failed with an errorRetry or continue offline

Sync operations can be triggered by:

  • manual - User explicitly requested sync (e.g., “Sync Now” button)
  • startup - Automatic sync on application startup
  • scheduled - Automatic sync on a scheduled interval
  • pullToRefresh - Sync triggered by pull-to-refresh gesture
  • login - Sync triggered after user authentication/login
  • dataChange - (planned, not yet implemented) - Sync triggered after local data changes
  • connectionRestore (planned, not yet implemented) - Sync after network connectivity is restored

Subscribe to sync state changes:

// Access sync manager from your Locorda instance
final syncManager = locorda.syncManager;
// Listen to state changes
syncManager.statusStream.listen((state) {
switch (state.status) {
case SyncStatus.idle:
print('Sync idle');
case SyncStatus.syncing:
print('Syncing... (trigger: ${state.lastTrigger})');
case SyncStatus.success:
print('Sync completed at ${state.lastSyncTime}');
case SyncStatus.error:
print('Sync error: ${state.errorMessage}');
}
});
// Check current state synchronously
final currentState = syncManager.currentState;
final isSyncing = syncManager.isSyncing;

Trigger sync manually:

// Trigger manual sync
await syncManager.sync(trigger: SyncTrigger.manual);
// Enable auto-sync with custom interval
syncManager.enableAutoSync(interval: Duration(minutes: 10));
// Disable auto-sync
syncManager.disableAutoSync();

When network errors occur:

Transient errors (network timeout, temporary 500 errors):

  • Sync automatically retries with exponential backoff
  • Local changes remain queued
  • UI continues to work offline

Permanent errors (401 unauthorized, 404 not found):

  • Sync stops retrying
  • Error exposed to UI for user action
  • Local changes remain queued

Conflicts are not errors in Locorda - they’re resolved automatically using CRDT merge strategies. See Conflict Resolution.

If local storage fails (disk full, database corruption):

  • Sync engine exposes the error
  • App should prompt user to free space or reinstall
  • Remote data remains safe

Failed sync attempts retry with increasing delays:

Attempt 1: Immediate
Attempt 2: 1 second delay
Attempt 3: 2 seconds delay
Attempt 4: 4 seconds delay
Attempt 5: 8 seconds delay
...
Max delay: 60 seconds

After max delay is reached, sync retries every 60 seconds until successful.

Locorda uses cursors to track how far it has replayed its local storage into your app’s database.

When hydrateWithCallbacks() is called, Locorda replays stored objects into your app via onUpdate/onDelete. The cursor records the position of the last successfully delivered update. On the next app start, Locorda resumes from that position — so only objects your app hasn’t seen yet are replayed.

getCurrentCursor: () async {
// Return the position your app has already processed
final prefs = await SharedPreferences.getInstance();
return prefs.getString('sync_cursor_tasks');
},
onCursorUpdate: (cursor) async {
// Persist the position after each successful delivery
final prefs = await SharedPreferences.getInstance();
await prefs.setString('sync_cursor_tasks', cursor);
}

Benefits:

  • App startup is fast — only unprocessed updates are replayed
  • Survives crashes: replays from last committed cursor, not from scratch
  • Essential when your local database already holds the full dataset

For large datasets:

  • Implement pagination in your UI
  • Consider lazy loading (fetch on demand, not all at once)

See Advanced Features - Lazy Loading for details.

For rapidly changing data:

  • Use debouncing in your UI (avoid save on every keystroke)
  • Trust batching - sync engine batches automatically
  • Monitor network usage if on mobile data
final locorda = await initLocorda(
remotes: [solidPod],
storage: DriftStorageMainHandler(),
// setupLogging would be provided by you, the developer
onWorkerSpawn: () => setupLogging(
level: Level.ALL, // Verbose logging
threadName: 'WORKER',
),
);

Changes not syncing:

  • Check connection state
  • Verify remote credentials
  • Check for network errors in logs

Unexpected merge results:

  • Review CRDT merge strategies on your models
  • Check timestamps (last-write-wins depends on clock sync)
  • See Conflict Resolution

Slow sync performance:

  • Check dataset size
  • Consider lazy loading
  • Consider “shard dataset” mode for the backends - this means that all resources of a shard would be stored in the shard file instead of separately as a file per resource.

Sync lifecycle in Locorda:

  1. Local changes: Queued automatically, flow through onUpdate callback
  2. Background sync: Automatic, bidirectional, runs in worker thread
  3. Offline mode: Fully supported, changes queued until reconnection
  4. Conflict resolution: Automatic via CRDT merge strategies
  5. Error handling: Exponential backoff retry with error exposure
  6. Optimizations: Incremental sync (cursors), batching, background processing

The callback pattern ensures all changes (local and remote) flow through a single code path, keeping your UI in sync with the merged state.