Mobile System Design
Offline-first, sync, pagination, caching, modularization, feature toggles
- 01
How would you design an offline-first feed for a mobile app?
MediumCore principle: the UI reads from a LOCAL store and never blocks on network.
// Domain data class FeedItem(val id: String, val title: String, val updatedAt: Long, val isLocalOnly: Boolean = false) interface FeedRepository { fun observePage(): Flow<List<FeedItem>> suspend fun refresh(): Result<Unit> … - 02
Why does offset pagination break in a live feed, and what does the client half of cursor pagination look like?
MediumOffset pagination breaks the moment rows are inserted or deleted between two requests: the offset points at a position, the position shifts, and the user silently sees duplicates or misses items entirely.
// Compose pagination — manual but explicit @Composable fun FeedScreen(vm: FeedViewModel = hiltViewModel()) { val items by vm.items.collectAsStateWithLifecycle() val listState = rememberLazyListState() … - 03
How would you design a chat / messaging feature?
HardChat is one of the hardest mobile features because it combines real-time, offline, attachments, and ordering.
// Sending a message — optimistic + outbox suspend fun sendMessage(chatId: String, body: String) { val clientId = UUID.randomUUID().toString() val now = System.currentTimeMillis() val local = MessageEntity( id = clientId, clientId = clientId, chatId = chatId, … - 04
How do you run feature flags in a large mobile app without ending up with hundreds of stale branches?
MediumA feature flag is a promise to delete code later, and the flags that ruin a codebase are the ones nobody ever removed.
// Flag interface — typed, testable interface FeatureFlags { val checkoutV2Enabled: Boolean val newSearchEnabled: Boolean val paywallVariant: PaywallVariant } … - 05
How do you wire analytics, crash reporting and performance monitoring so you can swap vendors and keep PII out?
MediumAnalytics, crash reporting and performance are three separate systems, and the usual mistake is letting three vendor SDKs spread through your call sites instead of hiding each behind one interface.
// Internal interface interface AnalyticsClient { fun track(event: AnalyticsEvent) fun identify(userId: String?) fun setProperty(key: String, value: Any?) } … - 06
Design a system that streams large amounts of data while being kind to the device.
HardA high-rate stream on a phone is a budgeting exercise: memory, battery, CPU, network and the render loop each have a ceiling, and the design is mostly about deciding which one you are willing to hit first.
// Telemetry stream — explicit buffer and drop policy, batched to the UI fun telemetryFlow(): Flow<List<Sample>> = socket.messages .map { it.toSample() } .buffer(capacity = 256, onBufferOverflow = BufferOverflow.DROP_OLDEST) .chunkedByTime(100) // custom operator: batch every 100ms, no stdlib equivalent … - 07
Trace a push notification from your backend to the user tapping it and landing on the right screen. Where does it usually go wrong?
MediumThe happy path is short, and almost every real bug in this system is either a token problem or an assumption that delivery is guaranteed.
class AppMessagingService : FirebaseMessagingService() { override fun onNewToken(token: String) { // Fires on install, restore, data clear, and rotation tokenSyncScheduler.enqueue(token) } … - 08
What is your app actually allowed to do in the background on Android and on iOS, and how do you schedule a sync around those limits?
MediumNeither platform lets you choose when background code runs — you describe the work and its constraints, and the system decides the moment.
// Shared code owns the logic; the platform owns the scheduling interface SyncScheduler { fun schedulePeriodic() fun requestExpedited() } … - 09
The same note gets edited offline on a phone and on a tablet. What does the client need so that merging the two is not luck?
HardThree pieces on the client make the outcome deterministic: an outbox, an idempotency key per operation, and a version the server can compare against.
@Entity(tableName = "outbox") data class PendingOp( @PrimaryKey val opId: String = uuid4().toString(), // idempotency key val entityId: String, val type: OpType, val payload: String, … - 10
Six months after a release, people are still opening that old build. What does that force you to design differently?
MediumA shipped mobile client cannot be retired the way a web page can, so every API decision has to survive years of clients you no longer control.
// Version gate, checked on launch and on resume @Serializable data class VersionPolicy(val minSupported: Int, val recommended: Int, val storeUrl: String) suspend fun checkVersion(current: Int): Gate = when { current < policy.minSupported -> Gate.Block(policy.storeUrl) // blocking screen … - 11
When is server-driven UI worth it, and what goes wrong when a team takes it too far?
HardServer-driven UI means the backend sends a description of a screen and the client renders it from a fixed catalogue of components it already ships.
@Serializable sealed interface Block { @Serializable @SerialName("hero") data class Hero(val imageUrl: String, val title: String, val action: String) : Block @Serializable @SerialName("product_row") … - 12
How do you build search-as-you-type so it stays responsive without hammering the backend?
MediumDebounce the input, cancel the previous request, and let local results carry the screen while the network catches up.
private val query = MutableStateFlow("") val results: StateFlow<SearchUi> = query .map { it.trim() } .distinctUntilChanged() .debounce(250) … - 13
You like a post on the detail screen, go back, and the feed still shows it unliked. What is wrong with the architecture?
MediumTwo screens are holding two copies of the same entity, so the fix is not to notify the feed — it is to make both screens read the same store.
// Wrong: each screen owns a copy, so a write on one is invisible to the other class DetailViewModel(private val api: Api) : ViewModel() { val post = MutableStateFlow<Post?>(null) fun like(id: String) = viewModelScope.launch { post.update { it?.copy(liked = true) } // only this screen will ever know … - 14
Product wants every screen fresh, and the app still fires forty requests on cold start. How do you decide what to cache and for how long?
MediumFreshness is a per-resource decision, never one app-wide number: classify each endpoint by how bad it is to show it stale, and give each class its own policy.
// One policy per resource class, not one number for the whole app enum class Freshness(val ttl: Duration) { NEAR_STATIC(24.hours), // render stale, revalidate in the background USER_OWNED(5.minutes), // stale-while-revalidate VOLATILE(Duration.ZERO) // never render silently from cache } … - 15
Opening the profile screen fires seven requests and it stays blank until the slowest one returns. What do you change, on both sides?
MediumNothing on first paint should wait for the slowest of seven calls: collapse the round trips into one screen-shaped endpoint, and render each section as its own data arrives.
// Wrong: awaitAll means the screen is blank until the slowest call finishes val state = coroutineScope { val header = async { api.header(id) } val stats = async { api.stats(id) } val recs = async { api.recommendations(id) } // 900 ms p95, blocks the name ProfileUi(header.await(), stats.await(), recs.await()) … - 16
iOS shows one order total, Android shows another, and the emailed receipt shows a third. What went wrong in the design?
MediumThree clients are each computing a number that only one system is allowed to own: the total has to be calculated once, on the server, and merely rendered everywhere else.
// Wrong: the pricing rule now lives in three codebases and drifts on the first edge case fun total(items: List<Item>, promo: Promo?): Double { val sub = items.sumOf { it.price * it.qty } val discount = if (promo?.code == "SUMMER") sub * 0.1 else 0.0 val shipping = if (sub - discount > 50) 0.0 else 4.99 // will disagree with the receipt return sub - discount + shipping … - 17
Forty Gradle modules later, a one-line change still rebuilds most of the app and CI got slower. What did the split get wrong?
HardA module split only pays off if the dependency graph is wide and shallow; one
:corethat everything imports and everyone edits rebuilds the world on every commit.// settings.gradle.kts — wide and shallow beats one :core everyone imports include(":app") include(":core:network", ":core:database", ":core:designsystem") include(":feature:feed", ":feature:profile", ":feature:profile:api") // feature/feed/build.gradle.kts … - 18
Users say the photos they posted last night never appeared, and the phone was in their pocket the whole time. How do you design the upload path?
HardAn upload is a durable job, not a screen operation: copy the file somewhere you own, record the intent in a queue, and let the platform's background transfer machinery finish it.
@Entity(tableName = "upload_queue") data class UploadJob( @PrimaryKey val id: String = UUID.randomUUID().toString(), // also the idempotency key val localPath: String, // our own copy: the picker's content:// grant will be gone val postId: String, val uploadUrl: String? = null, // resumable session, so a retry continues from its offset … - 19
A limited offer must end at midnight, but some users keep seeing it for another day. Which clock is the app allowed to trust?
HardNot the device one: the wall clock is user-settable, drifts, and jumps, so anything with a deadline or an ordering attached has to be anchored to the server.
// Wrong: the countdown and the gate both trust a clock the user can change val remaining = offer.endsAtMillis - System.currentTimeMillis() if (remaining > 0) showOffer() // Right: one offset, refreshed on every response, applied wherever "now" is needed class ServerTime { … - 20
Sync reports success every hour, yet one user has been looking at last week's data. Where does an incremental sync silently lose rows?
HardAlmost always at the watermark:
updated_at > lastSyncis not a safe cursor, and a row skipped once is never asked for again.// Wrong: the client invents the cursor from data it happens to have suspend fun syncBad() { val since = dao.maxUpdatedAt() // ties, clock skew and in-flight commits all lose rows val page = api.changes(since = since) // deletes are not even representable here dao.upsertAll(page.items) } …