Back to Blog

Offline-First Architecture: Lessons from Building Enterprise Apps

How we built an enterprise logistics app with 72+ hours offline support and 99.9% sync success rate.

February 14, 20263 min read
#architecture#mobile#offline-first#enterprise

Offline-First Architecture: Lessons from Building Enterprise Apps

When building enterprise mobile apps, you can't assume connectivity. Warehouse workers, field inspectors, and delivery drivers often work in areas with poor or no network. Here's how we built a logistics platform with 72+ hours offline support and 99.9% sync success rate.

The Challenge

At Turvo, we needed to build an app that:

  • Works in warehouse basements with no signal
  • Handles 72+ hours of offline operations
  • Syncs reliably when connectivity returns
  • Resolves conflicts gracefully

Architecture Overview

┌─────────────────┐
│   UI Layer      │
└────────┬────────┘
         │
┌────────▼────────┐
│  Repository     │ ← Single source of truth
└────────┬────────┘
         │
┌────────▼────────┐     ┌──────────────┐
│  Local DB       │────▶│  Sync Engine │
│  (Room/SQLite)  │     └──────┬───────┘
└─────────────────┘            │
                               ▼
                        ┌──────────────┐
                        │  Remote API  │
                        └──────────────┘

Key Principles

1. Local-First, Always

// Repository pattern - always read from local
class ShipmentRepository(
    private val localDataSource: ShipmentDao,
    private val remoteDataSource: ShipmentApi,
    private val syncEngine: SyncEngine
) {
    fun getShipments(): Flow<List<Shipment>> {
        // Always return local data
        return localDataSource.getAllShipments()
    }
    
    suspend fun createShipment(shipment: Shipment) {
        // Write locally first
        localDataSource.insert(shipment.copy(syncStatus = PENDING))
        // Queue for sync
        syncEngine.queueOperation(CreateShipmentOp(shipment))
    }
}

2. Operation Queue, Not Data Sync

Instead of syncing data, we sync operations:

sealed class SyncOperation {
    data class CreateShipment(val shipment: Shipment) : SyncOperation()
    data class UpdateStatus(val id: String, val status: Status) : SyncOperation()
    data class AddPhoto(val shipmentId: String, val photoPath: String) : SyncOperation()
}

This approach:

  • Preserves user intent
  • Handles conflicts better
  • Enables optimistic UI updates

3. Conflict Resolution Strategy

We use Last-Write-Wins with Merge:

fun resolveConflict(local: Shipment, remote: Shipment): Shipment {
    return when {
        local.updatedAt > remote.updatedAt -> local
        remote.updatedAt > local.updatedAt -> remote
        else -> mergeShipments(local, remote)
    }
}

fun mergeShipments(local: Shipment, remote: Shipment): Shipment {
    return local.copy(
        // Merge arrays (photos, notes)
        photos = (local.photos + remote.photos).distinctBy { it.id },
        notes = (local.notes + remote.notes).distinctBy { it.id },
        // Take latest for scalars
        status = if (local.statusUpdatedAt > remote.statusUpdatedAt) 
                     local.status else remote.status
    )
}

Sync Engine Implementation

class SyncEngine(
    private val operationQueue: OperationQueue,
    private val networkMonitor: NetworkMonitor
) {
    init {
        networkMonitor.isConnected
            .filter { it }
            .collect { processPendingOperations() }
    }
    
    private suspend fun processPendingOperations() {
        val operations = operationQueue.getPending()
        
        operations.forEach { op ->
            try {
                executeOperation(op)
                operationQueue.markCompleted(op.id)
            } catch (e: ConflictException) {
                handleConflict(op, e.serverVersion)
            } catch (e: NetworkException) {
                // Will retry on next connectivity
                break
            }
        }
    }
}

Results

After implementing this architecture:

MetricBeforeAfter
Offline Duration2 hours72+ hours
Sync Success Rate94%99.9%
Data Loss Incidents12/month0
User ComplaintsHighMinimal

Key Takeaways

  1. Local is the source of truth — Never assume network availability
  2. Sync operations, not data — Preserves user intent
  3. Design for conflicts — They will happen
  4. Queue everything — Be resilient to interruptions
  5. Test offline scenarios — Use airplane mode religiously

What's Next?

In the next post, I'll dive deeper into MQTT-based real-time sync and how we handle bidirectional updates efficiently.


Have questions about offline-first architecture? Hit me up on Twitter!

Share this article