DEV Community

az hala
az hala

Posted on

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

The exact folder names do not matter. The dependency direction does.

A useful rule is:

Presentation -> Domain -> Data
Enter fullscreen mode Exit fullscreen mode

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<List<Order>>
    suspend fun submitOrder(command: SubmitOrder): Result<Order>
}
Enter fullscreen mode Exit fullscreen mode

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<Order> {
        return repository.submitOrder(command)
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

It allows impossible combinations such as:

isLoading = true
hasError = true
hasData = true
Enter fullscreen mode Exit fullscreen mode

Instead, model meaningful states explicitly:

sealed interface OrdersUiState {
    data object Loading : OrdersUiState

    data class Content(
        val orders: List<Order>,
        val isRefreshing: Boolean = false
    ) : OrdersUiState

    data class Empty(
        val canRetry: Boolean = true
    ) : OrdersUiState

    data class Error(
        val message: String,
        val canRetry: Boolean = true
    ) : OrdersUiState
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

That can create race conditions.

Instead, make token refresh a single-flight operation:

A ----\
B -----+----> refreshOnce() ----> new token
C ----/
Enter fullscreen mode Exit fullscreen mode

Conceptually:

private var refreshInFlight: Deferred<Token>? = 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
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

It is much more dangerous for a mutation:

POST /payments
POST /orders
POST /transfers
Enter fullscreen mode Exit fullscreen mode

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-...
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

with random jitter:

wait = random(0, backoff)
Enter fullscreen mode Exit fullscreen mode

For example:

Attempt 1: 0.5s - 1.0s
Attempt 2: 1.0s - 2.0s
Attempt 3: 2.0s - 4.0s
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The application needs to know what the cache means.

A practical model is:

Fresh
Stale-but-usable
Expired
Missing
Enter fullscreen mode Exit fullscreen mode

For example:

data class Cached<T>(
    val value: T,
    val fetchedAt: Instant
) {
    fun isStale(now: Instant, ttl: Duration): Boolean =
        now - fetchedAt > ttl
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 Notifications Need Idempotent Navigation

A notification may be delivered more than once or opened after the underlying resource changes.

Do not blindly navigate from the payload.

Instead:

Notification Payload
        |
        v
Validate
        |
        v
Map to Application Intent
        |
        v
Check Auth / Permissions
        |
        v
Fetch Current Data
        |
        v
Navigate
Enter fullscreen mode Exit fullscreen mode

This prevents the client from treating notification payloads as authoritative business data.

13. Design for Partial Failure

A mobile application rarely fails completely. Usually, only part of the experience fails.

Example:

Dashboard
├── User profile      -> OK
├── Recommendations   -> timeout
├── Notifications     -> OK
└── Orders             -> stale cache
Enter fullscreen mode Exit fullscreen mode

The correct UI is not necessarily a full-screen error.

A better approach is to make independent components fail independently:

DashboardState {
    profile: Content
    recommendations: Error
    notifications: Content
    orders: StaleContent
}
Enter fullscreen mode Exit fullscreen mode

This increases resilience and gives users a useful product even when one dependency is unavailable.

14. Security Starts at the Data Boundary

Do not store sensitive data just because storage is convenient.

Classify data first:

Public
Internal
Sensitive
Secrets / Credentials
Enter fullscreen mode Exit fullscreen mode

For credentials and tokens, use platform secure storage rather than ordinary preferences or an unencrypted database.

Also consider:

  • certificate and TLS validation
  • secure logging
  • least-privilege permissions
  • secure deep-link validation
  • input validation
  • server-side authorization
  • protection against replay
  • dependency updates

A mobile client is an untrusted environment. Any authorization decision that matters must be enforced by the server.

15. Observability Must Exist Before the First Incident

A production app without telemetry makes debugging guesswork.

At minimum, track:

app_start
screen_view
api_request_failed
api_request_duration
auth_refresh
sync_started
sync_failed
purchase_started
purchase_completed
Enter fullscreen mode Exit fullscreen mode

Avoid logging secrets and personally sensitive values.

For an API request, useful telemetry might include:

{
  "operation": "GET /orders",
  "duration_ms": 483,
  "status": 200,
  "retry_count": 0,
  "network": "wifi",
  "app_version": "3.4.1"
}
Enter fullscreen mode Exit fullscreen mode

A request ID or trace ID shared between mobile and backend services can make distributed debugging dramatically easier.

16. Performance: Measure the Critical Path

Avoid premature optimization, but measure the parts that directly affect user-perceived latency.

For a cold start, the critical path might be:

Process launch
 -> dependency initialization
 -> storage initialization
 -> authentication restore
 -> first screen render
Enter fullscreen mode Exit fullscreen mode

Do not block the first meaningful render on work that does not need to happen first.

For network-bound screens, measure:

DNS
TCP/TLS
request queue time
server time
payload transfer
deserialization
render
Enter fullscreen mode Exit fullscreen mode

This gives you a performance budget that can be acted upon instead of a vague statement such as "the app feels slow."

17. API Design Should Match Client Behavior

A mobile client often works on unreliable networks and constrained devices. APIs should account for that.

Useful properties include:

  • pagination
  • stable resource identifiers
  • explicit error codes
  • versioned contracts
  • idempotency for important writes
  • partial update semantics
  • cache headers where appropriate
  • compact payloads

For example, returning 5,000 records because the mobile client "might need them later" creates unnecessary transfer and memory costs.

Cursor-based pagination is often more robust for rapidly changing collections:

GET /orders?limit=20&cursor=eyJpZCI6MTIzfQ==
Enter fullscreen mode Exit fullscreen mode

The client should not have to download the entire collection to display the first screen.

18. Testing Should Target Failure Paths

The strongest mobile test suite is not the one with the most screenshot tests. It is the one that exercises the state transitions that are easy to break.

Test cases should include:

valid response
empty response
slow response
timeout
401 + refresh
401 + refresh failure
429
500
offline
cache hit
stale cache
conflict
process restart
rotation / configuration change
background -> foreground
notification cold start
duplicate mutation
Enter fullscreen mode Exit fullscreen mode

For a repository, tests might assert:

@Test
fun `timeout returns cached value when cache is usable`() = runTest {
    // arrange
    // act
    // assert
}
Enter fullscreen mode Exit fullscreen mode

For synchronization logic, property-based or state-transition testing can uncover combinations that example-based tests miss.

19. Product Design and Engineering Design Are Connected

A technically clean architecture can still produce a poor product if the user flow is wrong.

Before implementing a feature, define:

User goal
Primary action
Required data
Failure states
Permission requirements
Offline behavior
Success state
Recovery action
Enter fullscreen mode Exit fullscreen mode

This is where product and engineering design meet. A serious mobile product needs both interaction design and implementation architecture; the mobile app design process used by AzkiWeb is an example of treating UX flows, API integration, authentication, testing, and technology selection as connected decisions rather than isolated deliverables.

The important lesson is not that a particular agency or framework is always correct. The lesson is that product design and technical architecture should be considered together.

20. A Practical Pre-Release Checklist

Before shipping a production mobile application, verify at least the following:

Architecture

  • Are domain rules independent from UI frameworks?
  • Are repositories responsible for data access rather than screens?
  • Can the networking layer be tested independently?

State

  • Are loading, empty, error, stale, and success states explicit?
  • Can partial failures be represented?
  • Can the screen recover without restarting the app?

Networking

  • Are timeouts configured?
  • Are retries limited and policy-driven?
  • Are important mutations idempotent?
  • Is token refresh concurrency-safe?

Offline

  • Is cached data usable?
  • Are writes queued safely?
  • What happens after process termination?
  • How are conflicts resolved?

Security

  • Are credentials stored using secure platform facilities?
  • Are secrets excluded from logs?
  • Are authorization decisions enforced on the server?
  • Are deep links validated?

Performance

  • Is cold-start time measured?
  • Are API and rendering bottlenecks observable?
  • Are large lists paginated?
  • Are unnecessary blocking operations removed from the critical path?

Testing

  • Are failure paths covered?
  • Are background/foreground transitions tested?
  • Are duplicate actions tested?
  • Are authentication edge cases covered?

Conclusion

Production mobile application design is mostly about managing uncertainty.

The happy path is easy to draw:

Tap -> Request -> Response -> Render
Enter fullscreen mode Exit fullscreen mode

The real system looks more like:

Tap
 |
 +--> offline?
 |
 +--> loading?
 |
 +--> duplicate action?
 |
 +--> expired token?
 |
 +--> timeout?
 |
 +--> server error?
 |
 +--> stale cache?
 |
 +--> process restart?
 |
 +--> partial failure?
 |
 +--> conflict?
 |
 v
Recoverable application state
Enter fullscreen mode Exit fullscreen mode

When those cases are designed before implementation, the codebase becomes easier to reason about and the product becomes more resilient.

Good mobile engineering is not simply choosing Flutter, React Native, Swift, Kotlin, or another stack. The important work is defining boundaries, modeling state, making data ownership explicit, designing for unreliable networks, and giving every important failure a deliberate recovery path.

That is what turns a collection of mobile screens into a production system.

Top comments (0)