← All problems

Mobile System Design

The Android App Template

mediumFree

Every concrete app design in this catalog - Weather App, Notes App, Newsfeed, Messenger, Photo Gallery, Music Player, Doc Editor, Ride-Sharing, and others to come - inherits the architecture in this article. What changes between them is the domain: audio, video, location, real-time messaging. What doesn't change is the skeleton underneath.

This page is deliberately long, with eight deep dives, to cover the many common angles an Android system-design question can probe, treat it as a reference to return to, not a single sitting.

Google has defined a very structured architecture for modern Android apps. It covers most of the common cases, which is what makes knowing it the key to Android system-design questions. This list is a mix of the canonical architecture and stack choices:

  • Unidirectional Data Flow (UDF) - the three-layer architecture (UI / Domain / Data) runs on UDF: events flow down (the screen emits an intent, the layers below decide what it means), state flows up (each layer exposes a Flow, the screen is a pure function of the latest value). One owner per layer, one direction each.
  • Compose - Android's modern UI toolkit. A screen is a @Composable that takes an immutable UiState and renders it, when the state changes, Compose recomposes only the nodes whose inputs changed.
  • ViewModel + StateFlow - the ViewModel owns the screen's state as a StateFlow<UiState> (a hot stream that always has a current value). Survives rotation, the screen never loses state.
  • Room + Retrofit - Room is Android's SQLite wrapper (on-disk cache), Retrofit is a REST client. Data arrives as Kotlin Flows from both.
  • Use cases - thin Kotlin classes wrapping one or two repository calls plus business logic the UI shouldn't know about. They keep the ViewModel off the data layer, making it unit-testable without Room.
  • Hilt - the DI framework that wires the layers at compile time. A missing binding fails the build, not the app on launch. Modules sit at the two layer seams, everything else is constructor injection.

The architectural core is usually follows - UDF, the repository over a two-tier cache, optimistic writes through an outbox, cursor pagination, multi-module architecture - and only the details change per app. The API surface in this writeup uses a generic /v1/items namespace and the domain entities are placeholders, a concrete design swaps in its own resource names and content types.

In mobile system design, the interesting design pressure is boundary discipline, the technology usually defaults to the same stack. Compose + Hilt + Room + OkHttp + Coil is the established Android combo, what separates a working app from a maintainable one is where the layers cut. Boundry discipline usually shows up here:

The shape of the code - where the layers cut and who enforces it:

  • Layering is the contract, not a preference (dd1) - features that skip the boundary become unowned code, harder to test, harder to maintain.
  • The repository is the only thing that knows there's a network (dd2) - everything above it sees a Flow, everything below it is plumbing.
  • Multi-module is a day-one decision (dd6) - the build (compilation) cost compounds linearly with feature count, smaller modules let gradle be more efficient in incremental builds

The data paths - how reads, writes, and errors move:

  • Tap→render must beat the network round-trip (dd3) - the write path inverts the read path: optimistic UI first, server ack as a side effect.
  • Errors are normal, not exceptional (dd4) - retry budget, banner state, idempotency keys, silent failure is the worst outcome on a flaky network.
  • Pagination is cursor or wrong (dd5) - offset drifts under top-insert, only opaque cursors survive a re-ranking server.
Phase 01

Requirements

Clarifying Questions

QuestionAnswerWhat it signals
Platform - Android only, or cross-platform?Android onlyLocks this stack.
KMP (Kotlin Multiplatform) keeps the 90% (data + domain),
React Native/Flutter replaces all of it.
You will rarely be asked to design in other frameworks (unless you already know the role requires that)
User interactions in scope?Like + comment, no creation flowLike + comment are simple writes.
A creation flow forces a multi-stage write pipeline.
Offline reads?YesForces a local cache as the read source-of-truth.
Otherwise the repository is a network proxy.
Offline writes?YesForces writes to survive process death.
Otherwise tap-send loses to process death or duplicates on retry.
Real-time updates?Stretch onlyThe template stays REST-only.
WS / SSE (WebSocket / Server-Sent Events) or FCM (Firebase Cloud Messaging - Google's push service) is each concrete design's call.
Authentication, friend graph, accounts?Out of scopeAssume signed-in, every call carries Authorization: Bearer <token>. No login flows in this design.
Low-end devices, global audience?YesBounds cache sizes, decoder pools, and image budgets. Rules out "premium hardware on WiFi" defaults.

Functional Requirements

  • Show a list of items, tap an item to see its detail
  • Like and comment on items
  • Use the app offline - browse previously seen content and queue interactions
  • Scroll an arbitrarily long list (infinite scroll)
  • See images attached to items
  • See network failures surfaced clearly, never silently lose user input

Non-Functional Requirements

  • Frame budgetUI state updates &l;16 ms, no disk or network on the main thread
  • Cold startFirst screen renders in <100 ms on a cold start, offline included
  • Offline toleranceReads work entirely from cache, writes survive process death and reboot
  • Local storageCache bounded to ~12.5% of available heap, on-disk cache capped (e.g. 200 MB), cleared on logout
  • Battery and data efficiencyNo background polling, minimal network traffic on metered connections
  • Network toleranceGraceful degradation on cellular, lossy, and high-latency networks, transient failures recover without user action
  • Sync convergenceAfter reconnect, local state reconciles to server state in <30 s at p95 (the slowest 5% of syncs)
  • ScaleHolds at the 100 M DAU (daily active users) scale concrete designs target
Phase 02

Data Model + API

Generic Domain Types + REST Surface

The domain entities below are placeholders (this is a "Template App" design). Lines tagged // template are the contract - the fields and shapes you'll need in ~90% of concrete app designs. Lines tagged // specialized are the few substitution points: each concrete design picks its own content type, its own ActionKind values, and its own REST resource name.

// The unit the list and detail screens render. Concrete designs specialize content.
data class Item(
    val id: String,                    // template - server-assigned, stable across edits
    val authorId: String,              // template
    val createdAt: Instant,            // template
    val content: ItemContent,          // specialized - sealed type per concrete app
    val counters: Counters,            // template - server-side, eventually consistent
    val myActions: MyActions,          // template - viewer state, locally optimistic
)
 
// The outbox row. One per queued user action. Idempotency key client-side.
data class OutboxAction(
    val id: String,                                 // template - client-generated UUID, the dedup key
    val itemId: String,                             // template
    val kind: ActionKind,                           // specialized - LIKE, UNLIKE, COMMENT, ...
    val payload: String? = null,                    // template - e.g. comment body, null for likes
    val state: ActionState = ActionState.PENDING,   // template - PENDING, SENT, FAILED
    val createdAt: Instant = Clock.System.now(),    // template
)

Two important notes:

Pagination is opaque-cursor. The server returns nextCursor, the client passes it back on the next request and never tries to parse it. That lets the server switch between id-based, time-based, keyset, or signed-cursor schemes without a client rev, and unlike offset pagination, inserts at the head don't shift the window mid-scroll.

Mutations are idempotent on a client-generated actionId. Idempotent just means safe to send more than once: the server commits it only once no matter how many copies arrive. The client mints a UUID before queuing the write, sends the same id on every retry, and the server dedupes on it. Generating the id client-side is what lets the outbox dispatcher and the server agree on the dedup key without a round-trip, so the client can retry as aggressively as it likes and the server will only commit once.

# List
GET    /v1/items?cursor=<opaque>&limit=20        # template - resource path specialized
       → { items: [Item], nextCursor }           # template - Item type specialized
 
# Detail
GET    /v1/items/{itemId}                        # template - resource path specialized
       → Item                                    # template - Item type specialized
 
# Actions (idempotent on actionId, server dedupes)
POST   /v1/items/{itemId}/actions                # template - resource path specialized
       body: { actionId, kind, payload? }        # template - kind values specialized
       → { item: Item }                          # template - Item type specialized, echoes server state
 
# Auth - every call carries Bearer, the server resolves user from the token
Authorization: Bearer <token>                    # template
Note
Backend is the single source of truth - the one authoritative copy, if the local copy and the server ever disagree, the server wins. The client only stores two things locally:
1. a cache of what the server told it (an in-memory L1 over an on-disk L2, both behind the repository)
2. a queue of writes it hasn't confirmed yet (the outbox table).
Optimistic UI, retries, and offline scrolling are all built on those two stores. The optimistic write mechanic (tap → cache + outbox → server ack → reconcile) is unpacked in the Write Path deep dive (dd3).
Phase 03

High-Level Design

Architecture

The simplest system that satisfies the requirements is six layers deep. The top three (UI, Domain, Data) are the client's own tiers, written in pure Kotlin, the bottom three (Local Storage, Network, Backend) are the platform surfaces and the wire.

A screen renders a UiState held by a ViewModel, a use case sits between the ViewModel and the one ItemRepository, the repository fans down to an L1 in-memory StateFlow, an L2 Room database (cache plus a durable outbox), and a RemoteDataSource built on OkHttp + Retrofit. Coil's ImageLoader (uses the same OkHttp pool). A WorkManager-driven OutboxDispatcher is the only thing that turns queued writes into network calls.

The repository is the only component wired to both the cache stack and the network - the layers above it consume a Flow and never learn whether an item came from disk or the wire.

Durability seam for writes: the outbox lives in the same Room database as the on-disk cache, so a queued likes survive process death exactly the way cached items do.

Read path (dd2), write path (dd3), and push path (dd8) are all covered in deep dives.

Happy-Path - Cold Start, Scroll, Tap-Like

Read this once for the gist - it traces a real session end to end, and every step that looks dense (the cache ladder, the optimistic write) gets its own deep dive in Phase 4.

  1. App launches, the list screen's ViewModel subscribes to observeItems() on the use case, which subscribes to repository.observeItems().
  2. repository.observeItems() returns the L1 StateFlow synchronously - empty on cold start, then populated when Room's observer fires from L2 (one disk read, a few ms). UI renders the cached page in <100 ms.
  3. Meanwhile, repository.refresh() fires RemoteDataSource -> OkHttp -> API for the head page. On response, the new items land in L2 in a single transaction, and L1 follows.
  4. Room's Flow observer fires, the use case re-emits, the StateFlow<UiState> updates, Compose re-composes only the rows that changed.
  5. User scrolls near the end of the cached pages, Paging 3's RemoteMediator triggers load(APPEND) with the last nextCursor, new page is appended to L2 and the diff flows up.
  6. User taps Like: the ViewModel calls performAction(itemId, LIKE). Inside one Room transaction the repository applies the optimistic update to the cached Item (counter +1, myActions.liked = true) and inserts an OutboxAction row, Room's Flow observer fires on commit, so the new state propagates upward automatically - no manual emit needed.
  7. The outbox dispatcher (a long-running coroutine or WorkManager worker) reads PENDING rows in FIFO (first-in-first-out) order and POSTs each via OkHttp, on 2xx the row is deleted and the server's authoritative Item replaces the optimistic L2 row, on transient failure the row stays and is retried with exponential backoff.
  8. Configuration change or process death does not lose anything: the UI rebuilds from L2 via the same Flow, and the outbox dispatcher resumes draining on next launch.
Phase 04

Deep Dives

Scenario

A concrete app design picks a feed, a chat, a player, a doc. The template underneath - what is a "screen", how does it get data, where do business rules live - is identical across all of them. Skipping the layering means every feature reinvents the boundary, getting it wrong means feature teams trip over each other every quarter.

Three layers, each owning one thing:

  • UI (Composable + ViewModel) - renders the current state and emits user intents.
  • Domain (use cases) - the business rules: what a "like" means, what counts as a valid comment, which query shape a given view needs.
  • Data (repository) - the cache and the network. The single component that touches both Room and OkHttp; to the domain layer above it is just a Flow source.

Unidirectional Data Flow (UDF) is the rule the three layers enforce: no layer reaches across or mutates another layer's state.

Why this matters in practice

A bug like "the like count is wrong" has a small search space - it must be either an emit-from-below or a render-from-above. Without UDF, the same bug could be in any of N code paths that write the shared state, and the only way to find it is to read all of them.

How state actually moves up: cold Flow, hot StateFlow

The data layer hands you a cold Flow that only runs while something is watching - collecting dao.observeAll() opens a Room query that re-emits whenever the table changes, and each collector re-runs the producer. The ViewModel turns that into a hot StateFlow<UiState> that always has a value ready for the screen, so Compose gets a value now and a rotation doesn't restart the query. The rest of the Flow mechanics live in State Management and Concurrency.

The use-case design rule

The rule that interviewers probe: reads multiply by view, writes converge through one entrypoint.

  • Reads: every new way to view data (filter by author, search, sort, archived-only) needs its own use case, because each runs a different query. You can't reuse one.
  • Writes: a new action (like, unlike, comment, bookmark) does not need a new use case. The plumbing is identical for all of them - open a transaction, apply the change, queue an outbox row, kick the dispatcher. Only the action kind changes, so it's just a new enum value plus one branch in the switch.

The smell it warns against: LikeItemUseCase, UnlikeItemUseCase, CommentUseCase as separate classes - N copies of the same plumbing, varying only in the kind string they pass down. Explodes the domain layer for nothing.

The exception: if an action needs real pre-write logic (validate a comment, rate-limit, route to a different repository), put that in a domain helper the one use case calls - don't spin up a parallel use case class for it.

Hilt turns the layer boundaries into build errors - modules sit at the two seams, and the domain and data layers should still compile with every Hilt import stripped. The mechanics are covered in Dependency Injection.

Scenario

A scrolling list that makes an API call per visible row is unshippable on cellular. A cache that returns stale data after a server-side update is worse than no cache - the staleness is silent. How do we orchestrate the cache ladder so the UI never sees the network directly and the cache stays consistent with the wire?

Keep a fast in-memory copy (L1) in front of the on-disk Room cache (L2, Android's SQLite layer) so scrolling never waits on a database query, and let the repository hide the network behind one Flow.

The diagram below is the read-path delta - the four lanes the read touches. UI and Domain are pass-through (the screen subscribes via ObserveItems) and omitted. L1 is a hot mirror of L2, kept hydrated by Room's Flow observer, reads return L1 directly with no SQLite round-trip:

BadViewModel calls the API directly

ViewModel.init { fetch() } hits the API on every screen entry. No offline support, flaky on cellular, every navigation event pays the round-trip cost. The first time a user opens the app on a subway the screen is empty - the experience is "is this broken?".

GoodRepository with a single-tier Room cache

The repository exposes observeItems(): Flow<List<Item>> backed by Room. refresh() calls the API and writes results into Room, Room's observer fires, the Flow re-emits. Offline reads work. The problem: every read after the cache is hydrated still goes through a SQLite query, which is on the order of milliseconds. On a scrolling list with row-level reads (counters, my-actions state), that cost shows up in frame-time budgets.

GreatTwo-tier cache: L1 in-memory, L2 Room, repository as orchestrator

The repository keeps an L1 MutableStateFlow<List<Item>> as a hot mirror of L2. On construction it subscribes to L2's Room Flow and pipes every emission into L1 - so L1 is never a lazy-fill cache that suffers misses, it's always already populated by the time anything reads it. Reads return L1 directly with no SQLite round-trip on the read path. Writes hit L2 inside a transaction, the L2 → L1 mirror handles propagation. refresh() writes the network response to L2 the same way, and Room's observer drives the same L1 update.

class ItemRepositoryImpl @Inject constructor(/* dao, remote, scope */) : ItemRepository {
    private val l1 = MutableStateFlow<List<Item>>(emptyList())
 
    init {
        // L1 is a hot mirror of L2, Room's Flow drives it.
        dao.observeAll().onEach { l1.value = it.toDomain() }.launchIn(scope)
    }
 
    override fun observeItems(): Flow<List<Item>> = l1
 
    override suspend fun refresh() {
        val page = remote.fetchItems(cursor = null)             // network
        dao.replaceHead(page.items.toEntities())                // L2 write, L1 hydrates via observer
    }
}

L1 is bounded by Runtime.maxMemory() / 8, a deliberately conservative slice of the heap. L2 is bounded at 200 MB with LRU eviction and is cleared on logout. The boundary is enforced at write time, not at read - the dao.insert path checks size after the insert and evicts the oldest rows if over. Because L1 is smaller than L2, it holds the observed working set, not all of L2: the unbounded infinite-scroll feed is paged from Room by Paging 3 (the pagination dive), while L1 is the hot path for detail and bounded-list reads.

Note
The repository is the only layer that knows there's a network - the use case calls repository.observeItems() and gets an always-hot Flow backed by L1, refresh is a separate concern the repository owns, with the network → L2 → L1 update happening behind the same Flow. Concrete designs vary cache sizes and eviction policy, the ordering (L1 mirrors L2, L2 is the durable store, remote is the supplier) is invariant.
Scenario

Tap-like must light up the heart within one frame (~16 ms). Waiting for a network round-trip is impossible. The user might be offline, might tap-then-untap five times in two seconds, or the server might be down for ten minutes. None of these are exceptional - they're the normal envelope. How do we render the tap instantly and reconcile with the server later?

Update the screen instantly, write the action into a durable queue (the "outbox"), and let a background worker push it to the server - so a tap survives offline, a flaky network, and the OS killing the app.

The outbox pattern is borrowed from email clients - Gmail and Outlook have shipped it for a decade because a dropped like and a dropped email have the same failure mode: silent loss. The difference is email users notice, feed users don't, so the bar for apps is lower and most teams clear it by accident.

The diagram below is the write-path delta - the four lanes the write touches. UI and Domain are pass-through (the tap arrives via PerformAction) and omitted. The outbox lives in the same Room database as the item cache, so a queued write survives process death exactly the way cached items do:

BadFire-and-forget API call from the ViewModel

viewModelScope.launch { api.like(itemId) }. Kill the app mid-flight and the launched coroutine goes down with the ViewModel, so the like never reaches the server. The UI updates only on response, so the heart lags 100-500 ms behind the tap. On any failure, the UI shows the unliked state and the user assumes the tap didn't register.

GoodOptimistic UI update + retry on failure

Update the L1/L2 state immediately, fire the API call, retry on transient failure. The UI feels instant. The flaw: if the app dies between optimistic write and network success, the local state says "liked" but the server doesn't know. On next launch the like silently disappears when the server's value comes back.

GreatOutbox table + idempotency key + sync worker

Tap-like is two writes inside one Room transaction - apply the optimistic mutation to the cached Item and append an OutboxAction row. Room's Flow observer fires on commit, so the UI re-renders without an explicit emit. A separate OutboxDispatcher reads PENDING rows in FIFO order and dispatches them via OkHttp. On success the row is deleted and the server's authoritative Item replaces the optimistic one. On failure the row stays and is retried with exponential backoff.

One perform() entrypoint covers every action kind. The orchestration (transaction + outbox row + dispatcher kick) is identical for LIKE, UNLIKE, COMMENT, and any future kind, only the optimistic-update branch and the payload differ. The when (kind) lives in applyOptimisticUpdate, not in the use case layer - adding BOOKMARK is one new enum value and one new branch, not a new use case.

suspend fun perform(itemId: String, kind: ActionKind, payload: String? = null) {
    val actionId = UUID.randomUUID().toString()
    db.withTransaction {
        applyOptimisticUpdate(itemId, kind, payload)            // when(kind) → LIKE +1, COMMENT push, ...
        outboxDao.insert(OutboxAction(actionId, itemId, kind, payload))
    }
    outboxDispatcher.kick()                                      // best-effort wake
}
 
private suspend fun applyOptimisticUpdate(itemId: String, kind: ActionKind, payload: String?) {
    when (kind) {
        LIKE    -> dao.toggleLike(itemId, liked = true)
        UNLIKE  -> dao.toggleLike(itemId, liked = false)
        COMMENT -> dao.insertLocalComment(itemId, body = payload!!)
        // new kinds add a branch here, not a new use case.
    }
}

The dispatcher side: what kick() actually wakes. outboxDispatcher.kick() just pokes a background worker to wake up and drain the queue - it's best-effort, so calling it twice does no harm. Under the hood it resolves to a WorkManager.enqueueUniqueWork(...) call: the same WorkManager durability the push path leaned on, with pending work persisted and resurrected after process death. The worker is constructed fresh per execution and borrows the singleton DAOs and API client for one doWork() call.

enqueueUniqueWork with ExistingWorkPolicy.KEEP is the coalescing knob: a burst of LIKE / UNLIKE taps calls kick() many times in a second, the second-through-Nth calls are no-ops because a worker is already enqueued under the unique name. The first run drains everything PENDING in FIFO order, so coalescing happens naturally at the queue level rather than via per-tap debouncing. The KEEP policy itself and the backoff semantics on Result.retry() are WorkManager mechanics; the design move here is choosing KEEP so a burst collapses, not the mechanics of the policy.

The same WorkManager primitive backs the push path - the FCM receiver enqueues a parallel ItemsRefreshWorker that calls ItemRepository.refresh(), Deep Dive 8 unpacks the push-path side.

What this buys: exactly-once delivery across retries, because the server dedupes on actionId, durability across process death, because the outbox row lives in Room and the dispatcher resumes draining on next launch, and cheap rapid taps, because the KEEP policy above collapses a burst into one drain. (An optional refinement cancels a LIKE still sitting in the queue when its UNLIKE arrives, so neither ships.)

Step through one outbox row across the four situations it has to survive - a coalesced double-tap, a transient 5xx, a permanent 4xx, and a process kill - and watch where the optimistic UI, the queue, and the server agree or diverge:

Note
The write path inverts the read path's source-of-truth direction - reads pull from the server through the cache, writes push to the server through the outbox. The local cache is authoritative until the server acks, after the ack, the server's value wins and the optimistic state is reconciled, not extrapolated.
Scenario

On mobile, network errors are the default. I've shipped apps where the #1 crash for the first week was SocketTimeoutException - not because the code was wrong, but because nobody planned for the user walking between rooms and losing wifi mid-request. A bad server release at 9am, a CDN edge that just rebooted, a metro tunnel - all normal. Every API call needs to handle transient failure, distinguish it from a real error, and surface the right thing. Silent failure is worse than a crash: it hides, surfaces days later in a different part of the app, and nobody can trace it back. How do we make every API call resilient without drowning the UI in retry logic?

Three layers each own one kind of error: OkHttp retries the transient ones, the repository maps HTTP codes to domain failures, and the UI shows an offline banner for session-wide outages.

Retrying transient failures

Retries belong at the network layer, not in app code. An OkHttp Interceptor (a hook that sits in the middle of every request and response, like middleware - see HTTP Client Library) examines the response and the request method, on a 5xx or a SocketTimeoutException, it retries up to N times with min(maxBackoff, base * 2^attempt) + uniform(0, base) between attempts. The jitter is what prevents the install base from synchronising retries against a recovering server. Idempotent methods (GET, HEAD, PUT, DELETE, plus POST carrying an Idempotency-Key the way the outbox does) retry, non-idempotent POST and PATCH don't - retrying them risks duplicate effects on the server.

class RetryInterceptor(private val maxAttempts: Int = 3) : Interceptor {
    override fun intercept(chain: Chain): Response {
        var attempt = 0
        while (true) {
            val res = runCatching { chain.proceed(chain.request()) }
            if (res.isSuccess && !res.getOrNull()!!.shouldRetry()) return res.getOrNull()!!
            if (++attempt >= maxAttempts || !chain.request().isRetryable()) return res.getOrThrow()
            Thread.sleep(backoff(attempt) + Random.nextLong(0, JITTER_MS))  // jittered backoff
        }
    }
}

See the HTTP Client Library design for the deeper retry-semantics treatment - Retry-After handling, method-aware idempotency, and the connection-pool behavior under failure.

Repository maps HTTP errors to domain failures

4xx errors are not retryable - the request was wrong. The repository wraps the Retrofit call in a runCatching { ... } and translates HttpException(400..499) into a sealed DomainError type (NotFound, Forbidden, BadInput, ...). The UI matches on the sealed type, not on raw HTTP codes.

Retrying a 4xx is always a bug, even though it looks harmless. The server told you "no" - bad input, forbidden, not found. Retrying burns battery on a response that won't change. The retry budget is for transient failures, and 4xx is the definition of permanent.

UI banner state for network outages

Wifi loss is not an error per request - it's a session state. A NetworkObserver (built on ConnectivityManager.registerDefaultNetworkCallback) emits a Boolean Flow that the UI overlays as a "No connection" banner whenever it's false. Errors on individual requests inside an offline session don't show as toasts - the banner is the right signal because it explains why everything is failing at once.

Note
Errors are mapped at the layer that knows the semantics - OkHttp distinguishes transient (retry) from permanent (don't retry), the repository distinguishes domain failure (NotFound) from infrastructure failure (network), the UI distinguishes "this request failed" from "we're offline". Each layer's error vocabulary is local to its concerns.
Scenario

A naive offset + limit pagination drifts the moment the server inserts at the top of the list between two fetches: page 2's offset still says "items 20-39", but those items have shifted to 25-44. Ranked feeds re-score under the user's feet, so positional pagination is wrong by construction. How do we page through a list that keeps shifting?

Never paginate by position (offset or timestamp), pass back an opaque cursor the server hands you, and let Paging 3 drive Room as the source of truth.

Offset pagination is the #1 thing I reject in design review. Ship the cursor on day one, it's the same code.

BadOffset + limit (positional by index)

GET /v1/items?offset=20&limit=20 names page 2 by position. Insert an item at the head between two fetches and every later offset points one row off, so the boundary either repeats a row or drops one, against a feed that re-ranks under the user, no offset is ever correct. It only holds when the data is genuinely static, which a live feed never is - and that is the failure the next tier has to remove.

BadTimestamp cursor (positional by time)

GET /v1/items?since=2026-05-12T10:00:00Z. Sometimes proposed as a "fix" for offset, but it's the same antipattern with a different axis: the client is still naming a position in some server-controlled ordering. It breaks three ways. It breaks on ties, when two items share a millisecond and interleave inconsistently across calls. It breaks for ranked feeds, where "since" is meaningless. And it breaks the moment the server re-ranks, because the since value now points into an ordering that no longer exists. Tempting because it works for the demo (a strict chronological feed with sparse timestamps), ships broken to production.

GreatOpaque server-defined cursor + Paging 3 + RemoteMediator

The server encodes whatever it needs in the cursor - (rank, itemId, salt) for ranked, (ts, itemId) for chronological. The client passes it back verbatim and never parses it. Paging 3's RemoteMediator is the canonical Android integration: a PagingSource reads pages from Room (the cache is the source of truth), the RemoteMediator hydrates Room from the network on cache miss. The three LoadType values (REFRESH, PREPEND, APPEND), the remote_keys table that holds the cursor per item, and the single-withTransaction write that keeps the cached page and the keys consistent are Paging 3 + RemoteMediator mechanics; the design move here is choosing the opaque cursor as the contract the server and client both commit to, not the mediator plumbing.

See the Newsfeed App design for the re-ranking-under-pagination corner case and the cursor-expired (410 Gone) protocol that keeps the client correct when the server's ranking snapshot ages out.

Watch the offset window drift the instant the server re-ranks - and how an opaque cursor that commits the snapshot survives the same re-rank with no duplicate at the page boundary:

Note
Cursors are opaque, server-defined, and never parsed by the client - the server is free to change the encoding without a client release, ranked and chronological feeds use the same client integration. Paging 3 + RemoteMediator owns the cache-vs-network seam so the UI consumes a single Flow<PagingData<T>> and the offline story falls out for free.
Scenario

A single :app module that compiles in four minutes blocks every engineer on the team. Every UI change rebuilds the data layer, every data layer change rebuilds every feature. By year two the build is the bottleneck and the migration to a modularised structure is a multi-quarter project nobody wants to own. How do we avoid the year-two migration entirely?

Split the app into per-feature modules on day one, the cost is a few hours of Gradle now, and it saves a multi-quarter migration nobody wants to own at year two.

I've had apps where the single-module build hit 15 minutes. Every UI tweak rebuilt the data layer, every data change rebuilt every feature, and the team's feedback loop died. Making a module split today is easier than ever - point your AI agent at it and let it work on splitting. It's not really system design content, but it's real life pain that's very easily solvable regardless, and interviewers are looking at the practicality of your thinking.

The right shape is straightforward and the patterns are well-established at Airbnb, Netflix, Square, and Google's own consumer apps.

Per-feature modules

Each feature is a pair of Gradle modules: :feature:foo (UI + ViewModels) and :feature:foo-data (domain + data + DI). The UI module depends on its data module's domain interfaces only - the implementations are wired via Hilt. A feature module never depends on another feature module, cross-feature calls go through an API module (:feature:foo-api) that declares the interface and is depended on by both.

Shared library modules

Cross-cutting infrastructure lives in :lib: modules: :lib:network (OkHttp + Retrofit setup), :lib:database (Room base configuration), :lib:designsystem (theme, common Compose components), :lib:common (utilities). Features depend on libs, libs never depend on features.

A representative module structure

:app                                ← assembles everything, no logic
:feature:feed                       ← UI + ViewModels
:feature:feed-data                  ← domain + data + Hilt wiring
:feature:feed-api                   ← public interface for cross-feature calls
:feature:detail                     ← UI + ViewModels
:feature:detail-data
:feature:detail-api
:lib:network                        ← OkHttp + Retrofit base
:lib:database                       ← Room base + migrations
:lib:designsystem                   ← Compose theme + components
:lib:common                         ← utilities, extensions

Three practical benefits follow from the split. (1) Build time - touching :feature:feed UI doesn't rebuild :feature:detail. That isolation also bounds failure: (2) a bug in one feature's data layer can't reach into another's. And the team scales with the module count: (3) three engineers can work on three features without merge conflicts in shared files.

Note
Multi-module is a day-one decision, not a refactor - the per-feature module pair is two lines of Gradle and one Hilt module, the cost up-front is hours and the saving compounds with every feature added. Concrete designs in this catalog assume this structure exists, their architecture diagrams collapse it into a single App Process box for readability.
Scenario

Images are where mobile performance dies quietly. A single 4000x3000 JPEG decoded at full size is 48 MB resident - enough to OOM (out-of-memory crash) a Pixel 3a. A list of twenty such images freezes scroll. Re-downloading the same hero image on every screen entry costs metered data and battery. Picking a library doesn't solve any of this - the placeholder dims, decode size, and cache budgets are the rest. How do we load images without OOM, jank, or wasted bandwidth?

Big images eat memory and decode time, and the library will not save you: set the display size before loading and decode smaller-than-original. (ARGB_8888 is 4 bytes per pixel, the default - see Memory and GC.)

Coil is the Compose-era default (coroutine-based, AsyncImage-native, shares OkHttp with the API client). The rest of this deep dive is what you do with it - none of which Coil does for you by default.

1. Placeholder dimensions, not placeholder drawables

The bug everyone hits first: an AsyncImage with no size hint renders at 0×0 until the bytes decode, then the layout reflows and everything below it jumps. Users read this as "the app is broken". The fix is to give Compose the dimensions before the bytes arrive - either from width/height metadata the server sends with each Item, or from a fixed aspect ratio the feature commits to.

AsyncImage(
    model = item.imageUrl,
    contentDescription = null,
    modifier = Modifier
        .fillMaxWidth()
        .aspectRatio(item.imageAspectRatio),    // 16:9, 1:1, whatever the API returned
    contentScale = ContentScale.Crop,
)

The aspectRatio modifier reserves the layout slot before the image loads. No reflow on decode, the placeholder is a colored rectangle of the correct size. This single modifier is the difference between a list that scrolls cleanly and one that bounces.

2. Downsampled decode

Coil auto-downsamples to the target Size the request is rendered at - but only if the target size is known at request time. Sizing the AsyncImage with aspectRatio (above) gives Coil the target dimensions automatically, off-screen requests need explicit .size(...).

ImageRequest.Builder(context)
    .data(url)
    .size(Size(400, 225))                       // explicit target size
    .scale(Scale.FILL)                           // crop rather than letterbox
    .allowHardware(true)                         // Bitmap.Config.HARDWARE: available API 26+, safe to default from 28
    .build()

Why the target size matters - a 4000x3000 JPEG decoded at native resolution is 48 MB at ARGB_8888, decoded for a 400-px column it's a few hundred KB - is the two-pass-decode / inSampleSize / Bitmap.Config.HARDWARE story, and it's covered in Image Loading Internals. The design move here is giving Coil the dimensions so it picks the right sample size; the platform substrate that makes the sample size the right lever lives on that page.

3. Two-tier cache, parallel to the item cache

Coil's caches mirror the item-data ladder - same shape, separate budgets. The memory cache holds decoded Bitmaps, the disk cache holds the compressed bytes off the wire; the full ladder (cache key shape, hit-miss path through decode and network) is covered in Image Loading Internals. What this design pins is the budgets:

  • L1 memory cache - Coil defaults to ~20% of available heap (15% on low-RAM devices), this design pins it to Runtime.maxMemory() / 8 to match the item L1 budget.
  • L2 disk cache - Coil defaults to 2% of free disk space (clamped to 10-250 MB) in cacheDir, for image-heavy apps pin it to 200 MB.
val imageLoader = ImageLoader.Builder(context)
    .memoryCache { MemoryCache.Builder(context).maxSizePercent(0.125).build() }  // 12.5% heap
    .diskCache { DiskCache.Builder().maxSizeBytes(200L * 1024 * 1024).build() }  // 200 MB
    .okHttpClient(appOkHttpClient)  // share the API client's connection pool + TLS
    .build()

Sharing appOkHttpClient is the under-noticed move: connection reuse to the image CDN, the same retry/auth interceptors, and a single TLS session pool. Two separate OkHttp instances pay the handshake cost twice. The disk cache also drives OkHttp's If-None-Match / If-Modified-Since so a server 304 Not Modified returns cached bytes without re-downloading.

4. Memory pressure handling

On onTrimMemory(...) Android signals the app to release caches before the OOM killer steps in. Coil registers a callback that trims the memory cache under pressure (halving it at TRIM_MEMORY_RUNNING_LOW) and clears it entirely once the app is backgrounded, the disk cache is unaffected (off-heap). The next visible image loads from disk - one-frame stutter, no crash. On a low-end device this is what keeps the app on screen when the user multitasks into a heavy game and back.

Note
Image loading inherits the architecture's cache-ladder shape - L1 in memory, L2 on disk, network as supplier - but the specifics (placeholder dims, downsampled decode, hardware bitmaps, low-memory callback) are what separate a working integration from a janky one. Coil's defaults are sane for a typical app, concrete designs override budgets, hardware-bitmap policy, and the disk-cache directory. The internals of the sampler, the bitmap pool, and downsampling math live in the Image Loading Internals deep dive.
Scenario

A server-side change - someone else liked your post, a new message arrived - has to reach the phone without the phone polling. A 30-second poll keeps the radio warm and still averages 15 s of latency, a long-lived socket per app is a battery cost no single app should own. How does the backend wake the app on demand, and why does the push carry a trigger instead of the data?

FCM (Firebase Cloud Messaging - Google's push service, the one persistent socket Play Services maintains for every app on the device) is the wake-up channel, the REST API is still the source of truth. The receiver enqueues a WorkManager job and returns immediately, the worker calls repository.refresh() - the same entrypoint a cold-start read uses - so the network call stays inside the repository and the cache update stays single-owner.

The diagram below is the push-path delta - only the three lanes the push touches. UI, Domain, and Local Storage are unchanged from the Phase 3 architecture and omitted. The refresh flows left through RemoteDataSource → REST API (the cold-start path), FCM enters from the right:

BadPoll the server on an interval

viewModelScope.launch { while (true) { delay(30_000); fetch() } }. The radio stays warm, battery drains, and p95 latency is the interval - 15 s on average. Polling inverts the right direction: the phone asks the server, instead of the server telling the phone.

GoodFCM notification payload, auto-displayed

The server sends a notification block, Play Services auto-displays it when backgrounded, and no app code runs - so there is no re-fetch, no local-state suppression, no update of an existing notification. The user sees a banner, but the app's cache is stale until the next cold start.

GreatFCM data payload as trigger + WorkManager → repository.refresh()

The server sends a data-only payload - a small key-value map like { "type": "items_changed" } carrying no item state. onMessageReceived is always called (foreground, background, or freshly woken from kill), and it does one thing: enqueue a WorkManager job. The worker calls repository.refresh(), which fetches the authoritative state from the REST API and writes it through the same L2 → L1 → Flow chain a cold-start read uses. The push decides when to ask, the REST API is still the answer.

Why data-only and not notification: the notification payload auto-displays on Play Services' terms, with no chance for the app to suppress based on local state or update an existing notification. Production apps send data-only so onMessageReceived runs every time and the app's code decides what to surface, the full tradeoff is in Push and Notifications.

Two transport knobs cross both payload kinds. priority - high wakes the device immediately and bypasses Doze, normal batches with the next maintenance window, and abusing high gets the app deprioritized. collapse_key - several messages with the same key queued while offline collapse to the latest on reconnect, right for "items changed", wrong for chat.

In code, the pattern is two short classes - the receiver does nothing but enqueue, the worker does nothing but call ItemRepository.refresh():

@AndroidEntryPoint
class PushReceiver : FirebaseMessagingService() {
    @Inject lateinit var workManager: WorkManager
    override fun onMessageReceived(msg: RemoteMessage) {
        // Push payload is a *trigger* ("something changed"), not data.
        workManager.enqueue(OneTimeWorkRequestBuilder<ItemsRefreshWorker>().build())
    }
}
 
@HiltWorker
class ItemsRefreshWorker @AssistedInject constructor(
    @Assisted ctx: Context, @Assisted params: WorkerParameters,
    private val itemRepository: ItemRepository,
) : CoroutineWorker(ctx, params) {
    override suspend fun doWork() =
        runCatching { itemRepository.refresh() }
            .fold(onSuccess = { Result.success() }, onFailure = { Result.retry() })
}

Why the receiver enqueues instead of doing the work directly. onMessageReceived runs on a binder thread with a ~10-second budget before ANR, calling repository.refresh() there risks blowing it on a slow network. Enqueueing defers to a durable worker that survives both the binder timeout and process death.

The worker carries NetworkType.CONNECTED. A push that lands while offline enqueues a worker that won't run until connectivity returns. WorkManager persists the request, so the deferred refresh survives reboot.

The self-healing property. The client reconciles the full head page on every cold start anyway, so a missed or coalesced push is caught by the next launch. Push is a latency optimization, not a correctness mechanism - and FCM delivery is not guaranteed under Doze or battery-saver modes, so it can't be.

WorkManager is the durability primitive on both the push path and the write path - it persists pending work to its own SQLite DB and resurrects it after process death. The write-path counterpart that drains the outbox queue is unpacked in dd3.

Note
Push is a latency optimization, not a correctness mechanism - FCM tells the app when to ask, the REST API is still the single source of truth. A missed or coalesced push is self-healing on the next cold start, which is what makes a best-effort channel safe to depend on. The receiver enqueues and returns within its binder-thread budget, the worker borrows the same repository.refresh() entrypoint a cold-start read uses, so the push path adds one new component (the receiver) and zero new data paths.

Tradeoffs

DecisionChosenAlternativeWhy
ArchitectureThree-layer UDF (UI / Domain / Data) with Hilt at boundariesTwo-layer (UI / Data) without use casesUse cases decouple the UI from network shape, ViewModels stay unit-testable
AsyncKotlin Coroutines + FlowRxJavaCompose-native, Google's recommended stack, legacy bridges available
DIHiltDagger / KoinAndroid-aware modules + Navigation scoping out of the box
Local DBRoomSQLDelightLarger ecosystem on Android-only projects, SQLDelight wins on KMP
NetworkOkHttp + RetrofitKtorDe facto standard, interceptor chain + connection pool are battle-tested
Image loadingCoil + placeholder dims + downsampled decodeGlideCompose-native, coroutine-based, shares OkHttp pool, Glide is the legacy View-system answer
PaginationPaging 3 + opaque cursorHand-rolled offset pagingCursor survives top-insert and re-rank, Paging 3 owns the cache+network seam
Module structurePer-feature pair + shared libs from day 1Single :app moduleBuild times, blast radius, team parallelism - migration cost compounds

The mechanisms on this page are each developed in depth in a core concept:

  • Architecture - the three-layer UDF stack, the repository boundary, and where the layers cut (Deep Dive 1).
  • Dependency Injection - Hilt as the contract enforcer at the two layer seams.
  • Persistence - Room as the L2 cache and the durable outbox, observed as Flows (Deep Dive 2 and 3).
  • State Management - cold Flow converted to a hot StateFlow<UiState> with stateIn (Deep Dive 1).
  • Caching Strategies - the two-tier cache, stale-while-revalidate, and the L1/L2 ladder (Deep Dive 2).
  • Offline-First and Sync - the durable outbox, idempotency keys, and reconciliation (Deep Dive 3).
  • Pagination - opaque server cursors via Paging 3 + RemoteMediator (the pagination dive).
  • Background Work and Scheduling - WorkManager as the outbox dispatcher and push-path trigger (the write path and push path).
  • Push and Notifications - FCM as a wakeup hint rather than a data channel, data vs notification payloads, and token rotation (Deep Dive 8).
  • Network Protocols - the REST surface, OkHttp interceptor retry, and idempotency keys.
  • Image Loading Internals - Coil with placeholder dims, downsampled decode, and the shared OkHttp pool (the image loading dive).

Done reading? Mark it so it sticks in your dashboard.

Next in System DesignWeather App