Platform Architecture

Sync Algorithm

A robust, offline-first synchronization system designed for unreliable networks and complex relational data. Single-shot, lightweight, parallelized, and fully idempotent.

Single-Shot
Not chatty
Lightweight
Delta only
Parallelized
High speed
Graph-Based
Relational
Idempotent
Safe retries
Arbitrary
Any entity

Architecture Overview

The sync algorithm is purpose-built for educational data collection in environments with intermittent connectivity. It ensures data integrity while minimizing bandwidth usage and network round-trips.

Offline-First Design
All operations queue locally, sync when connected
Conflict Resolution
Timestamp-based last-write-wins with manual override
Referential Integrity
Graph-aware ordering preserves foreign key relationships
Retry Safety
Idempotent operations safe to retry without duplication
1. TRIGGER
├─ Timer (every 15 min)
├─ Network reconnect
└─ Manual request
2. VALIDATE
├─ Check mutex (prevent overlap)
└─ Check connectivity
3. DOWNLOAD ← delta only
├─ Schools (updatedAfter)
├─ Visits (updatedAfter)
└─ Assessments (updatedAfter)
4. UPLOAD ← parallel
├─ Pending changes
└─ Queued operations
5. COMPLETE
└─ Update sync timestamps

Single-Shot Sync (Not Chatty)

Unlike traditional sync systems that maintain persistent connections or poll frequently, our sync operates as a single atomic operation. This design is critical for unreliable networks where connections may drop mid-sync.

Why Single-Shot?

  • Network resilience: Complete sync in one connection window
  • Battery efficiency: No persistent connections draining power
  • Predictable timing: Users know when sync happens
  • Simple recovery: Failed sync = retry entire operation

Problem with Chatty Protocols

Real-time sync (WebSockets, long-polling) requires stable connections. In field conditions with 2G/3G networks, these connections frequently drop, leaving data in inconsistent states and requiring complex reconciliation.

// SyncService - Mutex prevents overlap
class SyncService {
  bool _isSyncing = false;

  Future<void> syncAll() async {
    // Mutex check - only one sync at a time
    if (_isSyncing) return;
    if (!networkService.isConnected) return;

    _isSyncing = true;

    try {
      // Single atomic operation
      await _downloadPhase();
      await _uploadPhase();
      await _processOfflineQueue();

      _updateSyncTimestamps();
    } finally {
      _isSyncing = false;
    }
  }
}
15 min
Auto-sync interval
1
Sync at a time

Lightweight (Delta Only)

The sync system only transfers changed records since the last successful sync. This dramatically reduces bandwidth usage—critical when operating on metered mobile connections in the field.

Timestamp-Based Delta Detection

Each entity type tracks its last sync time. On sync, we query only records whereupdatedAt > lastSyncTime.

lastSyncTimes: {
  schools: 2024-01-15T10:30:00Z,
  visits: 2024-01-15T10:30:05Z,
  assessments: 2024-01-15T10:30:12Z
}

Selective Field Queries

REST API queries with delta parameters avoid over-fetching. Large binary data (photos, audio) synced separately via S3 endpoints.

// Delta query - only changed records
final lastSync = _lastSyncTimes['schools']
    ?? DateTime(2020);

// REST endpoint with delta parameter
final url = '/api/schools?updatedAfter='
    + lastSync.toIso8601String();

final response = await dio.get(
  url,
  options: Options(
    headers: {'Authorization': 'Bearer $token'}
  ),
);

final schools = response.data['items'];

// Only process changed records
for (final school in schools) {
  await _upsertSchool(school);
}

Bandwidth Savings

A typical deployment with 500 schools only transfers ~10-20 changed records per sync, reducing payload from megabytes to kilobytes.

Parallelization (High Speed)

While entity types sync sequentially (to maintain referential integrity), operations within each phase execute in parallel. This maximizes throughput while ensuring data consistency.

Sync Order (Sequential)

1
Schools
Parent entities first
2
Visits
References schoolId
3
Assessments
References visitId

Sequential ordering ensures foreign key references resolve correctly.

// Parallel upload with Future.wait()
Future<List<String>> uploadBatch(
  List<String> localPaths
) async {
  // Queue all uploads as futures
  final uploadFutures = <Future<String>>[];

  for (final path in localPaths) {
    uploadFutures.add(uploadPhoto(path));
  }

  // Execute ALL uploads in parallel
  return await Future.wait(uploadFutures);
}

// Progress tracking via streams
onSendProgress: (sent, total) {
  final progress = UploadProgress(
    bytesTransferred: sent,
    totalBytes: total,
  );
  _progressController.add(progress);
}
Download

Sequential by entity type

Upload

Parallel within batches

Graph-Based Data Model

The sync system handles complex relational data with deep nesting. The entity graph is synced in topological order—parents before children—ensuring referential integrity at every step.

// Entity relationship graph
Country
├── Region
│   └── District
│       └── School ←────────────────────────────────┐
│           ├── Teacher (many) ←──────────────────┐ │
│           └── SchoolVisit (many)                │ │
│               ├── TeacherVisit (many) ──────────┘ │
│               │   └── PupilAssessment (many)      │
│               │       ├── SectionAnswer (many)    │
│               │       └── AssessmentResult        │
│               └── MonitoringAnswer (many)         │
│                   └── ProjectForm (ref) ──────────┘
└── Organisation (tenant root)

Foreign Key Tracking

Every child entity stores its parent reference:

schoolId, districtId,
monitorId, teacherVisitId,
pupilAssessmentId

Indexed Fields

Realm indexes enable fast graph traversal:

@Indexed() String id;
@Indexed() String schoolId;
@Indexed() String districtId;

Cascade Sync

Nested entities sync with their parents:

Visit {
  teacherVisits: [...],
  monitoringAnswers: [...]
}

Idempotency (Safe Retries)

Every sync operation is idempotent—executing it multiple times produces the same result as executing it once. This is essential for reliability when network failures may interrupt operations mid-flight.

Layer 1: UUID Primary Keys

All entities use UUIDs generated client-side. The same entity always has the same ID, regardless of how many times it's synced.

id: "550e8400-e29b-41d4-a716-446655440000"

Layer 2: Upsert Semantics

All database writes use upsert: if record exists, update it; if not, create it. No duplicate records ever created.

realm.add(entity, update: true);

Layer 3: Timestamp Dedup

Conflict detection compares timestamps. Same or older data is skipped, preventing redundant processing.

if (remote.updatedAt <= local.updatedAt)
  skip();
// Idempotent save operation
Future<void> save<T extends RealmObject>(T entity) async {
  realm.write(() {
    // Upsert: create if new, update if exists
    // Running this 1x or 100x produces same result
    realm.add<T>(entity, update: true);
  });
}

// Idempotent queue processing
Future<void> processOfflineQueue() async {
  for (final operation in _queue) {
    try {
      await _executeOperation(operation);
      _queue.remove(operation);  // Only remove on success
    } catch (e) {
      if (operation.retryCount >= 3) {
        _queue.remove(operation);  // Give up after 3 tries
      } else {
        operation.retryCount++;   // Retry is safe (idempotent)
      }
    }
  }
}

Arbitrary Graph Support

The sync system uses an adapter pattern that allows any entity type to participate in sync. New entity types can be added without modifying the core sync engine.

// SyncAdapter interface
abstract class SyncAdapter {
  String get adapterName;

  /// Get entities needing sync
  Future<List<dynamic>> getEntitiesForSync(
    String entityType
  );

  /// Handle response from server
  Future<void> handleSyncResponse(
    String entityType,
    Map<String, dynamic> response
  );
}

/// Any entity can implement this
abstract class SyncableEntity {
  String get id;
  String get entityType;
  DateTime get updatedAt;

  Map<String, dynamic> toJson();
  void updateFromJson(Map<String, dynamic> json);
}

Supported Entity Types

School
Teacher
Pupil
Monitor
SchoolVisit
TeacherVisit
PupilAssessment
SectionAnswer
MonitoringAnswer
ProjectForm
AssessmentDefinition
FormQuestion
District
Region

Extensibility

Adding a new entity to sync requires only implementing the SyncAdapter interface. The core sync engine handles scheduling, retries, and conflict resolution automatically.

Conflict Resolution

When the same record is modified both locally and remotely, the system detects the conflict and resolves it using last-write-wins semantics with an option for manual override.

Resolution Algorithm

1
Compare Timestamps
Check local vs remote updatedAt
2
Remote Newer → Accept Remote
Server data wins, update local
3
Local Newer → Flag Conflict
Present to user for resolution
4
Equal → Accept Remote
Tie-breaker favors server
// Conflict detection
Future<List<Conflict>> _checkForConflicts(
  String entityType,
  String entityId,
  Map<String, dynamic> remoteData
) async {
  final conflicts = <Conflict>[];

  final localTimestamp = _getLocalTimestamp(
    entityType, entityId
  );
  final remoteTimestamp = DateTime.parse(
    remoteData['updatedAt']
  );

  // Only flag if local is strictly newer
  if (localTimestamp != null &&
      localTimestamp.isAfter(remoteTimestamp)) {
    conflicts.add(Conflict(
      entityType: entityType,
      entityId: entityId,
      localData: _getLocalData(entityType, entityId),
      remoteData: remoteData,
      localTimestamp: localTimestamp,
      remoteTimestamp: remoteTimestamp,
    ));
  }

  return conflicts;
}

Service Architecture

ServiceRoleKey Responsibilities
SyncServiceOrchestratorCoordinates full sync cycle, manages mutex, tracks timestamps
OfflineQueueServiceQueue ManagerQueues operations offline, processes on reconnect, handles retries
NetworkServiceConnectivityMonitors network state, triggers sync on reconnect
DatabaseServicePersistenceRealm upserts, transactions, schema management
PhotoServiceBinary SyncParallel photo uploads, progress tracking, S3 integration
CacheServiceResponse CacheTTL-based caching, reduces redundant fetches
RepositoriesEntity AdaptersEntity-specific sync logic, implement SyncAdapter

Performance Characteristics

3
Round-trips per sync
One per entity type
<1KB
Typical delta size
When few changes
30s
Request timeout
Per operation
3
Max retries
Per failed operation

Explore the Platform

See how the sync algorithm integrates with the mobile app and backend systems.