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 see | What it means for the design | Status |
|---|---|---|
| A vertical list of rows | A scrolling list that can be long, so it must be lazy | Seen |
| Most rows: avatars, text, "12 minutes ago" | One row layout. A timestamp that gets old while the screen is open | Seen. The refresh rate is my reasoning |
| Several avatars overlap | A layout for one to many people, with a limit | Seen. 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 second | Seen |
| The card is inside the same scroll | It is a list item, not a header | Seen. Its position is unknown |
| A friend request with Accept and Decline | Actions that call the server and can fail | Seen. 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.
| Group | Requirement | Source |
|---|---|---|
| Functional | Show photo activity, one offer and friend requests in one list | Screen |
| Functional | Accept or Decline a friend request | Screen |
| Functional | Open a row: album, order flow, profile | Assumption |
| Functional | Pull to refresh, and a retry after a failed load | Assumption |
| UI | Overlapping avatars for one to many people, with a "+N" for the rest | Screen, limit is mine |
| UI | Text that depends on the action and on how many people | Screen |
| UI | Buttons that cannot be tapped twice, and a way to show a failure | Assumption |
| Performance | Smooth scrolling with many images | Assumption |
| Performance | A tick every second must not redo work for the whole list | My design goal |
| Time | The countdown changes every second | Screen |
| Time | "12 minutes ago" is old after a few minutes on the screen | Reasoned from the screen |
| Time | The offer can end while the user is looking at it | Reasoned from the screen |
Unknown things I would ask about, each with the default I take if the interviewer says "your call":
| Question | My 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 scope | Out of scope | Parked until you ask |
|---|---|---|
| Three row types in one list | How push delivers a notification | Paging and a local database |
| The countdown and the clock behind it | Notification settings | Read and unread state |
| Accept and Decline with failures | Deep link details | Grouping rows on the phone |
| Loading, error and refresh | The order flow itself | More 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:
| Option | Good | Bad | I pick it when |
|---|---|---|---|
One flat class with a type field and nullable fields | Easy JSON | An offer without a deadline can exist. Nulls everywhere. The compiler does not check when | It is a throwaway prototype |
| An interface with shared fields (title, body) | One row composable | The rows are not the same. The UI must know the type anyway, so the abstraction hides nothing | All rows really look alike |
| A sealed interface with three subtypes | Each type has only its own fields. when must cover every type | Adding a type touches every when | This case |
| Separate lists per type, merged in the UI | Each source is simple | The order rule moves into the UI. Pagination of a mixed list gets hard | The offer comes from another endpoint. Then I merge in the repository, not in the UI |
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)
| Type | Fields | Choice and why |
|---|---|---|
| Photo activity | actors (first three), actorCount, action, photoCount, albumTitle, fallbackText | The 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 offer | title, body, imageUrl, ctaLabel, expiresAtMs | Only the deadline. "Live" or "ended" comes from the clock |
| Friend request | from, 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:
idis a string and unique across all types, for examplepa_881,offer_12,fr_40. If the server only makes IDs unique per type, the key istype: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)
| Call | Gives or does | Notes |
|---|---|---|
GET /v1/notifications?limit=30 | serverTimeMs and items in display order | The server time on every response keeps the clock right (section 4.2) |
POST /v1/friend-requests/{id}/accept | New status | It is idempotent: repeating it gives the same result |
POST /v1/friend-requests/{id}/decline | New status | Same |
| 404 or 410 on those calls | The request is gone | The 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 offer | Opens the order flow with the offer ID | The 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)
| Piece | It owns | Why it exists |
|---|---|---|
| Screen and rows | Nothing but drawing. Takes state and callbacks | Easy to preview and test. It survives a rotation because the state is not in it |
| ViewModel | uiState, the in-flight map, one pending message | Survives rotation. It is the place for "the user did X and the server has not answered" |
| Repository | The feed, refresh, respond, DTO mapping, clock sync, the order of calls | This code has no business in a composable. It needs to be tested without a screen |
| ServerClock | The best guess of the server time | Two 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)
| Thing | Inside the LazyColumn | Outside |
|---|---|---|
| Photo activity, offer, friend request | Yes. They scroll together, in server order | |
| Loading, first-load error | They replace the list | |
| Pull to refresh | A container around the list | |
| Snackbar for failures | The Scaffold | |
| The clock | Lives 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.contentTypelets 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. AList<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 withequals. I wantequals, 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
| Event | What happens | My rule |
|---|---|---|
| Pull to refresh brings new rows at the top | The list follows the row you were looking at, so new rows appear above the screen | If 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 order | Same key, so the row moves with animateItem() | Nothing to write |
| A row disappears (declined, withdrawn) | It fades out. The rows below move up | Nothing to write |
| Two refreshes overlap | The older answer could arrive last and win | One refresh at a time. A refresh and an Accept take turns (section 4.4) |
| An item type the app does not know | It is skipped in the repository | The 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)
| Unknown | Why it matters | My default |
|---|---|---|
| Does the backend send the deadline? | If not, the phone invents one and it is wrong | Yes: expiresAtMs and serverTimeMs |
| Must it stay right after background or a rotation? | A counter that restarts loses time | Always computed from a clock, never counted |
| Can the device clock change? | Users do it, and so does the network | Use 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 off | Yes. The order call is checked again |
| Several countdowns? | Decides if I share one ticker | One now |
| Recomposition, battery, or both? | Decides how much I tune | Both. 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:
| Approach | Problem | Verdict |
|---|---|---|
LaunchedEffect(Unit) { while (true) { delay(1000); now = ... } } in each card | It 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 visible | No |
CountDownTimer or a handler that counts down a number | CountDownTimer 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 good | No |
A Flow that wakes near each second change and emits a clock reading, collected with the lifecycle | Needs a clock object | Yes |
| The same Flow, shared by the whole app | More code | When two or more countdowns or the "ago" labels exist |
A frame loop (withFrameNanos) | About 60 updates a second for text that changes once a second | Only 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 time | Seconds shown |
|---|---|
| 0 ms | 4 |
| 400 ms | 3 |
| 1400 ms | 2 |
| 2400 ms | 1 |
| 3400 ms | 0, 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.
collectAsStateWithLifecyclecollects 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)
| Piece | Lives in | Why there |
|---|---|---|
| The deadline | The AlbumOffer from the server | It is data. It does not change each second |
| The estimate of server time | ServerClock, one per app | It must outlive screens, and it needs no UI |
| The "now" for display | A State<Long> made in the card from the Flow | It 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" labels | One slow Flow (each minute), made once for the list, passed as State | Same 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.
| Where the tick is read | What runs each second | Use it for |
|---|---|---|
In the card body (val now by ...) | The card body again. The test showed one more composition of the image per tick | Nothing. This is the trap |
In CountdownText (it takes a State<Long> and reads .value) | That function, then measure and draw of its Text | The text |
In derivedStateOf { now >= deadline }, read by the card | The card, once, when the value flips | Expired or not. Also "less than one hour left" styling |
In a drawBehind { } lambda | Draw only | A 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)
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.
| Failure | User sees | Data holds | Recovery |
|---|---|---|---|
| Device clock moved 3 hours ahead | The countdown does not change | The anchor from the last response | None needed |
| App stopped for two days, then opened | The right time left at once, and a refresh | Nothing old is trusted | The 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 death | The list loads again | Nothing | Cold 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 jump | No anchor yet | The first response anchors it. The server still checks the order |
| The offer ends while the user looks | The card turns into "This offer has ended" | The same offer, now past its deadline | If a tap gets through, the server says ended, and the app refreshes |
| Offer already ended when it arrives | The ended card, or no card | The deadline | The repository may drop it. The card handles it anyway |
| Accept tapped twice | One request | One in-flight entry | The second tap is ignored |
| Accept fails (offline, 5xx) | The buttons come back and a message | In-flight entry removed | The user taps again |
| Accept after the request was withdrawn (404, 410) | The row leaves and a message | Row removed | None |
| Accept times out, or the server says 409 | "Could not confirm", then the real state | The server decides | Refresh. Do not remove the row on a guess |
| Token expires during Accept | Nothing, or the sign-in screen | Overlay until the retry ends | Refresh the token, retry the same call once. If it fails, clear the overlay and sign in |
| App killed right after the tap | The truth after the next load | Nothing, the overlay was in memory | The server has either seen the tap or not |
| An older refresh arrives last | The newest data stays | The newest response | One refresh at a time. Refresh and respond take turns |
| A newer server sends a row type the app does not know | The row is not shown | Nothing | The repository skips it |
| Avatar fails to load, a name is very long, font scale is 200 percent | A placeholder, wrapped text, taller rows | None | No fixed heights on text rows |
5.1 Tests that matter
| Area | Kind | What I assert |
|---|---|---|
| Countdown maths | Unit | 2 days 2 h 43 min 5 s splits right. 0.4 s left shows 1 second. A past deadline shows 0 |
| The clock | Unit | A wall clock moved back one hour does not move the server time. A second response re-anchors |
| The ticker | Unit, virtual time | It 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 |
| Lifecycle | Compose UI | When the lifecycle is stopped, the fake clock gets no reads. When it starts, the text shows the current value |
| Photo activity, many people | Unit and Compose UI | The second avatar overlaps the first. Not tested in my run: the text for 1, 2 and 5 people, and the "+2" text |
| Friend request actions | Unit (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 calls | Unit | A slow refresh with "Pending" cannot overwrite a later Accept |
| Feed updates and identity | Unit and Compose UI | An 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 |
| Recomposition | Compose UI | A 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 |
| Performance | Manual | Layout 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.
- 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 throughderivedStateOf. Weak: "Add another timer." - 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."
- 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." - 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
Ageheader, 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." - 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."
- 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."
- 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."
- 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.
| Level | The answer covers | I want to hear |
|---|---|---|
| Mid-level | A 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." |
| Senior | A 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." |
| Staff | A 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:
| Decision | I chose | Instead | I switch when |
|---|---|---|---|
| Row model | Sealed interface | Flat class with nullable fields | Never for a real app. Only for a prototype |
| Text of the rows | Facts from the server, words on the phone | Text from the server | The server must own the wording for many languages and A/B tests |
| Offer source | Same feed, server order | Own endpoint merged by the repository | The offer has its own refresh or rules |
| Time source | Server time anchored with elapsedRealtime | Device clock | You cannot get server time at all |
| Ticker | Flow aligned to the shown second | Fixed 1 s delay | Several countdowns: share the clock, or one 250 ms ticker read through derivedStateOf |
| Where "now" lives | A State in the card, read in leaves | A StateFlow in the ViewModel | Other logic (not only drawing) needs the time |
| Friend request action | Optimistic with an overlay | Wait with a spinner | The action costs money or cannot be undone |
| Database | None | Room | Offline reading, or a very long list |
| Paging | One page | Paging 3 | The list passes a few hundred rows |
Staff answer: the decision and the rollout
- Decision: one
ServerClockand 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)
- Build it: a notifications screen in Jetpack Compose: the Kotlin code and tests for this case
- Compose performance best practices: defer reads, lazy layout keys
- Lists and grids in Compose: keys,
contentType,animateItem - Strong skipping mode
- Side-effects in Compose:
LaunchedEffect,derivedStateOf - Collect flows safely in the UI:
collectAsStateWithLifecycle,WhileSubscribed(5000) - SystemClock:
elapsedRealtime,uptimeMillis - Test time in Compose and MainTestClock
- Semantics and live regions
- Arrangement.spacedBy: a negative space makes children overlap
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.
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
offsetlets Compose skip composition, and a read indrawBehindlets it skip composition and layout. "Use lazy layout keys": a stable key avoids unnecessary recomposition. It does not mentiongraphicsLayer. - Lists in Compose: a stable key keeps item state across data changes.
contentTypelets Compose reuse compositions only between items of the same type.animateItemanimates 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()replacesanimateItemPlacement()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
LaunchedEffectcoroutine is cancelled when it leaves the composition.derivedStateOfis for inputs that change more often than the output. - Coroutines in Android architecture:
collectAsStateWithLifecyclecollects from STARTED and stops at STOPPED.WhileSubscribed(5000)keeps the subscription for five seconds to survive a configuration change. The same page notes that aLaunchedEffectcan run when its composable is not visible. SystemClockreference: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:
MainTestClockhasautoAdvanceandadvanceTimeBy. DelayedLaunchedEffectblocks resume at their scheduled time.performScrollToKeyandonNodeWithTagexist. - 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
Listparameter is always unstable to the compiler.@Immutableand@Stableare promises you make.ImmutableListis treated as immutable. ListFormatteris available from API level 26.DateUtils.getRelativeTimeSpanStringformats 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.