Architect offline-first mobile apps — local storage, sync strategies, conflict resolution, optimistic UI, and background sync patterns
npx claudepluginhub cure-consulting-group/productengineeringskillsThis skill uses the workspace's default tool permissions.
Architects production-grade offline-first applications across Android, iOS, and web. Every output enforces local-first data persistence, deterministic sync, explicit conflict resolution, and seamless UX regardless of connectivity. If your app breaks without internet, it is not ready for production.
Generates design tokens/docs from CSS/Tailwind/styled-components codebases, audits visual consistency across 10 dimensions, detects AI slop in UI.
Records polished WebM UI demo videos of web apps using Playwright with cursor overlay, natural pacing, and three-phase scripting. Activates for demo, walkthrough, screen recording, or tutorial requests.
Delivers idiomatic Kotlin patterns for null safety, immutability, sealed classes, coroutines, Flows, extensions, DSL builders, and Gradle DSL. Use when writing, reviewing, refactoring, or designing Kotlin code.
Architects production-grade offline-first applications across Android, iOS, and web. Every output enforces local-first data persistence, deterministic sync, explicit conflict resolution, and seamless UX regardless of connectivity. If your app breaks without internet, it is not ready for production.
Before starting, gather project context silently:
PORTFOLIO.md if it exists in the project root or parent directories for product/team contextcat package.json 2>/dev/null || cat build.gradle.kts 2>/dev/null || cat Podfile 2>/dev/null to detect stackgit log --oneline -5 2>/dev/null for recent changesls src/ app/ lib/ functions/ 2>/dev/null to understand project structureOffline-first → App works fully without network; syncs when connectivity returns
Cache-first → App shows cached data immediately, refreshes in background
Graceful degrade → Core features work offline, advanced features require network
Sync-critical → Data must be consistent across devices with conflict resolution
Hard rules:
| Request | Primary Output | Action |
|---|---|---|
| Full offline-first | Local DB + sync engine + conflict resolution | Architect full stack |
| Graceful degradation | Caching layer + offline fallbacks | Add offline resilience |
| Cache-first reads | Cache strategy + background refresh | Implement caching |
| Sync-critical data | Sync queue + conflict resolution + idempotency | Design sync system |
| Background sync | Platform-specific background task setup | Configure background sync |
| Offline audit | Connectivity failure analysis + gap report | Audit existing app |
Before generating, confirm:
| Storage Type | Use For | Technology | Max Size |
|---|---|---|---|
| Structured data | Entities, relationships, queries | Room (SQLite) | Device storage |
| Preferences | Settings, flags, simple key-value | DataStore (Proto/Preferences) | ~1MB practical |
| Files/media | Images, documents, cached assets | File storage + coil disk cache | Device storage |
| Sync queue | Pending operations | Room table | Device storage |
// Room entity with sync metadata
@Entity(tableName = "orders")
data class OrderEntity(
@PrimaryKey val id: String,
val customerId: String,
val status: String,
val total: Double,
val updatedAt: Long,
// Sync metadata
val syncStatus: SyncStatus, // SYNCED, PENDING, CONFLICT, FAILED
val localVersion: Int,
val serverVersion: Int,
val lastSyncedAt: Long?
)
enum class SyncStatus { SYNCED, PENDING, CONFLICT, FAILED }
| Storage Type | Use For | Technology | Max Size |
|---|---|---|---|
| Structured data | Entities, relationships, queries | SwiftData / Core Data | Device storage |
| Preferences | Settings, flags, simple key-value | UserDefaults | ~1MB practical |
| Files/media | Images, documents, cached assets | FileManager + URLCache | Device storage |
| Sync queue | Pending operations | SwiftData table | Device storage |
// SwiftData model with sync metadata
@Model
class Order {
@Attribute(.unique) var id: String
var customerId: String
var status: String
var total: Double
var updatedAt: Date
// Sync metadata
var syncStatus: SyncStatus
var localVersion: Int
var serverVersion: Int
var lastSyncedAt: Date?
}
enum SyncStatus: String, Codable {
case synced, pending, conflict, failed
}
| Storage Type | Use For | Technology | Max Size |
|---|---|---|---|
| Structured data | Entities, queries | IndexedDB (via Dexie.js or idb) | ~50MB-unlimited (with permission) |
| Preferences | Settings, tokens | localStorage | 5-10MB |
| Cached assets | Pages, images, API responses | Cache API (Service Worker) | Browser-managed |
| Sync queue | Pending operations | IndexedDB table | Browser-managed |
FirebaseFirestore.getInstance().firestoreSettings = firestoreSettings { isPersistenceEnabled = true }enableIndexedDbPersistence(db) or enableMultiTabIndexedDbPersistence(db) for multi-tab| Strategy | Best For | Complexity | Data Loss Risk |
|---|---|---|---|
| Last-write-wins (LWW) | User settings, preferences, non-collaborative data | Low | Medium (silent overwrite) |
| Server-wins | Read-heavy data, admin-controlled content | Low | Low |
| Client-wins | Offline-heavy workflows, field data collection | Low | Medium |
| Field-level merge | Forms, profiles, entities with independent fields | Medium | Low |
| Operational transform | Real-time collaborative editing (docs, whiteboards) | Very high | Very low |
| CRDT | Distributed counters, sets, collaborative data | High | Very low |
Default recommendation: Field-level merge for most business applications. Use LWW only for truly independent per-user data.
Field-Level Merge:
Server version: { name: "Alice", email: "alice@old.com", phone: "555-1234" }
Client version: { name: "Alice", email: "alice@new.com", phone: "555-1234" }
↑ client changed email
Server update: { name: "Alice", email: "alice@old.com", phone: "555-9999" }
↑ server changed phone
Merged result: { name: "Alice", email: "alice@new.com", phone: "555-9999" }
↑ client wins email ↑ server wins phone
Manual Conflict Resolution UI:
sync_queue table:
id: auto-increment
entityType: string ← "order", "profile", etc.
entityId: string ← remote entity ID
operation: "CREATE" | "UPDATE" | "DELETE"
payload: JSON ← serialized entity or delta
dirtyFields: string[] ← for field-level merge
createdAt: timestamp
retryCount: int
maxRetries: int (default 5)
nextRetryAt: timestamp ← exponential backoff
status: "PENDING" | "IN_PROGRESS" | "FAILED" | "COMPLETED"
{entityType}:{entityId}:{operation}:{contentHash}GET /entities?updatedAfter={lastSyncTimestamp}deletedAt timestamplastSyncTimestamp per entity type// Android — ViewModel pattern
fun placeOrder(order: Order) {
viewModelScope.launch {
// 1. Save locally with PENDING status
val localOrder = order.copy(syncStatus = SyncStatus.PENDING)
localRepository.save(localOrder)
// UI updates immediately via Room Flow
// 2. Enqueue sync operation
syncQueue.enqueue(SyncOperation.Create("order", localOrder))
// 3. Sync engine processes queue when online
// On success: update syncStatus to SYNCED
// On failure: update syncStatus to FAILED, show retry option
}
}
PENDING status (small cloud with arrow)FAILED status show a retry button — tapping retries immediately| Scenario | User Experience |
|---|---|
| Offline, user creates item | Item saved locally, shown with sync-pending icon |
| Comes online, sync succeeds | Sync icon disappears, item fully saved |
| Comes online, sync conflicts | Badge on item, tap to resolve conflict |
| Comes online, sync fails (server error) | Retry icon, automatic retry with backoff |
| Comes online, sync fails (validation) | Error message, edit form reopens with issues highlighted |
// Periodic background sync
val syncWork = PeriodicWorkRequestBuilder<SyncWorker>(
repeatInterval = 15, repeatIntervalTimeUnit = TimeUnit.MINUTES
).setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.build()
).setBackoffCriteria(
BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS
).build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
"periodic_sync", ExistingPeriodicWorkPolicy.KEEP, syncWork
)
PeriodicWorkRequest for regular sync (min 15-minute interval)OneTimeWorkRequest for immediate sync on connectivity changeuploadPendingChanges → downloadServerChanges → resolveConflictsForeground Service only for large uploads/downloads that take >10 minutes// Register background task
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "com.app.sync",
using: nil
) { task in
handleBackgroundSync(task: task as! BGAppRefreshTask)
}
// Schedule
let request = BGAppRefreshTaskRequest(identifier: "com.app.sync")
request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)
try BGTaskScheduler.shared.submit(request)
BGAppRefreshTask for lightweight sync (30s execution window)BGProcessingTask for heavy sync (minutes, requires power + WiFi)// Register sync event
navigator.serviceWorker.ready.then(registration => {
return registration.sync.register('sync-pending-changes');
});
// Service worker handles sync
self.addEventListener('sync', event => {
if (event.tag === 'sync-pending-changes') {
event.waitUntil(processPendingSyncQueue());
}
});
periodicSync) requires site to be installed as PWA// Android — ConnectivityManager with Flow
class NetworkMonitor @Inject constructor(
context: Context
) {
val isOnline: Flow<Boolean> = callbackFlow {
val connectivityManager = context.getSystemService<ConnectivityManager>()
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) { trySend(true) }
override fun onLost(network: Network) { trySend(false) }
}
connectivityManager?.registerDefaultNetworkCallback(callback)
awaitClose { connectivityManager?.unregisterNetworkCallback(callback) }
}.distinctUntilChanged()
}
ConnectivityManager.NetworkCallback — reactive, battery-efficientNWPathMonitor — observe currentPath.statusnavigator.onLine + online/offline events (unreliable) — verify with actual fetch| Bandwidth | Behavior |
|---|---|
| High (WiFi) | Full sync, download images at full resolution, prefetch next pages |
| Medium (4G/5G) | Normal sync, compressed images, no prefetch |
| Low (3G/Edge) | Essential sync only, thumbnail images, pagination reduced |
| None (offline) | Local data only, queue all writes, show offline indicator |
NetworkCapabilities.NET_CAPABILITY_NOT_METERED for WiFi detectionNWPath.isExpensive and NWPath.isConstrainedConnectivityManager may report connected but HTTP requests fail — detect via connectivity check endpointConnectivityManager mock in tests; for manual testing, use Android Emulator's network settings or Charles Proxy throttling| Scenario | Expected Behavior |
|---|---|
| App launch while offline | Shows cached data, no crash, no blocking spinner |
| Create item while offline | Saved locally, shown immediately, sync icon visible |
| Edit item while offline | Local update applied, sync queued |
| Delete item while offline | Removed from UI, soft-deleted locally, sync queued |
| Go online after offline edits | Sync queue processes, all changes uploaded |
| Conflict during sync | Conflict UI shown, user can resolve |
| Server rejects sync operation | Error shown, user can retry or discard |
| Kill app while offline, reopen | Pending operations survive, queue intact |
| Background sync fires | Queue processed without user interaction |
| Token expired during sync | Auth refresh triggered, sync retries automatically |
For every offline-first architecture, deliver:
| Entity | Local Count | Server Count | Pending | Conflicts | Last Sync |
|--------|------------|--------------|---------|-----------|-----------|
| Orders | 142 | 140 | 2 | 0 | 2 min ago |
| Products | 89 | 89 | 0 | 0 | 5 min ago |
| Messages | 1,203 | 1,198 | 3 | 2 | 1 min ago |
| Entity | Field | Strategy | Priority | Notes |
|--------|-------|----------|----------|-------|
| Order | status | Server wins | — | Server is authoritative for status |
| Order | notes | Field merge | Client | User's notes take priority |
| Profile | email | Server wins | — | Email verified server-side |
| Profile | displayName | LWW | — | Non-critical, last write wins |
| Message | content | Client wins | — | User authored the content |
android:
local_db: Room 2.6.x (SQLite)
preferences: DataStore (Preferences or Proto)
sync: WorkManager 2.9.x
network_monitor: ConnectivityManager + Flow
image_cache: Coil 2.x with disk cache
ios:
local_db: SwiftData (iOS 17+) or Core Data
preferences: UserDefaults
sync: BGTaskScheduler + URLSession background tasks
network_monitor: NWPathMonitor
image_cache: URLCache + AsyncImage
web:
local_db: IndexedDB via Dexie.js 4.x or idb
sync: Background Sync API + Service Worker
cache: Cache API for assets, stale-while-revalidate for API
network_monitor: navigator.onLine + periodic health check
firestore:
offline: enableIndexedDbPersistence (web), default on (mobile)
cache_size: 100MB default, increase for data-heavy apps
listeners: onSnapshot for real-time, getDoc for one-shot cached reads
conflict_resolution:
default: field-level merge
fallback: manual resolution UI
collaborative: evaluate CRDT (Yjs, Automerge) for real-time co-editing
Generate offline-first infrastructure using Write:
data/sync/SyncQueue.kt — Room-backed operation queue with WorkManagerData/Sync/SyncQueue.swift — SwiftData-backed queue with BGTaskSchedulersrc/sync/sync-queue.ts — IndexedDB-backed queue with service workersrc/sync/conflict-resolver.ts — last-write-wins or custom merge strategysrc/sync/network-monitor.ts — connectivity detection and queue drain triggersrc/sync/optimistic.ts — temporary ID management and rollbackBefore generating, Grep for existing offline/sync code and detect which data layer is in use (Room, SwiftData, IndexedDB).
/database-architect — schema design for local storage and sync metadata tables/firebase-architect — Firestore offline persistence configuration and security rules/testing-strategy — offline test scenarios in the testing pyramid/performance-review — sync performance budgets and battery impact monitoring/notification-architect — silent push notifications to trigger background sync/i18n — offline-available localized strings