Mobile Design Rounds
Case study Android28 min read

Design a notifications screen in Jetpack Compose (Android)

Last reviewed . Drafted with AI assistance. Checked against official documentation where noted (how this is checked). Part of: The five-step method · Staff-level guide.

Build it. The Kotlin code, the JSON, the tests and the strings are on a separate page: Build it: a notifications screen in Jetpack Compose.

How to read this. This page is the 45 minutes, in the order I would talk in an interview: ask, state assumptions, model the data, design the screen, go deep on the countdown, handle state and the server, then failures, tests and trade-offs. It is Android and Jetpack Compose only. It uses a travel app as the example. It builds on the news feed case. The follow-up questions are my reconstruction from public prep material, not transcripts of real rounds.

By the end you can:

  • Split what you can see on a screen from what you must ask.
  • Choose a data model for rows that look different, and say why.
  • Explain why a countdown needs a clock and not a counter, and which clock.
  • Say exactly which Compose code runs on every tick, and how to keep that small.
  • Handle Accept and Decline when the server is slow, wrong, or gone.

Read section 4 twice. Most answers stop at a timer in a LaunchedEffect.

TL;DR. My answer in four lines:

  • Data. A sealed type with three row types in one LazyColumn, with stable keys and a content type. The server sends facts and times. The phone writes the words.
  • Time. The deadline is data. Time comes from a clock object: the server time of the last response plus elapsedRealtime(). A Flow wakes when the shown second changes, and runs only while the screen is started.
  • Compose. The tick is read in two small leaves: the text, and a draw lambda for a bar. The card reads only a derived "expired" flag.
  • Actions. Accept and Decline are optimistic, with an overlay in the ViewModel, one request per row, and a rollback with a message.

Patterns in this round.

  • State machine (a friend request moves through a short list of states)
  • Repository (parsing, clock sync and the order of calls in one place)
  • Idempotency key (when an action creates something, the key survives a restart)

More in design patterns in mobile rounds.

Free: one follow-up round a week. A short interview question with a weak and a strong answer, by email. You also get the cheat sheet.

1. Scope and constraints (minutes 0 to 5)

In short: A list with three kinds of rows. One row has a clock in it, and one row has buttons. I read the screen first, then I ask.

A prompt I would expect: "Here is a screenshot of a notifications screen in a travel app. Design how you would build it." There is no spec. So I split what I can see from what I must guess.

What I seeWhat it means for the designStatus
A vertical list of rowsA scrolling list that can be long, so it must be lazySeen
Most rows: avatars, text, "12 minutes ago"One row layout. A timestamp that gets old while the screen is openSeen. The refresh rate is my reasoning
Several avatars overlapA layout for one to many people, with a limitSeen. The limit is my assumption
A big promo card with "2 days, 2 hours, 43 minutes and 5 seconds"A different row type, and a clock that changes every secondSeen
The card is inside the same scrollIt is a list item, not a headerSeen. Its position is unknown
A friend request with Accept and DeclineActions that call the server and can failSeen. What the server does is unknown

Now the requirements, in the five groups the prompt asks for. The last column says where each one comes from.

GroupRequirementSource
FunctionalShow photo activity, one offer and friend requests in one listScreen
FunctionalAccept or Decline a friend requestScreen
FunctionalOpen a row: album, order flow, profileAssumption
FunctionalPull to refresh, and a retry after a failed loadAssumption
UIOverlapping avatars for one to many people, with a "+N" for the restScreen, limit is mine
UIText that depends on the action and on how many peopleScreen
UIButtons that cannot be tapped twice, and a way to show a failureAssumption
PerformanceSmooth scrolling with many imagesAssumption
PerformanceA tick every second must not redo work for the whole listMy design goal
TimeThe countdown changes every secondScreen
Time"12 minutes ago" is old after a few minutes on the screenReasoned from the screen
TimeThe offer can end while the user is looking at itReasoned from the screen

Unknown things I would ask about, each with the default I take if the interviewer says "your call":

QuestionMy default
Does the offer come from the same endpoint as the notifications?Yes. The server sends one list in display order. The server knows about experiments and limits
Where is the offer in the list?The server decides. The client never assumes a position
Does the backend send the deadline, and the server time?Yes, both, as epoch milliseconds
Does the server enforce the end of the offer?Yes. The countdown only shows it. Tapping Order is checked by the server
How exact must the countdown be after background, rotation or a clock change?Right to the second whenever it is on screen. A changed device clock must not change it
More than one countdown?One now. I design so that two cost almost nothing more
Is the list available offline?No. Memory only, reload on open
How many rows? Paging?First page of about 30. Paging is parked
Are Accept and Decline safe to repeat?Yes. They set a state, so a repeat gives the same result
Which platforms?Android, Kotlin and Compose only. As the prompt says
In scopeOut of scopeParked until you ask
Three row types in one listHow push delivers a notificationPaging and a local database
The countdown and the clock behind itNotification settingsRead and unread state
Accept and Decline with failuresDeep link detailsGrouping rows on the phone
Loading, error and refreshThe order flow itselfMore row types

Your decision. The interviewer asks: "Should the list work offline? Put it in Room." Room is the Android library for a local database. Do you say yes?

First idea, and why it fails. Yes, every list gets a database. But this list is made of things that go stale in minutes: an offer that ends, a friend request that can be withdrawn. A cached copy shows exactly the wrong thing. It also adds migrations and a second source of truth.

What I would say. Memory only for now. The list reloads when the screen opens and on pull to refresh. If the interviewer wants offline reading, I add Room as the source of truth and mark old offers and requests as unconfirmed.

Say this. "Three row types, one list, in the order the server sends. I will treat the countdown as a clock problem. The server decides when the offer ends, and the phone only shows it. I assume Android and Compose, no offline database, and I will tell you when an assumption matters."

The follow-up question I expect: "Why not just ask the server for the text, like the "12 minutes ago"?"

Because that text is wrong one minute later. The server sends a time. The phone makes the words. The same is true for "2 days, 2 hours" on the offer.

2. Data and contracts (minutes 5 to 13)

In short: One sealed type with three subtypes. Times are numbers, never text. "Expired" is computed from the time, never stored.

2.1 One type or many (minutes 5 to 9)

The three rows share an ID and a time. Everything else differs. These are the options I weigh:

OptionGoodBadI pick it when
One flat class with a type field and nullable fieldsEasy JSONAn offer without a deadline can exist. Nulls everywhere. The compiler does not check whenIt is a throwaway prototype
An interface with shared fields (title, body)One row composableThe rows are not the same. The UI must know the type anyway, so the abstraction hides nothingAll rows really look alike
A sealed interface with three subtypesEach type has only its own fields. when must cover every typeAdding a type touches every whenThis case
Separate lists per type, merged in the UIEach source is simpleThe order rule moves into the UI. Pagination of a mixed list gets hardThe offer comes from another endpoint. Then I merge in the repository, not in the UI
NotificationItemid, createdAtMssealedPhotoActivityaction, actors, actorCount, photoCountAlbumOffertitle, body, image, expiresAtMsFriendRequestfrom, status, inFlight
One sealed parent. Each child has only the fields that make sense for it.

Two layers use this. The wire format is a DTO (data transfer object: the class that matches the JSON). It must survive a new type from a newer server: an unknown type becomes an Unknown item and is skipped, so the screen does not crash. The app model is the sealed type above. Mapping happens once, in the repository.

2.2 The fields (minutes 9 to 12)

TypeFieldsChoice and why
Photo activityactors (first three), actorCount, action, photoCount, albumTitle, fallbackTextThe server sends facts, not a sentence. The phone writes "Ana and 2 others added 5 photos" with plurals. An unknown action shows fallbackText so an old app still says something
Album offertitle, body, imageUrl, ctaLabel, expiresAtMsOnly the deadline. "Live" or "ended" comes from the clock
Friend requestfrom, status (Pending, Accepted, Declined), inFlight (Accept, Decline or none)status comes from the server and lasts. inFlight is local and short. I keep them apart so a refresh cannot erase what the user just did

Stable identity and time:

  • id is a string and unique across all types, for example pa_881, offer_12, fr_40. If the server only makes IDs unique per type, the key is type:id. I ask.
  • If the server groups people ("Ana and 2 others"), the group ID must stay the same when a third person joins. If it changes, the row looks new: it fades out and a new one fades in.
  • Times are epoch milliseconds in UTC. Never text like "12 minutes ago". The words are made on the phone from now - createdAtMs. If the result is negative, I show "Just now".
  • The phone never sorts by time. The offer has no natural place in time order, so the server order is the order.

Your decision. The offer might come from its own endpoint. The client could put it at position 2. Is that fine?

First idea, and why it fails. Hard-code the position. Quick, but placement, experiments and frequency limits are server rules. A fixed index copies those rules into the app and drifts. With paging it also breaks: what is "position 2" on page 3?

What I would say. I assume one endpoint, in display order. If the interviewer says the offer is separate, the repository merges it with one pure function merge(feed, offer) and the rule lives in one place. The UI model does not change.

2.3 The calls (minutes 12 to 13)

CallGives or doesNotes
GET /v1/notifications?limit=30serverTimeMs and items in display orderThe server time on every response keeps the clock right (section 4.2)
POST /v1/friend-requests/{id}/acceptNew statusIt is idempotent: repeating it gives the same result
POST /v1/friend-requests/{id}/declineNew statusSame
404 or 410 on those callsThe request is goneThe row leaves the list with a short message. A 409 or a timeout means "unknown": refresh and show what the server holds
Order button on the offerOpens the order flow with the offer IDThe server checks the deadline again

JSON, DTOs and the unknown-type fallback are in the build guide.

3. Client structure (minutes 13 to 21)

In short: One LazyColumn. A stateless screen, a ViewModel for the state, a repository for the server. A clock object that has no UI.

3.1 Who owns what (minutes 13 to 17)

Screen and rowsstate in, events outComposestate and eventsNotificationsViewModeluiState, in-flight overlay, messagescallsNotificationsRepositoryfeed, refresh, respond, parsingHTTPREST APIlist, accept, declinesyncServerClockserver time from the last responseno UI
The screen draws state. The clock is a plain object that the screen can read and the repository can correct.
PieceIt ownsWhy it exists
Screen and rowsNothing but drawing. Takes state and callbacksEasy to preview and test. It survives a rotation because the state is not in it
ViewModeluiState, the in-flight map, one pending messageSurvives rotation. It is the place for "the user did X and the server has not answered"
RepositoryThe feed, refresh, respond, DTO mapping, clock sync, the order of callsThis code has no business in a composable. It needs to be tested without a screen
ServerClockThe best guess of the server timeTwo places need time (offer and "ago" text). Time must not live in a composable

What I do not add, and why:

  • Use cases. Each would be one line that calls the repository.
  • A database. Section 1 says why.
  • Paging 3. One page of 30 rows. It enters if the list grows (section 6).
  • A separate UI model. The sealed domain type is what the rows need. I copy it only for the in-flight overlay, in one place, in the ViewModel.

3.2 The list (minutes 17 to 21)

ThingInside the LazyColumnOutside
Photo activity, offer, friend requestYes. They scroll together, in server order
Loading, first-load errorThey replace the list
Pull to refreshA container around the list
Snackbar for failuresThe Scaffold
The clockLives above the list. Rows get it as a parameter

The list code is short. Two arguments matter:

LazyColumn(state = listState, contentPadding = PaddingValues(16.dp),
    verticalArrangement = Arrangement.spacedBy(12.dp)) {
    items(items = items, key = { it.id }, contentType = { it::class }) { item ->
        when (item) {
            is PhotoActivity -> PhotoActivityRow(item, minuteNow, { onAlbumClick(item.albumId) }, Modifier.animateItem())
            is AlbumOffer    -> OfferCard(item, clock, { onOfferClick(item.id) }, Modifier.animateItem())
            is FriendRequest -> FriendRequestRow(item, minuteNow, onAccept, onDecline, Modifier.animateItem())
        }
    }
}
  • key = { it.id } gives each row an identity (a stable key). The state of a row, and the scroll position, follow the ID when rows move or arrive. The Android docs say a stable key keeps item state the same across data changes.
  • contentType lets Compose reuse the composition of a row only for rows of the same type. The docs say reuse works between items of the same type. The offer card is very different from an avatar row, so this matters.
  • animateItem() animates arrival, removal and moves. It exists from Compose Foundation 1.7.
  • Row models are @Immutable. A List<Actor> inside a class makes the Compose compiler treat it as unstable unless you say otherwise. With strong skipping (the default since Kotlin 2.0.20) unstable parameters are compared by instance, stable ones with equals. I want equals, so a refresh that returns equal rows does not redraw them.
  • Images: avatars at the size on screen (40 dp), not the original file. Use the image library the app already has.

3.3 When the list changes

EventWhat happensMy rule
Pull to refresh brings new rows at the topThe list follows the row you were looking at, so new rows appear above the screenIf the user pulled to refresh, scroll to the top after the result. If the update came by itself, do not move and show a "New" chip
The offer moves in the server orderSame key, so the row moves with animateItem()Nothing to write
A row disappears (declined, withdrawn)It fades out. The rows below move upNothing to write
Two refreshes overlapThe older answer could arrive last and winOne refresh at a time. A refresh and an Accept take turns (section 4.4)
An item type the app does not knowIt is skipped in the repositoryThe screen never sees it

I checked the first row in a test on Compose 1.7 (desktop). A list scrolled to the very top kept the old first row on screen when I put a row above it. The official pages I read do not say this, so I test it in the app before I rely on it.

4. Make the hard part work (minutes 21 to 38)

In short: A deadline is a clock problem. A counter counts ticks and a clock tells the time, so the value always comes from a clock. Then I read that value in the smallest place that needs it.

4.1 What is unknown (minutes 21 to 23)

UnknownWhy it mattersMy default
Does the backend send the deadline?If not, the phone invents one and it is wrongYes: expiresAtMs and serverTimeMs
Must it stay right after background or a rotation?A counter that restarts loses timeAlways computed from a clock, never counted
Can the device clock change?Users do it, and so does the networkUse a clock that does not jump
Does the server enforce the end?If yes, the phone is only a display and can be a little offYes. The order call is checked again
Several countdowns?Decides if I share one tickerOne now
Recomposition, battery, or both?Decides how much I tuneBoth. The tick runs only while the card is on screen and started

4.2 Pick the time source and the ticker (minutes 23 to 28)

Five ways to drive a countdown:

ApproachProblemVerdict
LaunchedEffect(Unit) { while (true) { delay(1000); now = ... } } in each cardIt drifts, because the work adds to every delay. It keeps running while the app is stopped: the Android docs warn that a LaunchedEffect can run when the composable is not visibleNo
CountDownTimer or a handler that counts down a numberCountDownTimer reads elapsedRealtime(), so it does not drift for good. But it ticks on a handler, not on the shown second, you cannot give it your server clock, and it knows nothing about the lifecycle. A number counted down by hand in a ViewModel is the version that is wrong for goodNo
A Flow that wakes near each second change and emits a clock reading, collected with the lifecycleNeeds a clock objectYes
The same Flow, shared by the whole appMore codeWhen two or more countdowns or the "ago" labels exist
A frame loop (withFrameNanos)About 60 updates a second for text that changes once a secondOnly for a smooth progress animation

Your decision. The deadline is 2 days away. What do you do each second?

First idea, and why it fails. Subtract one second from a number in the ViewModel. After the app is stopped for a minute the number is a minute late, and nothing can repair it.

What I would say. I store the deadline once. Each wake-up I read a clock and subtract. If a wake-up is late, the next reading is still right. The timer only says "wake me about then".

Which clock? The Android reference for SystemClock says System.currentTimeMillis() is the wall clock, which "can be set by the user or the phone network" and can jump. elapsedRealtime() counts time since boot, including sleep, so it works as a monotonic clock here. So:

  • Server time estimate = the server time of the last response + the time since then, measured with elapsedRealtime().
  • The user can move the wall clock by hours and this number does not move.
  • The estimate runs behind by about one trip from the server to the phone, because the server stamped the time before the response travelled. So the card shows a little more time than there is. A careful version adds half of the round trip. The server checks again at order time, so I accept this error. I only use fresh responses, not cached ones, and a new response can move the display by the jitter between two trips.
  • On this screen an offer is only drawn after a response, so the anchor already exists. The wall clock fallback matters only if you show cached rows before the first response. Then the number may jump once.
  • After a process restart the anchor is gone. The first response sets it again.
class AnchoredServerClock(
    private val elapsedRealtimeMs: () -> Long,  // { SystemClock.elapsedRealtime() }
    private val wallClockMs: () -> Long = { System.currentTimeMillis() },
) : ServerClock {
    private class Anchor(val serverMs: Long, val elapsedMs: Long)
    @Volatile private var anchor: Anchor? = null

    fun sync(serverNowMs: Long) { anchor = Anchor(serverNowMs, elapsedRealtimeMs()) }

    override fun nowMs(): Long {
        val a = anchor ?: return wallClockMs()
        return a.serverMs + (elapsedRealtimeMs() - a.elapsedMs)
    }
}

The ticker wakes when the shown second changes, not every 1000 ms. The displayed value rounds up, so it reaches zero when the offer ends:

fun ServerClock.countdownTicks(deadlineMs: Long): Flow<Long> = flow {
    while (true) {
        val now = nowMs()
        emit(now)
        val remaining = deadlineMs - now
        if (remaining <= 0) return@flow            // expired: one last value, then stop
        val toNextSecond = remaining % 1_000L
        delay(if (toNextSecond == 0L) 1_000L else toNextSecond)
    }
}

From my test, a deadline 3400 ms away with a fake clock. The delay is not a constant, and the flow ends by itself:

Virtual timeSeconds shown
0 ms4
400 ms3
1400 ms2
2400 ms1
3400 ms0, then the flow ends
  • Missed ticks. I jumped the fake clock 30 s forward in a test. The next reading showed the right value at once. Nothing is counted, so nothing is lost.
  • Lifecycle. collectAsStateWithLifecycle collects only while the lifecycle is at least STARTED, and starts the Flow again when it returns. The first value of the new Flow is a fresh reading. In a test, a stopped lifecycle made no clock reads at all.
  • Scrolled off screen. A lazy row that is scrolled far away leaves the composition, so its collector stops. A test with 60 rows showed no clock reads while the card was away, and reads again when it came back.
  • Expired. The Flow ends after the deadline. Another test shows no clock reads after expiry.
  • Device clock changed. Not visible to the countdown. A test moves the wall clock back one hour and the server time does not move.
  • New deadline. If the server extends the offer, the deadline in the model changes. The Flow is remembered with the deadline as a key, so a new Flow starts.

4.3 Where each piece lives (minutes 28 to 30)

PieceLives inWhy there
The deadlineThe AlbumOffer from the serverIt is data. It does not change each second
The estimate of server timeServerClock, one per appIt must outlive screens, and it needs no UI
The "now" for displayA State<Long> made in the card from the FlowIt is only for display. If it lived in the ViewModel, uiState would change every second
"Expired"derivedStateOf { now >= deadline }It is computed. It flips once
The "ago" labelsOne slow Flow (each minute), made once for the list, passed as StateSame rule, with a one-minute step

4.4 What recomposes on each tick (minutes 30 to 34)

Compose has three phases: composition (run the composable code), layout (measure and place) and draw. Reading a State in a phase subscribes that phase to it, and a change makes that phase run again. Running composition again is called recomposition. The performance docs call this deferring reads: a read inside a layout or draw lambda lets Compose skip composition, and a read in a draw lambda lets it skip layout too.

LazyColumn itemthe list and the other rowsOfferCardreads only "expired"CountdownTextreads the tick, builds the stringCountdownBarreads the tick inside the draw lambda
The tick is read in the two leaves. The card and the list do not read it.
Where the tick is readWhat runs each secondUse it for
In the card body (val now by ...)The card body again. The test showed one more composition of the image per tickNothing. This is the trap
In CountdownText (it takes a State<Long> and reads .value)That function, then measure and draw of its TextThe text
In derivedStateOf { now >= deadline }, read by the cardThe card, once, when the value flipsExpired or not. Also "less than one hour left" styling
In a drawBehind { } lambdaDraw onlyA shrinking bar or ring
@Composable
fun OfferCard(offer: AlbumOffer, clock: ServerClock, onOrderClick: () -> Unit, modifier: Modifier = Modifier) {
    val now = rememberCountdownNow(clock, offer.expiresAtMs)            // a State<Long>, NOT read here
    val expired by remember(offer.expiresAtMs) { derivedStateOf { now.value >= offer.expiresAtMs } }
    Card(modifier.fillMaxWidth()) {
        Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
            // image, title and body
            if (expired) Text(stringResource(R.string.offer_ended))
            else { CountdownText(offer.expiresAtMs, now); Button(onClick = onOrderClick) { Text(offer.ctaLabel) } }
        }
    }
}

What I measured in tests (Compose 1.7.3 on the desktop JVM, a stand-in image composable that counts how often it runs, a fake clock):

  • Three ticks: the card image was composed the same number of times before and after. The photo row next to it did not change either. The text did.
  • The same card with the read moved into the card body: one more composition per tick.
  • A bar that reads the tick in drawBehind: no composition and no measure after five seconds of ticks, and more than four draws.
  • Expiry: the card ran once more when it flipped to "ended", the order button left, and the ticker stopped.

Honest limits:

  • This is not "the card never recomposes". It recomposes when its own parameters change, when it reads other state, or when its parent passes new values. I only moved the per-second reads out of it.
  • A text of a different width can make its parent measure again. Tabular figures (fontFeatureSettings = "tnum") keep digits the same width. I did not measure the layout cost of this.
  • These numbers come from a desktop test, not from a phone. I still profile on a device (section 5).
  • Accessibility. The Compose docs say not to use live regions on content that updates often, such as countdown timers. So the text is not a live region. A screen reader reads it when focused. I can give it a rougher description ("about 2 days") that changes only each minute.

Say this. "The tick never goes into the ViewModel or the list state. It lives in the card, as a State that is read in two small leaves: the text and the bar. The card itself reads only a derived boolean, so it runs again once, when the offer ends."

The follow-up question I expect: "What if there are five countdowns on the screen?"

Share the clock, not the tick: one ServerClock for the app, and each card keeps its own aligned flow. If five wake-ups a second is too much, share one ticker at about 250 ms with stateIn(appScope, SharingStarted.WhileSubscribed(5_000)) and read it through derivedStateOf. A fixed 1 s shared tick is not aligned to each deadline, so a shown second can lag by up to 1 s, and the Order button can stay up for 1 s after the end. Pick the cost you can explain.

4.5 Accept and Decline (minutes 34 to 38)

Pendingtwo buttonstapIn flightthe row already shows the resultserver says OKAccepted or removedthe server status is keptserver says 410Gonethe row leaves, with a short message
Click an event to move it. A failure puts the row back to Pending, and the user can try again.

Your decision. Do you show the result before the server answers (optimistic), or wait?

First idea, and why it fails. Wait, with a spinner, because it is simpler to reason about. But Accept succeeds almost every time and a spinner on every tap feels slow. Optimistic without a rollback plan is worse: the row says "friends" and the server said no.

What I would say. Optimistic, with an explicit overlay. The server status stays in the repository. What the user did is a separate in-flight map in the ViewModel. The UI is server status plus overlay. On failure I drop the overlay and the old state comes back. I would switch to waiting if the action had a cost the user cannot see failing, such as a payment.

  • Double tap. The ViewModel checks the map first, so two taps in one frame send one request. A test sends three taps and counts one call. The guard lives as long as the ViewModel. After process death or a new ViewModel it is empty, and the server must treat a repeat as safe.
  • Leaving the screen. The call runs in the app scope, so a tap that was sent is not cancelled when the ViewModel is cleared. The refresh runs there too, so a cleared ViewModel cannot leave the repository stuck in "refreshing".
  • The answer is lost. A timeout does not mean the server said no. The call may have worked. So a timeout or a 409 is "unknown": the screen says "Could not confirm" and refreshes, and the row shows what the server holds. Only a clear 5xx rolls back with "Try again". An unknown status string is treated the same way, never as a Decline.
  • Refresh at the same time. A slow refresh that started before the tap could bring back "Pending" after "Accepted". The repository lets a refresh and a respond take turns. In a test, a slow refresh and an Accept ended in Accepted.
  • Token expires. The HTTP layer refreshes the token and retries the same call once. Accept is a state change, so a repeat is safe. If the refresh fails, the user goes to sign-in and the overlay is cleared.
  • App killed after the tap. The overlay is lost. The server either got it or not, and the next load shows the truth. I do not add an outbox: Accepts are rare, and a late silent Accept would surprise people.
  • If the call creates something (not a state change), I send an idempotency key. If I keep the action across restarts, I save the key with the action and send the same key after the restart. A new key after relaunch would make a duplicate.

5. Break it, ship it, roll it back (minutes 38 to 45)

In short: Every failure ends in a correct clock, a row that comes back, or a row that leaves with a message. A wrong state can show for a moment, but the next refresh fixes it.

FailureUser seesData holdsRecovery
Device clock moved 3 hours aheadThe countdown does not changeThe anchor from the last responseNone needed
App stopped for two days, then openedThe right time left at once, and a refreshNothing old is trustedThe ViewModel loads when the screen opens. Refreshing again when a stale list comes back to the front is a follow-up I would add (my assumption)
Process deathThe list loads againNothingCold start. The first response sets the clock
Cached rows shown before the first response (not on this screen)The countdown from the wall clock, then one small jumpNo anchor yetThe first response anchors it. The server still checks the order
The offer ends while the user looksThe card turns into "This offer has ended"The same offer, now past its deadlineIf a tap gets through, the server says ended, and the app refreshes
Offer already ended when it arrivesThe ended card, or no cardThe deadlineThe repository may drop it. The card handles it anyway
Accept tapped twiceOne requestOne in-flight entryThe second tap is ignored
Accept fails (offline, 5xx)The buttons come back and a messageIn-flight entry removedThe user taps again
Accept after the request was withdrawn (404, 410)The row leaves and a messageRow removedNone
Accept times out, or the server says 409"Could not confirm", then the real stateThe server decidesRefresh. Do not remove the row on a guess
Token expires during AcceptNothing, or the sign-in screenOverlay until the retry endsRefresh the token, retry the same call once. If it fails, clear the overlay and sign in
App killed right after the tapThe truth after the next loadNothing, the overlay was in memoryThe server has either seen the tap or not
An older refresh arrives lastThe newest data staysThe newest responseOne refresh at a time. Refresh and respond take turns
A newer server sends a row type the app does not knowThe row is not shownNothingThe repository skips it
Avatar fails to load, a name is very long, font scale is 200 percentA placeholder, wrapped text, taller rowsNoneNo fixed heights on text rows

5.1 Tests that matter

AreaKindWhat I assert
Countdown mathsUnit2 days 2 h 43 min 5 s splits right. 0.4 s left shows 1 second. A past deadline shows 0
The clockUnitA wall clock moved back one hour does not move the server time. A second response re-anchors
The tickerUnit, virtual timeIt wakes at 0, 400, 1400, 2400 and 3400 ms for a 3400 ms deadline, then ends. After a 30 s jump the next value is right. A past deadline emits once
LifecycleCompose UIWhen the lifecycle is stopped, the fake clock gets no reads. When it starts, the text shows the current value
Photo activity, many peopleUnit and Compose UIThe second avatar overlaps the first. Not tested in my run: the text for 1, 2 and 5 people, and the "+2" text
Friend request actionsUnit (ViewModel, fake API)The screen loads the feed on its own. Accept shows at once. A failure rolls back and sends one message. Three taps make one call. A 410 removes the row. A timeout, a 409 or an unknown status refreshes and never removes the row. A cancelled refresh does not leave the repository stuck
Order of callsUnitA slow refresh with "Pending" cannot overwrite a later Accept
Feed updates and identityUnit and Compose UIAn unknown row type is skipped. Two rows with one id show once. For an item inserted above the first visible one I measured the list position, but that test prints it and does not assert it
RecompositionCompose UIA counting stand-in for the image: the count stays the same across ticks. A deliberate bad version (read in the card body) goes up once per tick
PerformanceManualLayout Inspector recomposition counts. Macrobenchmark scroll jank with JankStats. A system trace to count wake-ups with the screen off

Everything above the last row ran in my own test setup: 27 tests on Compose 1.7.3 on the desktop JVM, with stand-ins for the Android image library and string resources. None ran on a phone. Not covered by any test: the token refresh (it lives in the HTTP layer), a bad row inside a known type (it fails the whole refresh, and the old list stays), and screen reader behaviour. The test code is in the build guide.

5.2 Ship it and roll it back

  • Rollout. A staged release. The new card is behind a remote flag, with the old static card as the fallback.
  • What I watch. Jank rate while scrolling (JankStats), crash-free sessions, the share of Order taps that the server rejects as ended (it tells me how wrong the clock is), the time from open to first rows.
  • Guardrail. Rejected-as-ended must not go up. If it does, the clock is wrong somewhere, and I do not "fix" it by trusting the device clock.
  • Rollback. Turn the flag off. The data model and the repository do not change between the two versions.

6. Follow-ups that change a constraint

In short: Each follow-up changes one constraint. A strong answer says what breaks and what stays.

  1. A second countdown appears on another row type. Strong: share the clock for the app, and keep one aligned flow per countdown. If wake-ups matter, one shared ticker at about 250 ms with stateIn(appScope, WhileSubscribed(5_000)), read through derivedStateOf. Weak: "Add another timer."
  2. The offer is also shown on the home screen. Strong: the clock object is already app-wide. Only the ticker moves up. The card stays the same. Weak: "Copy the card."
  3. The server extends the offer by one hour. Strong: the deadline is data. A refresh or a push replaces expiresAtMs, the remembered Flow has the deadline as a key, so a new one starts. An ended card must be able to come back to a live card. Weak: "Restart the app."
  4. Users say the countdown is 3 seconds off. Strong: 3 seconds is more than one network trip, so look for a cached response used as an anchor, a CDN Age header, or an anchor set long after the response arrived. Add half of the round trip, never anchor on cached responses, and compare with the rate of "ended" rejections. Weak: "Use the device clock."
  5. The list must work offline for days. Strong: Room as the source of truth, and the last anchor saved with the wall time and the boot count. After a reboot or a wall clock change, the saved anchor is not valid, so mark the countdown as approximate until a response arrives. The server still decides. Weak: "Cache the JSON."
  6. The list has thousands of rows. Strong: cursor paging with Paging 3, keys stay, and Room as the source of truth. The clock and the card do not change. Weak: "Load them all."
  7. Product wants rows grouped on the phone. Strong: no. The server groups. Rows keep stable IDs when a group grows. If it must be on the phone, a pure function with tests. Weak: "Group inside the composable."
  8. Product wants read and unread. Strong: it is server state and a badge count. Mark as read when the row has been on screen for a moment. It is its own case. Weak: "A boolean on the row."

7. What changes by level

In short: As I read it, levels differ in who finds the hard part: the interviewer, the candidate, or the candidate with a plan.

These levels are my own reading. They are not any company's scoring guide.

LevelThe answer coversI want to hear
Mid-levelA sealed type, a LazyColumn with keys, a ViewModel with a StateFlow, a timer in a LaunchedEffect with a lifecycle-aware collect, a disabled button while sending"The three rows are different, so I use three types and one list."
SeniorA clock and not a counter, the deadline as data, the read of the tick in the smallest place, a derived "expired", ticks only while started, the double tap and the failure after an optimistic update, calls that take turns"The value always comes from a clock. A timer only says when to look."
StaffA shared time source for the app, server time on every response, a metric for how wrong the clock is, a flag and a rollback, test and profiling plan, and how a new row type is added without breaking old apps"Here is how I know the countdown is right in production, and what I do if it is not."

Trade-offs in one table, for the last two minutes of the round:

DecisionI choseInsteadI switch when
Row modelSealed interfaceFlat class with nullable fieldsNever for a real app. Only for a prototype
Text of the rowsFacts from the server, words on the phoneText from the serverThe server must own the wording for many languages and A/B tests
Offer sourceSame feed, server orderOwn endpoint merged by the repositoryThe offer has its own refresh or rules
Time sourceServer time anchored with elapsedRealtimeDevice clockYou cannot get server time at all
TickerFlow aligned to the shown secondFixed 1 s delaySeveral countdowns: share the clock, or one 250 ms ticker read through derivedStateOf
Where "now" livesA State in the card, read in leavesA StateFlow in the ViewModelOther logic (not only drawing) needs the time
Friend request actionOptimistic with an overlayWait with a spinnerThe action costs money or cannot be undone
DatabaseNoneRoomOffline reading, or a very long list
PagingOne pagePaging 3The list passes a few hundred rows

Staff answer: the decision and the rollout

  • Decision: one ServerClock and one ticker module for the app. Every countdown and every "ago" label uses it.
  • Alternative: each screen owns a timer. It ships faster, but every screen has its own drift and its own battery cost.
  • Revisit trigger: a screen that needs sub-second updates, or a platform rule that limits wake-ups.
  • Rollout: the new card behind a remote flag, then a staged release. Compare jank, crash-free sessions and rejected-as-ended on matched groups.
  • Guardrail: none of those may get worse than with the static card.
  • Rollback: turn the flag off.

Common weak answers. A timer that counts down a number. A while (true) loop in each card. Reading the tick in the card body. Storing "isExpired" in the model. No lifecycle check, so the timer runs in the background. A server sentence for "12 minutes ago". A second tap that sends a second request. They are not data from a study.

Test yourself

In short: Ten questions from this case. Answer each one out loud first, then open the answer.

1. Why a sealed interface and not one class with a type field?

Each row has only the fields that are valid for it. An offer cannot exist without a deadline. A when over the sealed type must cover every row type, so the compiler tells you when you add a type.

2. Why is there no isExpired field on the offer?

A stored flag is wrong a second after it is written. The model holds the deadline, and "expired" is computed from the deadline and the clock.

3. Why not count a number down in the ViewModel?

A counter counts ticks. If the process is frozen, the screen is off or the app is stopped, ticks are missed and the number is wrong for good. A clock reading is right whenever you look. The timer only says when to look.

4. Which clock do you use, and why?

The server time of the last response plus the time since then, measured with elapsedRealtime(). The wall clock can be set by the user or the network and can jump. elapsedRealtime() counts time since boot, including sleep.

5. The tick changes every second. What recomposes?

Only the scope that reads the State. If the text composable reads it, only that function runs. If the card body reads it, the whole card body runs every second. A read inside a draw lambda repeats only the draw phase. A derived "expired" makes the card run once, when it flips.

6. How does the ticker stop in the background and off screen?

It is collected with collectAsStateWithLifecycle, which collects only while STARTED. A lazy row that is far off screen leaves the composition and its collector goes with it. A plain LaunchedEffect would keep running when the composable is not visible.

7. The user sets the phone clock 3 hours ahead. What does the countdown do?

Nothing. It uses server time plus elapsedRealtime(), and neither follows the wall clock. The "ago" labels use the same clock.

8. The user taps Accept three times, then the call fails. What happens?

The ViewModel ignores taps while the ID is in the in-flight map, so one request goes out. On failure the entry is removed, the server status is still Pending, the buttons come back and a snackbar shows once.

9. What do key and contentType do in items(...)?

key gives a row an identity, so its state and the scroll position follow it when rows move, arrive or leave, and animateItem() can animate it. contentType lets Compose reuse the composition of rows of the same type only.

10. The text says "12 minutes ago". How does it stay right?

The server sends a time. The phone makes the words from now - createdAtMs, using one slow Flow that ticks at each clock minute for the whole list. A label can be up to one minute late, because the tick is not aligned to each row. The rows read that State in a small text composable. If the result is negative, it shows "Just now".

Further reading (official docs)

Words on this page (10)
Recomposition
Compose runs a composable function again because a State it read has changed.
Stable key
An ID for a list row that does not change when the row moves. Compose uses it to keep the state and the scroll position of that row.
Jank
A frame that takes too long to draw, so the screen stutters while it scrolls.
Monotonic clock
A clock that only moves forward. The user cannot change it, so it is good for measuring how much time passed.
Optimistic write
The app shows a change at once, before the server confirms it. If the server says no, the app undoes the change.
Idempotent
Doing it twice has the same result as doing it once. This makes a retry safe.
Process death
The system ends the process of your app to free memory. Anything that is only in memory is lost.
State machine
A small model of a flow. It lists every state the flow can be in and every event that moves it from one state to the next.
ViewModel
A class that holds the state of one screen. It survives a screen rotation.
Repository
The class that reads and writes data for the rest of the app. It hides whether the data comes from the network or the database.

Read the whole glossary

Verification notes (last reviewed 20 Oct 2026)

What I checked in official docs.

  • Compose performance best practices, "Defer reads as long as possible": a lambda offset lets Compose skip composition, and a read in drawBehind lets it skip composition and layout. "Use lazy layout keys": a stable key avoids unnecessary recomposition. It does not mention graphicsLayer.
  • Lists in Compose: a stable key keeps item state across data changes. contentType lets Compose reuse compositions only between items of the same type. animateItem animates additions, removals and moves. The page does not say where the scroll goes when a row is inserted above the first visible one.
  • Compose Foundation release notes: animateItem() replaces animateItemPlacement() and arrived in 1.7.0.
  • Strong skipping: on by default since Kotlin 2.0.20. Unstable parameters are compared by instance, stable ones with equals. Lambdas are remembered automatically.
  • Side-effects: a LaunchedEffect coroutine is cancelled when it leaves the composition. derivedStateOf is for inputs that change more often than the output.
  • Coroutines in Android architecture: collectAsStateWithLifecycle collects from STARTED and stops at STOPPED. WhileSubscribed(5000) keeps the subscription for five seconds to survive a configuration change. The same page notes that a LaunchedEffect can run when its composable is not visible.
  • SystemClock reference: elapsedRealtime() is milliseconds since boot including sleep. uptimeMillis() does not count deep sleep. The wall clock can be set by the user or the network and may jump.
  • Compose testing: MainTestClock has autoAdvance and advanceTimeBy. Delayed LaunchedEffect blocks resume at their scheduled time. performScrollToKey and onNodeWithTag exist.
  • Semantics: live regions tell accessibility services about changes, and the page says they should not be used for content that updates often, such as countdown timers.
  • Arrangement.spacedBy: a negative space makes children overlap (the page shows "Added in 1.10.0" for these overloads, so check older versions).
  • Stability: a List parameter is always unstable to the compiler. @Immutable and @Stable are promises you make. ImmutableList is treated as immutable.
  • ListFormatter is available from API level 26. DateUtils.getRelativeTimeSpanString formats past times as "42 minutes ago" and future times as "In 42 minutes".
  • kotlinx.serialization: polymorphicDefaultDeserializer(baseClass) { className -> DeserializationStrategy? } handles unknown type names. I ran it in the tests.
  • My design, not from docs: the client structure, the anchored clock, the ticker that wakes at the shown second, the minute ticker, where each piece lives, the in-flight overlay, the order of refresh and respond, and the level text.
  • What I ran myself: 27 tests on Compose 1.7.3 on the desktop JVM (Kotlin 2.0.21). They check the ticker timings, clock jumps, lifecycle stop and start, a card scrolled off screen, the number of compositions per tick, a draw-only read, avatar overlap, rows inserted above the first visible one, the view model rules, and an unknown row type.
  • The numbers in the tables (3400 ms ticks, 2 days 2 hours 43 minutes 5 seconds) are from those tests or from the prompt.
  • The follow-up questions are my reconstruction of challenges that public prep material reports. They are not transcripts of real rounds.

Keep reading

Get the free one-page cheat sheet

One page: the five steps, a 45-minute time plan and two checklists. Print it or keep it on your phone.

We email you the cheat sheet after you confirm your address. You can also open it now from the Start here page. You also get a short email each week with a follow-up round, and a note when a new case is published. Unsubscribe any time. Privacy.