DEV Community

Rockey
Rockey

Posted on

Mobile Storage Is a Distributed Systems Problem Wearing a Local API

Mobile Storage Is a Distributed Systems Problem Wearing a Local API

Most mobile storage bugs are not caused by a missing database index. They happen because an app treats device storage as permanently available, instantly consistent, and private by default. None of those assumptions is safe.

On a phone, a saved record lives inside a sandbox, under disk-pressure rules, encryption and device-lock policies, backup/restore behavior, process death, OS upgrades, and an unreliable network that may later disagree with it. The useful mental model is this: local storage is a small, encrypted, failure-prone replica of product state. Design it like one.

This article builds a production storage architecture that works across iOS, Android, Flutter, and React Native without confusing their abstractions with native behavior.

The shape of a durable mobile write

Suppose a user edits a task while offline. The app should make that change feel immediate, but it also needs to survive a force-quit and eventually reach the server.

UI action
  -> validate intent
  -> one local transaction: domain row + outbox event
  -> render from local query
  -> background sync when allowed
  -> authenticated API request with idempotency key
  -> merge server response / resolve conflict
Enter fullscreen mode Exit fullscreen mode

The key decision is that the UI reads local state, not a request in flight. A local transaction atomically changes the task and inserts an outbox record such as { eventId, entityId, version, operation, retryCount }. If the app dies after the transaction, it restarts with a truthful record of work still to synchronize. If it dies before the transaction commits, neither the UI nor sync sees a partial change.

That is the local-first/outbox pattern. It is not a guarantee that every write reaches the server; it gives you a place to retry safely and to explain state to the user.

Pick storage by data class, not convenience

Use a database for queryable product data: messages, tasks, cached API entities, and an outbox. SQLite-backed options are common on both platforms. Keep migrations small, tested, and forward-only.

Use the platform secure store for small secrets: refresh tokens, private keys, or per-install encryption material. iOS Keychain and Android Keystore are not replacements for a database; they are designed for small credential material and have accessibility/authentication policies. Never place tokens or health data in ordinary preferences, logs, URLs, or React Native AsyncStorage. React Native documents AsyncStorage as unencrypted storage.

Use files for large immutable media, not database blobs. Store metadata and a content checksum in the database; write a temporary file, fsync/close it as the platform allows, then atomically rename it into its final location. A startup reconciler can delete abandoned temporary files.

Class                 Store                         Important rule
User/session secret   Keychain / Keystore           Small, access-controlled, never logged
Product records       Transactional local database  Include schema version and migrations
Large photo/audio     App-private file directory    Metadata + integrity checks in database
Derived API cache     Database/file cache           Disposable; server remains authority
Enter fullscreen mode Exit fullscreen mode

Sandbox is isolation, not a data-retention policy

iOS and Android put each app in a private sandbox. Other apps cannot normally read it, but that does not mean all files have the same durability or backup semantics. Caches may be evicted; temporary directories can disappear; documents/app-support locations should be chosen deliberately. On iOS, file-protection classes determine when encrypted files are accessible relative to device lock. On Android, use app-internal storage for private app data and avoid external/shared storage unless the product really needs user-visible sharing.

Do not assume a reinstall preserves anything. Do not assume a backup should restore everything. For example, a token copied through backup may become invalid, and sensitive decrypted media often should be excluded. Treat restore as an input to test, not a happy-path feature you get for free.

Lifecycle and background execution: sync is opportunistic

When the user backgrounds the app, there is no cross-platform promise that your remaining sync completes. iOS may suspend the process quickly; Android permits scheduled work but enforces constraints and can stop work. Flutter and React Native are bridges on top of those native constraints, not exemptions from them.

The safe rule is: commit the local transaction before requesting background execution. A scheduler is a hint to make progress later, never the only durability mechanism. On Android, model network/battery constraints with WorkManager. On iOS, use the appropriate background task capability and expect the OS to decide when it runs. Both need idempotent operations and short, cancellable units of work.

Native implementation sketches

Swift: transaction first, cancellation-aware sync

func editTask(_ change: TaskChange) async throws {
  try database.write { db in
    try db.apply(change)
    try db.insert(OutboxEvent(change: change, id: UUID()))
  }
  await syncScheduler.requestSoon()
}

func syncOne(_ event: OutboxEvent) async throws {
  try Task.checkCancellation()
  let token = try keychain.read("refresh-token")
  let response = try await api.send(event, bearer: token)
  try database.write { db in try db.acknowledge(event, response: response) }
}
Enter fullscreen mode Exit fullscreen mode

The database transaction protects local consistency; Task.checkCancellation() prevents work from continuing blindly when the OS or caller cancels it. Keychain access may fail when the device is locked depending on the chosen accessibility class, so surface a retryable state rather than deleting credentials.

Kotlin: transaction plus constrained WorkManager

suspend fun edit(change: TaskChange) = db.withTransaction {
  taskDao.apply(change)
  outboxDao.insert(OutboxEvent.from(change))
}.also { scheduleSync() }

val request = OneTimeWorkRequestBuilder<OutboxWorker>()
  .setConstraints(Constraints.Builder()
    .setRequiredNetworkType(NetworkType.CONNECTED).build())
  .build()

override suspend fun doWork(): Result = try {
  repository.syncPending(coroutineContext)
  Result.success()
} catch (e: IOException) { Result.retry() }
Enter fullscreen mode Exit fullscreen mode

Room and WorkManager are Android-native components. The scheduler does not make an arbitrary long task safe: keep work bounded, honor coroutine cancellation, and make server writes idempotent. Store cryptographic keys in Keystore-backed mechanisms, not in SharedPreferences.

Flutter/Dart: persist before scheduling platform work

Future<void> edit(TaskChange change) async {
  await database.transaction(() async {
    await tasks.apply(change);
    await outbox.insert(OutboxEvent.from(change));
  });
  await syncBridge.requestPlatformSync();
}

Future<void> syncOne(OutboxEvent event) async {
  final token = await secureStore.read(key: 'refresh-token');
  await client.send(event, token).timeout(const Duration(seconds: 20));
  await outbox.acknowledge(event.id);
}
Enter fullscreen mode Exit fullscreen mode

The database and secure-store packages are framework choices; actual background scheduling still must be implemented by an iOS/Android plugin and obey each OS. A timeout does not cancel a server operation: use idempotency keys so retry is safe.

React Native/TypeScript: keep secrets out of AsyncStorage

export async function edit(change: TaskChange) {
  await db.transaction(async () => {
    await tasks.apply(change);
    await outbox.insert(toEvent(change));
  });
  await nativeSync.requestSoon();
}

export async function syncOne(event: OutboxEvent, signal: AbortSignal) {
  const token = await secureCredentials.get("refresh-token");
  const response = await fetch(apiUrl, {
    method: "POST", signal,
    headers: { Authorization: `Bearer ${token}`, "Idempotency-Key": event.id },
    body: JSON.stringify(event)
  });
  if (!response.ok) throw new Error(`sync failed: ${response.status}`);
  await outbox.acknowledge(event.id);
}
Enter fullscreen mode Exit fullscreen mode

React Native code executes through framework runtimes, but disk encryption, background duration, and credential protection remain native concerns. Put the database and scheduler behind a small native-aware interface so app code does not pretend both platforms behave identically.

Failure, conflict, and migration design

Every sync request needs an idempotency key. Every server response needs a version or revision. For conflicts, define product policy before implementation: last-writer-wins for a draft may be acceptable; a financial action may require explicit resolution. Do not quietly overwrite a newer server value just because a device came online later.

Migrations deserve their own release plan. Ship a migration that can resume after interruption, measure migration duration, maintain a rollback/recovery path, and never delete an old column in the same release that stops writing it if older app versions still exist. Encrypting a local database can protect at-rest content, but key rotation, device-lock behavior, crash recovery, and backups must be designed around it.

Senior-level review checklist

  • Is every user-visible mutation locally atomic?
  • Can offline work be retried safely after process death?
  • Are credentials only in Keychain/Keystore-backed storage?
  • Do cache, document, temporary, and backup choices match the retention requirement?
  • Are background operations constrained, cancellable, short, and idempotent?
  • Are sync queue depth, failure reason, migration timing, and cache eviction observable?
  • Is conflict resolution explicit and testable?

Conclusion

The best mobile storage design makes the app useful without a network, truthful after a crash, and conservative with sensitive data. Start with an atomic local mutation plus an outbox, separate secrets from records and files, and let platform lifecycle rules shape the sync design. The APIs vary; the system-design obligation does not.

Sources

Top comments (1)

Collapse
 
arslan_ah profile image
Arslan Ahmad •

"Local storage is a small, encrypted, failure-prone replica" is the right framing. Once you say replica, everything else follows: idempotency keys, versions, a conflict policy.

One thing I'd add to the checklist: ordering inside the outbox. If a user edits the same task three times offline, you either send those events in strict per-entity order or compact them into one. Parallel sync workers draining the outbox out of order can let edit #2 land after edit #3, and a server version check won't save you if the client bumps versions locally. Draining per entityId (one in-flight event per entity) keeps it simple and still lets unrelated entities sync in parallel.

This is also one of the better mobile system design interview prompts. Candidates who open with "the offline write goes to an outbox in the same local transaction" instead of "we call the API and cache the response" stand out fast.