Clarify scope first, then define the API and data model, sketch the client architecture, go deep on the two or three hardest parts of this product, and close with cross-cutting concerns and trade-offs. Interviewers grade structure and judgement, not a complete diagram.
Requirements, about 5 minutes: which two or three screens are in scope, and the non-functional ones: offline, data volume, real time, platforms; say out loud what is out of scope
API and data model, about 5 minutes: the endpoints or socket events the client needs, the pagination style, the entities and their ids; the client side of the contract is your job
Client architecture, about 10 minutes: UI → ViewModel → repository → local database plus network, with the database as the source of truth; name the libraries (Room, Retrofit or Ktor, WorkManager, Coil) and move on
Deep dives, about 20 minutes: pick what is hard in this product: sync and conflicts for notes, ordering and delivery states for chat, caching and prefetch for a feed
Cross-cutting concerns and wrap-up, about 5 minutes: battery, memory, process death, security, observability, a feature flag for the rollout
Common mistake: drawing the backend for twenty minutes; in a mobile round the server is a box unless the interviewer opens it
AndroidSenior
Mobile System Design
Offline-first, sync, pagination, caching, modularization, feature toggles
Questions closed
0 / 20
- 0Review
- 1Asked often
Questions
Two screens hold two copies of the same post, so the like updates one copy and the other never hears about it. The fix is not to notify the feed but to give the post one home: a store that both screens observe.
Think of one whiteboard instead of two photocopies: everyone looks at the same board, so any change is seen by everyone.
Single source of truth: a database table or a normalised cache keyed by id; each screen observes a query over it and rebuilds when the row changes
Normalise by id: list responses store ids and the entity table stores the entity, so feed and detail write the same row
Optimistic writes come free: flip the row, call the server, revert on failure; all three steps are visible everywhere at once
Pass ids, not objects: a whole object as a navigation argument is a snapshot that can never update
Common mistake: "refresh on resume" flags and event buses fix the screen you remembered and miss the next one somebody adds
Debounce the input, cancel the previous request when a new query arrives, and show local results while the network catches up. That keeps the screen responsive and sends the backend one request per pause, not one per keystroke.
Debounce: wait about a quarter of a second after the last keystroke, and skip empty or too-short queries
Cancel and switch: flatMapLatest in coroutines, switchToLatest in Combine; without it a slow answer to an old query lands after the new one and the list flickers; if you merge callbacks by hand instead, tag each response with its query and drop stale ones
Local first: recent searches or a cached list fill the screen instantly, and a short-lived cache per query means deleting back does not refetch
On the backend: a cheap suggest endpoint separate from full search, rate-limited per client, because a broken client will eventually send one request per keystroke
Freshness is decided per resource, not with one app-wide number: sort each endpoint by how bad it is to show it stale, and give each class its own cache policy.
Near-static (categories, config, country lists): cache for hours or days, and bundle a copy so the first launch shows something before the network answers
User-owned (profile, feed, saved items): show the cached copy instantly and revalidate in the background; this stale-while-revalidate is what makes an app feel fast
Volatile (price, balance, seat availability): never render silently from cache; block on the fetch or label the value "as of 14:02"
Cold start: fire only the requests the first screen needs, and load the rest after first paint or on demand
Conditional requests: store the ETag and send If-None-Match; a 304 costs headers instead of a payload
Invalidate on write, and scope keys by user: after a mutation write the response into the cache and drop affected keys, or the user watches their own edit disappear
First paint must not wait for the slowest of seven calls: collapse the round trips into one screen-shaped endpoint, and render each section as soon as its own data arrives.
On a mobile link a round trip costs 150-300 ms and the screen waits for the slowest call, so the chance that one of seven hits its slow tail is far higher than for one request; each extra call also adds headers, a failure mode and radio time.
Backend-for-frontend: one endpoint returns exactly what this screen needs; if that is out of reach, split by priority: one critical call above the fold, deferred calls for the rest
Render per section: header, stats and recommendations each own their loading, empty and error state; the name never waits for the recommendations
Cache first, then patch: a returning user sees a slightly old screen, never a blank one
Deduplicate in-flight requests and cancel on leave; seven calls quietly become twelve when three components ask for the same profile
Measure p95, not the average, and give the slow dependency its own timeout
Three clients are each computing a number that only one system may own: the total must be calculated once, on the server, and only displayed everywhere else.
A rule ported into Kotlin, Swift and TypeScript diverges on the first edge case and stays diverged: three clients ship on three schedules and old builds never update.
Server owns money and eligibility: discounts, tax, shipping thresholds, promo stacking, entitlements
Send priced lines, not ingredients: a label, an amount in minor units and a currency; the client can only display them
Quote with an id: the server returns a quote, the client shows its lines and posts the id back, so what the user saw is what gets charged
What the client may still do: format, sort and filter data it already has; validate for fast feedback while the server validates for real
Common mistake: a "temporary" client-side rule shipped for a launch that becomes permanent and then wrong on every install you cannot reach
Not the device clock: the wall clock can be changed by the user, drifts and jumps, so anything with a deadline or an ordering must be anchored to server time. The app uses three clocks for three different jobs.
Wall clock (System.currentTimeMillis(), Date()) is for displaying a time and nothing else; it can move backwards mid-session
Monotonic clock (SystemClock.elapsedRealtime()) is for durations inside a session: timeouts, rate limits, "how long has this been loading"; it never goes backwards but resets at boot, so it cannot back a persisted TTL
Server time: the authority for anything two parties must agree on; on every response store the Date header together with SystemClock.elapsedRealtime() and compute now = serverTime + (elapsedRealtime() - storedAt), so a later change of the user's clock cannot move it; the anchor lives in memory, so resync after process death
Android 13+: SystemClock.currentNetworkTimeClock() gives NTP-synced time when the system has it and throws when it does not, so keep the server offset as the fallback
Deadlines are absolute instants: send them as UTC timestamps, never as «midnight» without a time zone
The client never decides expiry: it renders the state the server returns; a client-side check is a UX optimisation the server re-checks
Treat impossible values as expired: a cache row with fetchedAt in the future came from a wrong clock and must not count as fresh
You cannot retire a shipped mobile build, so every API change must stay compatible with clients you no longer control, and the important levers must live on the server rather than in the binary.
Additive changes only: new optional fields and new endpoints, never a rename, a removal or a type change; and the parser must ignore unknown keys, or every server addition is an outage for old builds
Send app version and platform with every request, so "can we drop that build" is answered with a number
Force-update from version one: the check must ship in the first build, because you cannot add it to a build already out; the endpoint returns a minimum and a recommended version
Tunables in config: timeouts, page sizes, endpoints and kill switches come from the server, because the fastest fix needs no new build
Migrations from any schema: the local database must upgrade from any older version, not just the previous one; users skip ten releases routinely
Read the feed only from the local database, request the next page a few rows before the end with one request in flight, de-duplicate by id, and persist the pages and the cursor, so scrolling never stops and rotation or process death costs nothing.
Cursor, not offset: in a live feed new posts shift every position, so offset pages duplicate or skip items; the server returns next_cursor, the client stores it and sends it back, never computes it
Prefetch threshold: load the next page about five rows before the end, so the spinner is rarely seen
One request at a time: keep an in-flight flag or job, or a fast scroller fires the same cursor four times
De-duplicate by id: an overlapping row from the server is normal; merge by stable id
Persist pages and the cursor: write them to the local database, so rotation or process death does not refetch and the list survives offline
Restore the scroll position: anchor it to an item id, not an index, because new posts shift indices; rememberLazyListState already survives rotation
Errors and the end: show a retry row at the bottom on failure, and stop requesting once next_cursor is null
A feature flag is a switch that turns code on or off without a release, and the danger is that flags pile up: every flag needs an owner, an expiry date and a planned deletion.
There are three kinds: build-time constants, remote config flipped from a server, and user-targeted flags for percentage rollouts and experiments.
Registry with expiry: every flag is listed with an owner and a date, and a CI check fails when a flag outlives it
One typed API: read flags.isEnabled(Flag.CHECKOUT_V2), not a string key at the call site, so the flag is easy to find when it is time to delete it
Default off: a new flag ships disabled and both branches are tested in CI; the off branch is the one that silently rots
Kill switches: a few permanent flags on risky paths (payments, sync, a third-party SDK) so an incident is a config change, not a release
Stable within a session: Remote Config values are fetched and activated asynchronously; apply a new value at a safe point such as the next launch or the next screen, never in the middle of a checkout
Common mistake: treating deletion as a good intention instead of scheduled work
Two tiers behind one interface: a small in-memory LRU bounded by bytes, not by entry count, and a larger disk LRU; reads go memory → disk → network and promote on a hit. LRU itself is a hash map plus a doubly linked list, which LinkedHashMap with accessOrder = true already is.
Memory tier
Size in bytes: override sizeOf (Android LruCache does this) and budget a fraction of ActivityManager.memoryClass, typically an eighth
Locking: LruCache is synchronized; a hand-rolled LinkedHashMap needs a lock, because even get reorders the list
Give memory back: trim in onTrimMemory when the app goes to the background
Disk tier
**Files in cacheDir named by a hash of the key, plus a journal or a small SQLite index with size and last access time
Atomic writes: write to a temp file and rename, so a crash never leaves half an entry
Evict on write** past the budget; the OS may also clear cacheDir at any time, so a miss is a normal path
Correctness
The key holds everything that changes the value: user id, locale, image size
Eviction is not freshness: LRU decides what to keep, a TTL or ETag decides what to trust
The UI reads only the local database and the network only refreshes it. Writes land locally first and go into an outbox that a background worker drains, and the server, not the client, detects conflicts by comparing versions.
Read path
Observe, do not fetch: screens collect a Room query as a Flow; a refresh writes into the database and the screen updates by itself
Pull by a server cursor: store the opaque cursor the server returns, in the same transaction as the rows; a client-computed updated_at > lastSync silently loses rows
Show freshness: stale data with "updated 5 min ago" beats a spinner
Write path
One transaction writes the new row state and a pending operation, so the UI shows the change at once with a pending badge
Every operation carries a client-generated idempotency key, so a retried request is not applied twice, and the base version the edit started from
Drain with WorkManager: unique work with a network constraint, in order per entity; also sync on app foreground and on a push hint
Conflicts
The server decides: a stale base version gets a 409 with the current state instead of a silent overwrite
Pick the merge per field type: last-writer-wins on server time for a toggle, per-field merge for independent fields, a CRDT or asking the user only for collaborative text
Deletes are tombstones: a missing row cannot tell "deleted elsewhere" from "never synced"
Common mistake
Treating the network response as the screen's data and the database as a cache: two sources of truth, and the app breaks the first time they disagree
The local database is the source of truth for the screen, a WebSocket delivers live events, REST pages the history, and every outgoing message gets a client id so it shows at once and is matched with the server copy when the ack arrives.
Transport
One socket per app, not per screen: auth on connect, heartbeats, reconnect with exponential backoff and jitter
Catch up after every reconnect: fetch messages after the last server sequence you hold per chat; the socket only covers the time it was open
In the background the socket closes: a high-priority FCM data message wakes the app or shows the notification
Sending and delivery states
**client_id on every message**: states sending → sent → delivered → read, plus failed with a retry button; the server de-duplicates by client_id, so a retry never posts twice
Outbox in the database, drained in order per chat when connectivity returns
Ordering and history
Order by a server sequence per chat, not by device time; messages arrive out of order, so insert by position rather than append
History pages by cursor (before = oldestSeq) into Room, and the list pages over the database
Attachments
Upload through the durable upload pipeline first, show a local thumbnail meanwhile, then send the message that references the media id
A request pipeline: key → memory cache → disk cache → network, then decode at the target size off the main thread, all tied to the view's lifecycle so a recycled row cancels its old request. Most of the design is about memory and cancellation, not the download.
Pipeline
The request is data (a URL), a target size and transformations; the memory key includes size and transformations
Memory cache of decoded bitmaps, LRU sized in bytes; disk cache of the original bytes, so a different size does not refetch
One fetch per key: rows asking for the same image share an in-flight request
Decoding
Downsample to the view size with ImageDecoder.setTargetSize or inSampleSize; a 4000×3000 photo is 48 MB as ARGB_8888
Limited parallelism for decoding, so a fast fling does not start forty decodes at once
Hardware bitmaps (API 26+) keep pixels in graphics memory so drawing needs no upload, but they cannot be read or modified in place; bitmap pooling mattered before Android 8 and much less now
Lifecycle and cancellation
Bind to the target: when a row is recycled or leaves composition, cancel; set the placeholder immediately so the old image never flashes in a reused row
Preload the next few items' images during idle scroll
API
A short call for users (AsyncImage, imageView.load(url)), an interceptor chain, and pluggable fetchers and decoders for SVG, GIF and video frames
A VerticalPager over a paged list of clips, a pool of three ExoPlayers bound to the current clip and its neighbours, and a preload manager that keeps the first seconds of nearby clips in memory and on disk, so a swipe lands on a clip whose data and first frame are already there.
Media format
Each clip comes as an HLS or DASH manifest with several renditions plus a poster URL. Short segments and a low starting rendition get the first frame through a slow network; adaptive bitrate climbs from there.
A pool of players
One player per page is the classic mistake: each holds a codec, buffers and threads, and a phone runs only a few hardware decoder instances (CodecCapabilities.getMaxSupportedInstances()).
Three players cover the previous, current and next clip. The neighbour pages are composed ahead (beyondViewportPageCount = 1) with their players prepared and paused, so the page a drag reveals already shows its first frame. When a swipe settles, play the current one, pause the others, and give the player that fell out of range to the new neighbour with setMediaSource and prepare, never release().
A poster covers the surface until the first frame renders (rememberPresentationState(player).coverSurface), so the user never sees black.
Preloading and the cache
Media3's DefaultPreloadManager ranks clips by distance from the current one: a couple of seconds loaded into memory for the neighbours, cached on disk one further out, only the manifest beyond that. setCurrentPlayingIndex re-ranks after each swipe.
One SimpleCache with a LeastRecentlyUsedCacheEvictor (a few hundred MB under cacheDir) serves the preload manager and the players, so a swipe back or a replay costs no network. On a metered or slow network, preload fewer clips at lower renditions.
Measure time to first frame per swipe, the share of swipes that start without a spinner, and rebuffering per device model.
Persist every transfer as a row in a queue, run it as durable background work the OS resumes after process death, move bytes in resumable chunks, and expose progress from that row. The UI observes the queue; it never owns the transfer.
Queue
A row per transfer: id (also the idempotency key), source, target file, bytes done, total, state, attempts, ETag or upload session URL
Limits and priority: two or three transfers per host, and a user-started download beats a prefetch
Execution
WorkManager unique work per transfer with a network constraint (unmetered for large optional files); long transfers call setForeground with a progress notification, which needs the dataSync foreground service type on Android 14+; on Android 15 dataSync has a daily time limit, so a very long transfer must resume from the .part file when the worker is stopped
Uploads copy the source first: a picker content:// grant dies with the process, and the user may delete the original
Resume and retries
Downloads send Range: bytes=N- with If-Range: <ETag>, so a changed file restarts instead of producing a hybrid; write to a .part file, verify size or hash, then rename
Uploads use a resumable protocol (S3 multipart, tus) with the session URL stored in the row
Retry only what can succeed: backoff on I/O errors and 5xx; 408 and 429 are retried with backoff (honour Retry-After), 416 restarts the file; 403, 404 and 413 are permanent, so mark the row failed and tell the user
Check free space before starting
Progress
Write progress to the row throttled, every percent or half a second, and let the UI observe it; per-chunk database writes cost more than the transfer
A typed track() that only appends to a local queue; a separate uploader batches by count or time, survives process death and fans out to vendor adapters behind your own interface. Callers never touch a vendor SDK, so swapping one or scrubbing PII is a change in one place.
API
Typed events generated from a schema, so a renamed property is a compile error, not a silently empty column
Common context attached centrally: app version, OS, session id, active flag variants
**track never blocks and never throws: analytics must not crash or slow the screen that calls it
Pipeline
Persistent, bounded queue in Room or a file; on overflow drop the oldest and count the drops
Flush by size or time, and when the app goes to the background, through WorkManager with a network constraint; gzip the batch
An event id on every event, so the server drops duplicates when a batch is retried
Sampling and cost
Sample high-volume events such as impressions and performance spans, and record the rate on the event so analysis can re-weight; never sample funnel events
Debug builds log to the console and fail on unknown properties
Privacy and vendors
Consent gate before anything leaves the device, and a scrubber for emails and free text
Vendors are sinks** behind one interface; crash reporting stays a separate path, because it must work while the process is dying
Server-driven UI means the backend sends a description of a screen and the client renders it from a fixed set of components it already ships. It pays off when layout and content must change faster than store releases allow: home screens, promo carousels, onboarding.
The contract: a versioned list of typed components (hero, product row, banner), each with data and an action; the server owns composition and order, the client owns rendering, interaction and accessibility
Skip unknown components silently: otherwise every new component crashes every build that predates it
Fallback and cache: every screen has a default, and the last good response is cached so a cold start without network still shows something
Actions are routes or named intents, never code: sending behaviour turns the client into a bad interpreter and, on iOS, into a store-policy problem
Where it does not belong: interaction-heavy, animation-heavy or offline-critical screens; a camera is not assembled from JSON, and debugging now spans two systems
Split it into publishing your own location and watching other people's: publish through the fused provider with accuracy that follows the trip state, send only meaningful moves over one connection, and animate others' markers between sparse updates instead of asking for more.
Publishing your location
Accuracy follows the state: LocationRequest.Builder with PRIORITY_HIGH_ACCURACY every few seconds only during an active trip, balanced or passive priority otherwise; setMinUpdateDistanceMeters skips updates while standing still
Batch delivery: setMaxUpdateDelayMillis lets the hardware collect fixes and wake the app less often
Background tracking needs a foreground service of type location with a visible notification; ask for ACCESS_BACKGROUND_LOCATION only if you truly need it, and expect Play to ask why
Transport
One WebSocket or MQTT connection; send a point only when it moved past a threshold
The server fans out to subscribers by geohash cell and throttles pushes to one or two seconds
Offline: record trip points locally with timestamps and upload them later for the trip history
Rendering
Interpolate markers between updates, and snap to roads on the server
Cluster dense markers, and collect updates only while the map is visible
Edge cases
The user grants only approximate location, location services are off, or the process dies mid-trip and the trip must come back from the database
Make each feature a pair of modules that talks to others only through small api modules and a few shared core modules, with the :app shell wiring everything through DI and navigation. The goal is team ownership and a small blast radius; build speed follows from a wide, shallow graph.
Module structure
**:feature:x:api and :feature:x:impl: the api module holds interfaces and route types; no feature depends on another feature's impl
Small, stable :core:* modules**: network, database, design system, analytics; a god :core that everyone edits rebuilds the world
Rules enforced by CI: convention plugins, a version catalog and a dependency-rule check that fails on a forbidden edge
Navigation and communication
Features register their entry points through DI multibinding (@IntoSet), and the shell builds the graph from the set
Cross-feature navigation uses route types from api modules; deep links are resolved in one place
Shared state such as the session or a cart badge comes from core services exposing a Flow, not from feature-to-feature calls
Runtime isolation
**No feature initialises in Application.onCreate: lazy init on first use protects cold start
One process means no crash isolation: any feature's crash kills the app, so each team owns its crash-free rate, and every feature has a remote kill switch
Dynamic feature modules** for large, rarely used features; the cost is no direct references and a harder test setup
Team process
CODEOWNERS per module, a small demo app per feature for fast iteration, and review by a platform team for changes to core