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:
| Metric | Before | After |
|---|---|---|
| Offline Duration | 2 hours | 72+ hours |
| Sync Success Rate | 94% | 99.9% |
| Data Loss Incidents | 12/month | 0 |
| User Complaints | High | Minimal |
Key Takeaways
- Local is the source of truth — Never assume network availability
- Sync operations, not data — Preserves user intent
- Design for conflicts — They will happen
- Queue everything — Be resilient to interruptions
- 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!