Designing Production-Grade Mobile Apps: Architecture, State, Offline-First, and Failure Handling
DEV Community

Designing Production-Grade Mobile Apps: Architecture, State, Offline-First, and Failure Handling

Designing Production-Grade Mobile Apps A mobile application can look simple from the outside while hiding a surprisingly complex distributed system underneath. A user taps a button. The UI updates. A request is sent. A server validates a token, writes data to a database, triggers a background job, and returns a response. The client then has to decide what to render if the network is slow, the request times out, the token expires, the process is killed, the device goes offline, or the user taps the same action twice. That is the real engineering problem behind production mobile applications. This article presents a failure-first approach to mobile application design. The goal is not to pick a trendy framework or produce another screen-by-screen UI tutorial. The goal is to define boundaries, state transitions, data ownership, networking behavior, and recovery strategies that keep a mobile codebase understandable as the product grows. The examples are framework-neutral, but the principles map well to Kotlin/Android, Swift/iOS, Flutter, and React Native architectures. 1. Start With a System Model, Not a Screen List A common mistake is to begin with a list of screens: Login Home Profile Orders Checkout Settings That is useful for a design review, but insufficient for engineering. Before implementation, define the system boundaries: +----------------------- Mobile Client -----------------------+ | UI -> State -> Use Cases -> Repositories -> Data Sources | | | | | | | +--> Local DB / Cache | | +----------------------> Network Client | +-------------------------------------------------------------+ | v API / Backend Services | +-----------+-----------+ | | Database External APIs The important question is not only "What screens do we have?" It is: Which layer owns each decision, and what happens when the expected dependency is unavailable? For example, a UI component should not decide how a token is refreshed. A network interceptor should not decide whether a user should see an empty state. A repository should not know how a composable or view controller is rendered. Clear ownership reduces accidental coupling. 2. Use Explicit Architectural Boundaries A practical structure for a growing application is: presentation/ screens/ viewmodels/ ui-state/ domain/ entities/ usecases/ repositories/ data/ api/ database/ cache/ repository-impl/ core/ networking/ auth/ logging/ analytics/ errors/ The exact folder names do not matter. The dependency direction does. A useful rule is: Presentation -> Domain -> Data The domain layer should not depend on HTTP clients, SQLite implementations, Android Activities, SwiftUI views, or other framework details. A repository interface might look like this: interface OrderRepository { suspend fun getOrders(forceRefresh: Boolean = false): Result > suspend fun submitOrder(command: SubmitOrder): Result } The UI calls a use case rather than constructing HTTP requests directly: class SubmitOrderUseCase( private val repository: OrderRepository ) { suspend operator fun invoke(command: SubmitOrder): Result { return repository.submitOrder(command) } } This separation pays off when the same business operation later needs to work with a cache, a queue, a mock API, or a second client. 3. Treat UI State as a State Machine Many production bugs come from UI state being represented by unrelated booleans. This pattern is fragile: var isLoading = false var hasError = false var isEmpty = false var hasData = false It allows impossible combinations such as: isLoading = true hasError = true hasData = true Instead, model meaningful states explicitly: sealed interface OrdersUiState { data object Loading : OrdersUiState data class Content( val orders: List , val isRefreshing: Boolean = false ) : OrdersUiState data class Empty( val canRetry: Boolean = true ) : OrdersUiState data class Error( val message: String, val canRetry: Boolean = true ) : OrdersUiState } Now the screen has a finite set of states. That matters because a real mobile client has more than a happy path: Cold Start | v Loading -----> Error | v Content -----> Refreshing -----> Content | +---------> Offline An explicit state machine is much easier to test than a collection of flags. 4. Separate Remote State, Local State, and UI State A useful distinction is: Remote state: what the server currently knows. Local state: what is persisted on the device. UI state: what the current screen needs to render. For example, a profile record may exist in a local database for 30 minutes, while the UI only cares whether the record is available and whether a refresh is running. Do not make UI state the database schema. Instead: Remote DTO | v Mapper | v Domain Model | v UI Model This gives the client freedom to change API contracts without forcing every screen to change at the same time. 5. Design Networking Around Failure, Not Just HTTP 200 A request can fail in many ways: DNS failure TCP connection failure TLS failure timeout HTTP 401 HTTP 403 HTTP 404 HTTP 409 HTTP 422 HTTP 429 HTTP 500 HTTP 503 malformed response partial response client cancellation Treating every non-200 response as Exception("Request failed") destroys useful information. Use a normalized error model: sealed interface NetworkError { data object NoConnection : NetworkError data object Timeout : NetworkError data object Unauthorized : NetworkError data object Forbidden : NetworkError data object NotFound : NetworkError data object Conflict : NetworkError data object RateLimited : NetworkError data object ServerUnavailable : NetworkError data class InvalidResponse(val code: Int?) : NetworkError } Then map transport errors into domain-safe errors before they reach the UI. The screen should not need to know what SocketTimeoutException means. 6. Token Refresh Needs Concurrency Control A particularly subtle problem occurs when several API calls receive 401 Unauthorized at nearly the same time. A naive interceptor may start a refresh for every request: Request A -> 401 -> refresh token Request B -> 401 -> refresh token Request C -> 401 -> refresh token That can create race conditions. Instead, make token refresh a single-flight operation: A ----\ B -----+----> refreshOnce() ----> new token C ----/ Conceptually: private var refreshInFlight: Deferred ? = null suspend fun getFreshToken(): Token { val existing = refreshInFlight if (existing != null) return existing.await() val created = scope.async(start = CoroutineStart.LAZY) { authApi.refresh(refreshToken) } refreshInFlight = created return try { created.await() } finally { refreshInFlight = null } } The implementation varies by platform, but the invariant should remain: concurrent unauthorized requests must coordinate around one refresh operation. 7. Retry Is a Policy, Not a Reflex Retrying everything is dangerous. This is usually acceptable for idempotent reads: GET /products GET /profile It is much more dangerous for a mutation: POST /payments POST /orders POST /transfers Imagine the client times out after the server successfully processes the payment. The client sees a timeout and retries. Without idempotency, the server may process the same operation twice. For important mutations, send an idempotency key: POST /orders Idempotency-Key: 5f3e4d1c-... The backend stores the result associated with that key and returns the same logical result for a duplicate request. A retry policy should consider: Is the operation idempotent? Was there a transport failure or a definitive business failure? Is the response retryable? Has the maximum attempt count been reached? Would another attempt create a duplicate side effect? 8. Use Exponential Backoff With Jitter If 5,000 clients receive a temporary server failure and all retry exactly two seconds later, the server can receive another synchronized spike. A more robust delay is approximately: backoff = min(maxDelay, baseDelay * 2^attempt) with random jitter: wait = random(0, backoff) For example: Attempt 1: 0.5s - 1.0s Attempt 2: 1.0s - 2.0s Attempt 3: 2.0s - 4.0s Use an upper bound, and stop retrying when the operation should surface an error to the user. 9. Offline-First Does Not Mean "Always Work Offline" Offline-first means the product has an explicit strategy for limited connectivity. For read-heavy applications, a common flow is: UI | v Repository | +----> Local DB ----> immediate render | +----> Network ----> refresh local DB The screen can display cached content immediately, then update when fresh data arrives. For writes, the architecture is more complex: User Action | v Local Transaction | +--> pending operation queue | v UI updates optimistically | v Sync Worker | +--> success -> mark synced | +--> conflict -> resolve | +--> transient error -> retry | +--> permanent error -> surface action The conflict strategy must be explicit. Some domains can use last-write-wins. Others need version numbers, server reconciliation, or user intervention. 10. Cache With Semantics Caching is not simply: if cachedData != null return cachedData The application needs to know what the cache means. A practical model is: Fresh Stale-but-usable Expired Missing For example: data class Cached ( val value: T, val fetchedAt: Instant ) { fun isStale(now: Instant, ttl: Duration): Boolean = now - fetchedAt > ttl } Different data should have different TTLs. A user profile may tolerate minutes or hours. Stock prices, delivery tracking, and chat messages require a very different strategy. 11. Deep Links Are Part of Navigation Architecture A deep link should not simply open a screen. Consider: myapp://orders/123 What happens when the user is logged out? A robust navigation pipeline is closer to: Deep Link | v Parse Route | v Validate Parameters | +--> authenticated? -- no --> Login | | | v | restore intent | +--> yes --> load resource --> render The original route should survive authentication where appropriate. You also need to think about invalid IDs, deleted resources, permissions, and links opened from cold start versus warm start. 12. Push Noti

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.