Article · System design

Mobile System Design: A Practical Guide for Android Engineers

Khairul Alam·October 2026·~30 min read
System designAndroidOffline-firstArchitecture

A mobile system design question is not a backend question with a phone drawn on the left. The phone is a small, unreliable computer that is often offline, runs on a battery, can be killed at any moment and is shipped as a binary you cannot patch tomorrow. This article gives you a repeatable way to design for those facts, with a step-by-step framework, the building blocks, and three worked designs.

This article in 60 seconds
  • Design the client first: what the user sees, what must work offline, what must be fast.
  • Follow six steps: requirements, API, data model, architecture, deep dives, trade-offs.
  • The network is a slow, flaky, metered resource. Cache, paginate, batch, retry with backoff and make writes idempotent.
  • Use the local database as the single source of truth; the UI only observes it.
  • Budget for battery, memory, startup time and app size, and plan for rollout, feature flags and a kill switch.
Contents
  1. What makes mobile system design different
  2. A six-step framework
  3. Step 1: Requirements and constraints
  4. Step 2: The API between app and server
  5. Step 3: Data model and local storage
  6. Step 4: Client architecture
  7. Networking building blocks
  8. Offline-first and sync
  9. Performance, battery and memory
  10. Security and privacy
  11. Release, flags and observability
  12. Three worked designs
  13. Classic system design lessons, seen from the phone
  14. Common mistakes and a checklist
  15. Takeaways

1. What makes mobile system design different

Backend design asks: how do we serve a million requests per second? Mobile design asks a different question: how does one phone give one person a fast, correct, battery-friendly experience on a bad network? The server still matters, but the client is where most of the interesting trade-offs live.

ConstraintWhat it means for the design
Unreliable networkRequests fail, time out, or succeed after the user left the screen. Assume offline is normal.
Metered, slow dataSend less: pagination, compression, small images, delta updates.
BatteryRadios and wake-ups cost power. Batch work, avoid polling, respect Doze and background limits.
Limited memoryThe OS kills background apps and large bitmaps cause crashes. Cache with limits.
Process death and lifecycleState must survive rotation and being killed. Never keep the only copy of data in memory.
Fragmented devicesMany OS versions, screen sizes and performance tiers. Test low-end devices.
Slow release cycleUsers run old versions for months. APIs must be backward compatible; you need remote control (flags).
Untrusted deviceThe app can be inspected, rooted or modified. Never trust the client for authorization.

Think of a courier on a mountain road. The courier (the phone) sometimes has no signal, carries a small bag (memory), and has a limited fuel tank (battery). A good plan packs what is needed ahead of time, delivers in batches, writes everything down so a lost trip can be resumed, and never depends on a call going through at the exact right moment.

The whole system, from thumb to disk and backPhone appCDN edgeimages, staticPush (FCM / APNs)Load balancer+ rate limiterAPI servers(stateless)DNS, HTTPSmissAPI callspushCache(Redis)DB(sharded, replicas)Queue / pub-subBlob storeWorkersfan-out, push, thumbnails, search index
The classic back-end boxes from a system design course. In a mobile design you also draw the phone, its local database, and the push path back to it.

2. A six-step framework

Use the same order every time. It keeps you from jumping to a favourite library before you know the problem.

1 Requirementswhat must the app do? what must it never do?2 APIwhat does the app ask the server, and how often?3 Data modelwhat is stored on the phone, and who owns it?4 Architecturelayers, state, navigation, modules5 Deep divescaching, sync, images, push, performance, security6 Trade-offswhat did we give up, how do we ship and measure it?
Spend about one fifth of your time on each of the first four steps and the rest on deep dives and trade-offs.

3. Step 1: Requirements and constraints

Functional requirements

List what the user can do, in a few short sentences. For a feed app: “see a feed, scroll forever, like a post, create a post with a photo, get notified of replies.” Mark which of these must work offline.

Non-functional requirements

These decide the design. Ask about each one, and write a number when you can.

AreaQuestions to ask
LatencyHow fast must the first screen appear? Cold start under 2 seconds? Scroll at 60 frames per second?
OfflineRead-only offline, or can the user also write and sync later?
ConsistencyIs a stale feed fine? Is a stale bank balance fine? Who wins when two devices edit the same thing?
ScaleHow many users, how many requests per user per day, how big is the data?
Battery and dataIs it used for hours (navigation) or seconds (a widget)? Must it work on 2G?
SecurityPersonal or payment data? Do we need encryption at rest, certificate pinning, biometric unlock?
PlatformsAndroid only? Tablets, wearables, foldables? Minimum OS version?
ReleaseHow often do we ship? Can we roll back? Do we need A/B tests?

4. Step 2: The API between app and server

The app and server meet at the API. A mobile-friendly API is shaped by the screens, not only by the database.

StyleGood forWatch out for
REST over HTTPS and JSONMost apps. Simple, cacheable, easy to debug.Over-fetching and many round trips per screen.
GraphQLScreens that need different slices of data; fewer round trips.Caching is harder; query cost must be limited on the server.
gRPC and Protocol BuffersCompact binary messages, streaming, strong contracts.Tooling and debugging are heavier; proxies may need support.
WebSocket / SSELive updates: chat, scores, presence.Keeping a connection open costs battery; reconnection logic is required.
Push (FCM)Waking the app when it is closed.Delivery is best effort; never rely on it as the only path.

Design rules for a mobile API

GET  /v1/feed?limit=20&cursor=eyJ0IjoxNzI5...      -> { "items": [...], "nextCursor": "eyJ0Ijox..." }
POST /v1/posts          Idempotency-Key: 7f3c-...    -> 201 { "id": "p_91" }
POST /v1/posts/p_91/like   Idempotency-Key: a12b-... -> 200 (second call returns the same result)

5. Step 3: Data model and local storage

Decide what lives on the phone, in what shape, and who is the owner of each piece of data.

WhatWhere to keep itNotes
Structured, queryable data (posts, messages, orders)SQLite through RoomThe main store. Index the columns you filter and sort on.
Small settings and flagsDataStore (or SharedPreferences)Not for large or relational data.
Secrets and tokensAndroid Keystore with encrypted storageNever in plain preferences or in source.
Images and filesDisk cache with a size limitEvict least recently used items; the OS may clear the cache.
Screen stateViewModel plus saved stateCheap and temporary; rebuild from the database when needed.

Separate the models

Keep three shapes of the same thing and convert between them at the edges. It lets the server, the database and the UI change independently.

// What the server sends (network model)
@Serializable data class PostDto(val id: String, val text: String, val authorId: String, val createdAt: Long)

// What the database stores (entity)
@Entity data class PostEntity(@PrimaryKey val id: String, val text: String, val authorId: String,
                              val createdAt: Long, val syncState: Int /* 0 synced, 1 pending, 2 failed */)

// What the screen needs (UI model)
data class PostUi(val id: String, val text: String, val timeAgo: String, val isPending: Boolean)

Why a syncState column? It lets the UI show a clock icon for a post that is saved locally but not yet uploaded, and it lets a background worker find everything that still needs to be sent.

6. Step 4: Client architecture

Keep three layers: a UI that shows state, a domain layer with the rules, and a data layer that hides where data comes from. This is the View, Logic, Data idea from my architecture series, and the repository is the glue.

UI (Compose / Views)shows state, sends eventsViewModelholds screen state, calls use casesDomain: use casesbusiness rules, no Android classesRepositorysingle entry point for dataeventsStateFlow of UI stateLocal DB(Room)Remote API(Retrofit)DB is the source of truthnetwork refreshes the DB
The UI observes the database through the repository. The network only updates the database.

Choices to state out loud

class FeedRepository(private val dao: PostDao, private val api: FeedApi) {

    // The UI observes this. It never reads the network directly.
    fun feed(): Flow<List<Post>> = dao.observeAll().map { rows -> rows.map { it.toDomain() } }

    // Called on open, pull to refresh, or by a background worker.
    suspend fun refresh(): Result<Unit> = runCatching {
        val page = api.feed(limit = 20)
        dao.upsertAll(page.items.map { it.toEntity() })
    }
}

7. Networking building blocks

Caching: layers, from fastest to slowest

LayerSpeedUse for
In-memory (LRU map)InstantThe last few screens, decoded images.
Local databaseMillisecondsThe data the app shows. Survives restarts.
HTTP cacheMillisecondsResponses with Cache-Control or ETag.
CDN edgeTens of millisecondsImages, video, static config.
Origin serverHundreds of millisecondsThe truth.

Common cache policies: cache-then-network (show stored data now, refresh in the background), stale-while-revalidate (use stale data and update it silently), and network-first with fallback (for data that must be fresh, such as a balance).

Cache-then-network read (what the screen sees)open screenread Roomshow stored items at oncet = 20 msGET /feedIf-None-Match: "v42"304 Not Modified200 + new itemserror / offlinekeep what is shownwrite to RoomFlow emitslist updateskeep stored items, show small "offline" banner
The user sees something immediately. The network only improves it.

Retries: exponential backoff with jitter

If a request fails because of a timeout or a 5xx error, wait and try again, with the wait doubling each time and a little randomness so a million phones do not retry at the same instant. Do not retry client errors such as 400 or 403; they will never succeed. Only retry writes that are idempotent.

suspend fun <T> retryWithBackoff(times: Int = 4, base: Long = 500, block: suspend () -> T): T {
    var delayMs = base
    repeat(times - 1) {
        try { return block() } catch (e: IOException) { /* transient: fall through and wait */ }
        delay(delayMs + Random.nextLong(0, delayMs / 2))      // jitter
        delayMs *= 2
    }
    return block()                                            // last attempt: let the error out
}
Request lifecycle on a bad network (write with an idempotency key)AppNetworkServer1 POST /posts key=K1losttimeout after 10 swait 0.5 s + jitter2 POST /posts key=K1 (retry)create post, remember K1201 (response lost)wait 1 s + jitter3 POST /posts key=K1 (retry)K1 seen: return same 2014 201 (same result)mark SYNCEDone post, not three
Retries are safe only because the server recognises the key. Without it, the second request would create a duplicate post.

Pagination

Load the first page fast, load the next when the user is near the end, and keep the pages in the database so scrolling back is instant. On Android, Paging 3 with a RemoteMediator does exactly this. A cursor beats an offset, because an offset shifts when new items appear at the top.

Images

Real-time and push

NeedApproach
Updates while the screen is openWebSocket or SSE; fall back to polling on a long interval.
Updates while the app is closedFCM push with a small payload (“something changed”), then fetch the details.
Never miss a messageGive every message a sequence number or timestamp, and on reconnect ask “what happened since X?”.

Background work

Use WorkManager for deferrable, guaranteed work (upload a photo, sync when the network returns), with constraints such as “connected” and “not low battery”. Use a foreground service only for work the user is actively aware of (navigation, a call, a download).

8. Offline-first and sync

Offline-first means the app works from its local data and treats the network as a background sync. Reads come from the database; writes go to the database first, then to the server.

user taps "send"1. write to local DBsyncState = PENDING(UI updates at once)2. enqueue WorkManager job(needs network)3. POST to server with Idempotency-Keyfail (transient)retry with backoffok4. mark SYNCED, store server id and timestamp
The user never waits for the network. The outbox is a table of pending writes.

The outbox pattern

Store every outgoing change in a table (or the pending rows themselves). A background worker drains it in order. Because each item has a stable id, a crash or a retry cannot send it twice with a different effect.

Conflicts: what if two devices edit the same thing?

StrategyHow it worksUse when
Last write winsThe newest timestamp (or server order) wins.Simple settings, low stakes. Beware clock differences; prefer server time.
Versioning (optimistic lock)The write includes the version it was based on; the server rejects a stale one and the app re-reads and re-applies.Documents and records edited by few people.
Field-level mergeMerge non-overlapping field changes.Profiles and forms.
CRDTs or operational transformData structures that merge automatically.Collaborative text; high complexity, use only when needed.
Ask the userShow both versions.Rare, high-value conflicts.
Pull sync, in short

Keep a lastSyncToken per resource. Ask the server for changes since the token, apply them in one database transaction, then save the new token. If the process dies half-way, the transaction rolls back and the next run starts from the old token.

9. Performance, battery and memory

Set budgets, then measure

MetricTypical targetCommon fixes
Cold startUnder about 2 seconds to first useful screenDefer heavy initialisation, lazy-load libraries, use Baseline Profiles and the splash screen API.
Frame timeUnder 16 ms per frame (60 fps), 8 ms at 120 HzNo work on the main thread, stable keys in lists, avoid unneeded recomposition.
MemoryNo leaks; no out-of-memory on low-end devicesDownsample bitmaps, bounded caches, avoid holding Activity references.
BatteryFew wake-ups, few radio sessionsBatch requests, push instead of poll, WorkManager constraints.
App sizeAs small as practicalApp bundles, R8 shrinking, on-demand feature modules, compressed assets.
Network useFew bytes per sessionCompression, delta sync, image sizing, avoid chatty APIs.

Radio and battery

The mobile radio stays in a high-power state for a while after each request. Ten small requests spread over ten minutes cost much more battery than one request carrying the same data. Group non-urgent work and let the system choose the moment.

Lists

Long lists are the most common performance problem. Use lazy lists (LazyColumn or RecyclerView), give items stable keys, keep item layouts shallow, load images asynchronously and avoid doing heavy work in the binding code.

10. Security and privacy

Token refresh without a stampedeReq A: 401 expiredReq B: 401 expiredReq C: 401 expiredone shared refresh (mutex)okfailsave new tokensreplay A, B, Cclear tokensgo to sign-in
Only the first 401 triggers a refresh. The others wait for the result and then retry once.

11. Release, flags and observability

Because you cannot instantly fix a shipped app, the system around the code matters as much as the code.

ToolPurpose
Staged rolloutRelease to 1%, 5%, 20%, 100% and watch crashes and metrics between steps. Halt if they worsen.
Feature flags and remote configTurn features on or off without a release; run A/B experiments; provide a kill switch for risky code.
Crash reportingCollect stack traces and the app version; track crash-free users.
AnalyticsTrack funnels and key events with a stable schema; respect consent.
Performance monitoringCold start, screen load and request latency in the field, by device class and country.
Forced and soft upgradeA server field says “this version is too old” (force) or “an update is available” (nudge).
Backward-compatible migrationsDatabase migrations must work from any previous version; test them.

Testing strategy. Many fast unit tests for use cases and view models, repository tests with a fake API and an in-memory database, a few UI tests for the key flows, and screenshot tests for visual regressions. See testing and TDD.

12. Three worked designs

A. A social feed (Instagram or X style)

Requirements: infinite scroll, like, post with photo, notifications. Stale data for a few minutes is fine. Posting should work offline.

Feed paging with a RemoteMediatorLazyColumnPager (Paging 3)Room: posts + remote_keysuser nears the endRemoteMediator(load next page)GET /feed?cursor=...APIwrite page + next cursorLike tap:UI flips heartRoom (pending)WorkManagerPOST /like (key)
The list reads only from Room. The mediator fills Room from the network, one cursor page at a time.

B. A chat app (WhatsApp style)

Requirements: one-to-one and group chat, delivery and read status, works offline, history is stored on the phone, messages must never be lost or duplicated.

Sending one chat messageAlice appServerBob app1 save local (id=M1, sending)2 send M13 store, seq=574 ack seq=575 mark sent6 push / socket7 save, show8 delivered M19 delivered10 read up to 57(one batched receipt)11 readOffline for Alice: steps 2 to 5 wait in the outbox; M1 is sent when the network returns.Bob reconnects: "give me conversation C after seq 52" then the server replays 53 to 57.
The client-made id removes duplicates; the server sequence number gives the order.

C. A ride or delivery tracking screen

Requirements: show the driver’s position moving on a map in near real time, with a changing ETA, on mobile networks that drop out.

Live trackingDriver appGPS every 3-5 sLocation servicepub-sub topic "trip:T9"WebSocket / gRPC stream (while visible)Rider appinterpolate between points, snap to road, drop old timestampsstream silent for 15 s?show "last updated 15 s ago", fetch latest state, resubscribe
Pub-sub fans one driver update out to everyone watching that trip. The rider app subscribes only while the screen is open.

13. Classic system design lessons, seen from the phone

Well-known system design courses (the Grokking series, for example) teach the back end: estimation, building blocks and case studies. I have not copied any course; the tables below are my own summary of the usual topics, and a full course is the place to go deep on the server side. Every one of these ideas still shapes a mobile design, because the app is the first component in that picture.

The RESHADED approach, mapped to this article

The course ends with a named approach for tackling a design problem. As I understand it, the letters stand for Requirements, Estimation, Storage schema, High-level design, API design, Detailed design, Evaluation and Distinctive component or feature. It lines up with the six steps in section 2:

RESHADEDThis articleMobile twist
RequirementsStep 1Add offline, battery, data cost and OS support.
EstimationBelowEstimate per-device bytes and wake-ups as well as server load.
Storage schemaStep 3Server tables and the on-device Room schema.
High-level designOverview diagram, Step 4Draw the phone, its local database and the push path.
API designStep 2Cursors, idempotency keys, compatibility with old app versions.
Detailed designSections 7 to 10Sync, retries, images, security.
EvaluationSection 11Flags, rollout, field metrics, stated trade-offs.
Distinctive componentWorked designsOutbox for chat, RemoteMediator for feeds, streaming for tracking.

Back-of-the-envelope estimates

Estimate first; it tells you whether you need a cache, a CDN or sharding. Use round numbers and show the arithmetic.

Feed app, 10 million daily users, 20 feed requests per user per day
  requests per day     = 10M x 20            = 200M
  average per second   = 200M / 86,400       = about 2,300 requests/s
  peak (3x average)                          = about 7,000 requests/s

  one feed page        = 20 items x 1 KB     = 20 KB (JSON, before compression)
  traffic per second   = 2,300 x 20 KB       = about 46 MB/s   -> use a CDN and compression

On the phone: 100 posts cached x 1 KB = 100 KB of rows (tiny);
              100 thumbnails x 30 KB   = 3 MB on disk        (set a disk-cache limit)

Non-functional characteristics

Back-end termWhat it means on a phone
AvailabilityThe app still opens and shows data when the server is down. Offline-first gives you this for free on reads.
ReliabilityA message or payment is never lost or doubled: outbox, idempotency keys, durable local writes.
ScalabilityYour retries, polling and reconnects are part of the server’s load. Backoff and jitter protect it.
Fault toleranceHandle partial failure: one endpoint down should not blank the whole screen.
Consistency (CAP trade-off)Offline writes mean you choose availability and accept eventual consistency, so you need a conflict strategy.

Building blocks and where the app touches them

Building blockServer ideaWhat the mobile engineer decides
DNSName to IP addressWhich host names to use; cost of extra hosts (each needs a lookup and a TLS handshake).
Load balancerSpread requests over servers; health checksRetry safely, because a retry may land on another server; keep requests stateless.
CDNCache static content near the userImage sizes and formats, cache headers, signed URLs.
Distributed cacheFast reads (for example Redis)Use ETags so a cached response costs a 304; know that data may be slightly stale.
Database, replication, shardingScale storage and readsReplicas can lag, so show your own write immediately (read-your-writes) from local data.
Key-value storeSimple, fast, partitioned dataSession, settings and token lookups need nothing more.
Sequencer (unique IDs)Ordered, unique ids across serversGenerate client ids (UUID) for offline creates; let the server add the order.
Message queueAbsorb bursts, decouple workYour upload is accepted fast and processed later, so design a “processing” state in the UI.
Pub-subFan an event out to many subscribersChat rooms, live scores, tracking: subscribe on screen show, unsubscribe on hide.
Rate limiterProtect the server per user or keyHonour 429 and Retry-After; do not hammer.
Blob storeLarge files such as photos and videoDirect, resumable, chunked uploads with pre-signed URLs.
Search and typeaheadIndexing and prefix matchingDebounce typing (about 200 ms), cancel stale requests, cache recent queries.
Distributed task schedulerRun jobs later or repeatedlyWorkManager is the on-device equivalent.
Monitoring and loggingServer errors and latencyClient-side crash and latency reporting with app version and device class.
Sharded countersCount likes and views at scaleShow an approximate count and update it optimistically; do not block on the exact number.

Classic case studies and the mobile angle

Case studyServer focusMobile focus
URL shortener (TinyURL)Id generation, read-heavy cacheApp Links and deep links, handling redirects, offline link list.
Twitter and newsfeed, InstagramFan-out on write versus read, rankingCursor paging, optimistic likes, image loading, stale-feed UX.
WhatsApp and MessengerConnection servers, message routing, receiptsOutbox, client message ids, reconnect with sequence numbers, push wake-up.
YouTubeTranscoding, CDN, adaptive bitrateAdaptive streaming (ExoPlayer/Media3), prefetching, download for offline, data-saver mode.
Uber, proximity service (Yelp), Google MapsGeospatial indexing, matching, routingFused location, battery-aware updates, map tiles caching, marker clustering.
Google DocsConcurrent edits (operational transform or CRDT)Offline edits, merge on reconnect, cursor presence.
TypeaheadTrie or index, rankingDebounce, cancel, local history, tiny payloads.
Payment systemExactly-once effect, ledgerIdempotency keys, never double-charge on retry, clear pending state.
Deployment systemStaged release, rollbackPlay staged rollout, feature flags, forced upgrade.
Web crawler, rate limiterPoliteness, throttlingRespect server limits in your own sync code.

14. Common mistakes and a checklist

Mistakes to avoid

Design-review checklist

Ask yourselfAnswer in your design
What works offline, and where does the data live?Local database as the source of truth.
How do pages, refreshes and updates arrive?Cursor pagination, refresh policy, push or socket.
What happens when a request fails or repeats?Backoff, idempotency keys, outbox.
What happens when two devices edit?A named conflict strategy.
What are the performance and battery budgets?Numbers for start time, frame time, memory, data.
How is it secured?Token storage, transport, server-side authorization.
How is it released and watched?Staged rollout, flags, crash and latency metrics.
What did we trade away?State it: freshness vs speed, battery vs liveness, simplicity vs flexibility.

15. Takeaways

TermIn plain words
Offline-firstThe app works from local data and syncs in the background.
Source of truthThe one place that owns a piece of data.
IdempotentDoing it twice has the same effect as doing it once.
Backoff and jitterWaiting longer after each failure, with a little randomness.
CursorA token that marks where the last page ended.
OutboxA queue of local changes waiting to be sent.
Optimistic updateShow the result at once and fix it later if the server disagrees.
Staged rolloutReleasing to a small share of users first.
Kill switchA remote flag that turns a feature off.
CDNServers near the user that cache static files such as images.
← Back to all writing