This is the build material behind the live stock ticker interview case. It covers the money type, wire formats (the shape of the messages), stream and subscription code, sequence numbers and charts. It also covers stale and closed states, alerts, accessibility, platform limits and the iOS version. None of it belongs in the 45 minutes. I draw it only if asked.
The Kotlin snippets use plain coroutines and StateFlow. The Swift snippet uses an actor and AsyncStream. Both are sketches. Event, QuoteSocket and MarketState are app-defined. The snippets leave out how the repository, the ViewModel and the dispatchers are passed in.
Terms used here: a tick is one price update. A snapshot is the current prices for some symbols, fetched in one request. A gap is one or more missing sequence numbers. A resync is a new snapshot that repairs the data. Stale means old and maybe wrong.
1. Money is a decimal
Never use Double or Float for a price. Binary floating point cannot store most decimal fractions exactly, so sums and comparisons drift.
On Android, use BigDecimal. It stores an integer value plus a scale (the number of digits after the decimal point). It is java.math.BigDecimal, which Android also ships. On iOS, use Decimal, a base-10 number type. On the wire, send prices as strings ("187.42"), so JSON number parsing never turns them into floats.
One trap: BigDecimal equality includes scale, so 1.0 and 1.00 are not equals. Compare with compareTo, and make the scale the same when you parse the value.
data class Quote(
val symbol: String,
val price: BigDecimal,
val seq: Long, // per-symbol sequence from the server
val serverTime: Instant, // when the exchange or feed produced it
val marketState: MarketState // OPEN, CLOSED, PRE, POST, HALTED
)
serverTime drives the "last updated" label. The server assigns seq for each symbol. In this design it is contiguous (it has no missing numbers), so the client can find a gap by counting. It is a promise from my gateway to each client. An exchange feed does not give you this.
Chat's seq is different. Gaps are allowed there, and a cursor sync (a request for everything after a saved position) repairs them. The analytics page uses a client-side counter with the same name. See the chat case study.
2. REST and WebSocket messages
GET /v1/quotes?symbols=AAPL,MSFT → [{ symbol, price, seq, serverTime, marketState }] (snapshot)
GET /v1/symbols/{s}/history?range=1D&points=300 → { open, points: [[t, price], ...] }
PUT /v1/watchlist/{symbol} (idempotent)
POST /v1/alerts { symbol, condition, price, clientId } (idempotent on clientId)
client → server: subscribe { symbols }, unsubscribe { symbols }
server → client: snapshot { symbol, price, seq, ts }
tick { symbol, price, seq, ts }
market { exchange, state }
The history call asks for a point count. The server then reduces the data, and the client never receives more points than pixels.
3. The stream: apply every tick, render the latest
A busy symbol can tick many times in one frame (about 16 ms at 60 Hz). Rendering every tick wastes CPU and battery, because nobody can read the middle values. The rule is: process every tick, render the latest.
On Android, the pipeline works in four steps. The app parses socket frames on a background dispatcher. It applies them to an in-memory map. It exposes the map as a StateFlow. The kotlinx.coroutines docs state that a StateFlow always conflates updates. Conflation means that a slow collector skips middle values and gets the most recent one. The StateFlow also ignores equal consecutive values, using equals.
For a plain Flow, conflate() does the same thing. A slow collector never suspends the emitter, and the collector always gets the most recent value.
The sample(period) operator emits at most the latest value for each period. It is marked @FlowPreview. Its docs say the latest element is not sent if it does not fit into the sampling window. For prices, this matters. The last value of a burst can be dropped, and a row can show an old number.
// Sketch: applying ticks. Single collector coroutine, so book.value and resyncing are only touched from it.
class QuoteStream(private val socket: QuoteSocket) {
private val book = MutableStateFlow<Map<String, Quote>>(emptyMap())
val quotes: StateFlow<Map<String, Quote>> = book.asStateFlow()
private val resyncing = HashSet<String>() // symbols waiting for a snapshot
suspend fun run() = socket.events().collect { e ->
when (e) {
is Event.Snapshot -> {
resyncing.remove(e.quote.symbol)
book.update { it + (e.quote.symbol to e.quote) }
}
is Event.Tick -> onTick(e)
}
}
private fun onTick(t: Event.Tick) {
val cur = book.value[t.symbol]
when {
t.symbol in resyncing -> Unit // waiting for the snapshot
cur != null && t.seq <= cur.seq -> Unit // duplicate or old
cur == null || t.seq != cur.seq + 1 -> { // no snapshot yet, or a gap
resyncing += t.symbol
socket.requestSnapshot(t.symbol) // asked once per gap
}
else -> book.update { it + (t.symbol to cur.copy(price = t.price, seq = t.seq, serverTime = t.ts)) }
}
}
}
The resyncing set makes step 3 in section 5 true. Without it, every later tick of a symbol with a gap also fails the seq check. Each one asks for another snapshot, so one gap becomes many requests.
A snapshot replaces the entry without any check. A reconnect needs this. But a late snapshot can move seq backwards, so the server should send a snapshot only when the client asks for one.
The sketch has one limit. If a requested snapshot never arrives, the symbol stays in resyncing and ignores ticks. A reconnect, or a timeout on the request, has to clear the set. I left that out.
The ViewModel gives the UI one immutable uiState. It builds the state from the repository's flows with stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), initial). The pipeline then stops shortly after the screen stops collecting.
The data layer owns the socket loop. It runs QuoteStream.run() in an injected external CoroutineScope, which starts on the first subscriber. It does not use viewModelScope or WorkManager, because nothing here needs to survive the death of the process.
In Compose, collect with collectAsStateWithLifecycle(). By default it starts collecting at STARTED and stops at STOPPED, so the UI stops watching in the background. Compose applies state changes once per frame, so extra writes between frames should cost little. That is my reading, not a measurement, so I would profile before I rely on it.
If a list still redraws too often, give each row only the value for its own symbol. Then one tick redraws one row. Avoid .sample(100) on prices, for the reason above. To cap the rate, prefer conflate() plus a short delay in the collector. That is my suggestion, not a documented method. Keep the age label so a stale row is visible. The delay is a setting to tune.
Replacing the whole map on every tick creates new objects each time. That is fine for tens of symbols. Say that you chose the simple form.
4. Subscriptions: only what is visible
A socket that subscribes to everything wastes data and CPU. Subscribe to the union of two sets: the symbols on the visible screen, and the watchlist if it is on screen. Use a reference count per symbol (a ref count: how many screens use it). Then two screens that show AAPL cause one subscribe. The unsubscribe happens only when both screens are gone.
// Sketch: ref-counted subscriptions, a data layer class; screens reach it only through the ViewModel and repository
class Subscriptions(private val socket: QuoteSocket) {
private val refs = mutableMapOf<String, Int>()
@Synchronized fun acquire(symbol: String) {
val n = (refs[symbol] ?: 0) + 1
refs[symbol] = n
if (n == 1) socket.subscribe(symbol)
}
@Synchronized fun release(symbol: String) {
val n = (refs[symbol] ?: return) - 1
if (n == 0) { refs.remove(symbol); socket.unsubscribe(symbol) } else refs[symbol] = n
}
@Synchronized fun active(): Set<String> = refs.keys.toSet() // for re-subscribing after a reconnect
}
Tie acquire and release to visibility, not to the creation of objects. On Android, the screen calls ViewModel methods (for example onSymbolsVisible) from a DisposableEffect or a lifecycle effect. The ViewModel calls the repository, and never calls Subscriptions directly. On iOS, use onAppear and onDisappear.
Delay the release a little, so quick back-and-forth navigation does not overload the server. After a reconnect, send the whole current set again. active() returns that set. The sketch calls the socket while it holds the lock. That is fine at this size. If the socket calls could block, I would move them out of the lock.
5. Sequence numbers, gaps, resync and a dead link
A reconnect loses whatever was in flight. Each symbol has a seq that increases by one for each update:
- A tick with
seq <= lastis a duplicate or a late packet. Drop it. - A tick with
seq == last + 1is applied. - A tick with
seq > last + 1is a gap. Do not apply it. Request a snapshot for that symbol, once. Ignore ticks for that symbol until the snapshot arrives. - On reconnect, subscribe again. The server replies with a snapshot for each symbol, and the client resets
seqfrom it.
The client never replays history. A snapshot is cheap and repairs the data by itself. Reconnect with exponential backoff and jitter. Backoff means to wait longer after each failure. Jitter means a small random change to the wait time. Together they stop many clients from reconnecting at the same moment after a server restart. The chat case study gives an example schedule (1 s up to about 30 s, randomized).
For a plain last-price display, a missed tick does no harm, because the next tick carries the whole truth.
The gap check matters when the stream carries state that is not complete in each message. The live candle (the current bar of the chart) is an example. Its high, low and volume are built from ticks. A missed tick can leave them wrong until the next snapshot. State which case you have.
An open socket is not always a live one. A connection can stay open while nothing arrives, for example behind a weak network or a proxy. Send a ping at a fixed interval. Treat a missing pong (the reply) as a dead connection. Rebuild it, send the subscriptions again and take snapshots.
OkHttp has a pingInterval setting for this. URLSessionWebSocketTask has sendPing. I did not check how OkHttp reports a missed pong, so test that on your version. Android's network-state guide says a captive-portal network (a login page before internet access) has the internet capability but is not validated. So a connectivity callback alone does not prove that the stream is alive.
A long-lived socket also outlives its auth token. Refresh the token and sign in again without dropping the subscription set. Let the server tag each quote as real-time or delayed (see follow-up 4 on the interview page).
6. Charts, stale data, time zones, derived values
Charts. Fetch the history for the selected range, already reduced by the server (a 1Y daily chart needs a few hundred points). Add live ticks to the last candle in memory. Do not fetch the history again.
On Android, draw with the Compose Canvas composable, which wraps Modifier.drawBehind. Build the Path once and cache it with Modifier.drawWithCache. It keeps objects until the size or the state it reads changes. Reduce the points and do the point math off the main thread.
On iOS, Swift Charts (iOS 16 and later) with LineMark covers a standard line chart. For thousands of points or a custom crosshair, draw with Core Graphics or a SwiftUI Canvas.
Stale data. Keep two clocks apart. The connection is live while heartbeats keep arriving. It is stale when they stop, or when the prices came from the cache. Each row also has its own age, shown next to the price ("Updated 14:03:21" or "2 min ago").
A quiet symbol on a healthy connection is old, not stale. A symbol with little trading can go minutes without a tick. Graying it would tell the user that the app is broken. When the connection is stale, gray every price.
Closed comes from the market state. A closed market is not an error. Show the close price and "Market closed", and stop expecting ticks.
Time zones. Exchanges trade in their own time zones. Store times in UTC. Format them in the device time zone for "last updated", and in the exchange time zone for "opens 9:30 ET". Never write fixed offsets in code, because daylight saving time changes them. Use IANA names such as America/New_York (ZoneId on Android, TimeZone on iOS).
Let the server send marketState and the next open time, so holidays and half days are not client logic.
Derived values. Half of a ticker UI is numbers computed from the price.
- The day change is the price minus the previous close.
- The percent is that change divided by the previous close.
I would take the previous close from the server rather than compute it from ticks. It needs a field on the snapshot. The listing in section 2 leaves it out. Compute both values with the decimal type, not a double.
Format numbers for the user's locale, but keep the decimal places the exchange sends. Show direction with an arrow or a sign. Treat halted and auction states as their own display state, not as a frozen live price.
Always-online stance. When the device is offline, show the cached watchlist from the last snapshot. Mark every row stale with its age, and show a banner. Turn off alert creation, because the server holds the rules. Nothing is queued, so creating an alert fails at once instead of waiting.
The app saves a watchlist edit locally first, then syncs it with a plain PUT. It fetches quotes as snapshots when needed. Live ticks stream in only while the screen is visible.
7. Lifecycle, alerts and accessibility
Backgrounding. When the app goes to the background, close the socket and clear the subscriptions. When it returns, reconnect, subscribe again and take snapshots. Do not try to replay missed ticks.
Price alerts. The server checks alert rules against the feed, so alerts fire when the app is closed. When a rule triggers, the server sends a push notification. Treat the push as a hint plus a short text ("AAPL crossed 190.00"). Tapping it opens the symbol, which loads a fresh snapshot. APNs limits payloads to 4 KB for most notifications, so send identifiers, not data.
Creating the alert is a POST. If it fails for a temporary reason, retry with the same clientId. Temporary reasons are an I/O error, 408, 429 (respect Retry-After when present) or 5xx. Treat other 4xx errors as permanent. The push SDK case study covers the alert delivery path in more depth.
Accessibility. Do not announce ticks. Android's accessibility guidance says live regions (areas that the screen reader speaks aloud) should not be used on content that updates often, such as countdown timers. This avoids overwhelming users with feedback.
- Leave the price as plain readable text.
- Use a polite live region only for rare state changes such as "Market closed" or "Alert triggered".
- On iOS, the
updatesFrequentlytrait tells assistive technology to poll for changes instead of waiting for notifications. - Show movement with a sign or an arrow, not with color alone.
8. Platform limits that shaped the design
Each fact in this table is used elsewhere in this guide, or was read on the official page linked at the end of the guide.
| Limit | Android | iOS | So the design changes by |
|---|---|---|---|
| Socket in the background | Doze suspends network access; foreground services are for immediate work the user can see, and most cannot start from the background | Close the socket when the scene leaves .active; I did not confirm from an Apple page whether a WebSocket task can run in a background session | No socket in the background; alerts go by push notification, and the return to the foreground is reconnect, subscribe again, snapshot |
| Reconnecting | OkHttp has pingInterval; how it reports a missed pong is unchecked | Apple documents no reconnect for URLSessionWebSocketTask; it has sendPing | I own the rebuild, the new subscribe and the snapshot on both platforms |
| Alert push notification | FCM alert behavior is unverified here | APNs limits most payloads to 4 KB | The push carries identifiers and a short text; the symbol screen loads a fresh snapshot |
| Fast-changing text | Live regions are not meant for content that updates often | updatesFrequently tells assistive technology to poll | Prices stay plain text; only rare state changes are announced |
9. Doing it on iOS (Swift)
The design is the same. Only the tools change.
| Concern | Android | iOS |
|---|---|---|
| Socket | OkHttp WebSocket | URLSessionWebSocketTask |
| Conflation | StateFlow, conflate(), sample() | AsyncStream with .bufferingNewest(1) |
| Money | BigDecimal | Decimal |
| Lifecycle | collectAsStateWithLifecycle, DisposableEffect | scenePhase, onAppear / onDisappear |
| Charts | Compose Canvas | Swift Charts LineMark, or Core Graphics |
| Push | FCM | APNs |
| Fast-changing values and accessibility | Avoid liveRegion on ticks | updatesFrequently trait |
What is really different:
- Conflation is an explicit buffering choice.
AsyncStreambuffers without limit by default. A fast feed with a slow consumer then uses more and more memory. Choose.bufferingNewest(1). When the buffer is full, it discards the oldest element, so the consumer sees the latest state. - Apple documents no reconnect behavior for
URLSessionWebSocketTask. Treat a failedreceiveas the end of the task. Then rebuild it and subscribe again yourself. It supports ping (sendPing) for liveness checks andmaximumMessageSizeas a safety limit. Apple's docs say the receive API returns a message once all of its frames are available. - Backgrounding follows
scenePhase. Close the socket withcancel(with:reason:)when the phase leaves.active. The.backgroundphase means the scene is not visible. - Money.
Decimalis a base-10 type. Create it from a string, not from aDouble. Otherwise you bring in the binary error you wanted to avoid. This is also why the wire format uses strings:JSONDecoderreads a JSON number through aDouble. I have not verified either point against Apple's docs. If you useDecimal(string:), passLocale(identifier: "en_US_POSIX"), since some overloads depend on the locale (also unverified).
// Sketch: sequence-checked book exposed as a conflated stream.
// AsyncStream.makeStream is listed on Apple's page for iOS 13+ (back-deployed before iOS 17).
struct Quote: Sendable { let symbol: String; var price: Decimal; var seq: Int; var ts: Date }
actor QuoteBook {
let updates: AsyncStream<[String: Quote]>
private let out: AsyncStream<[String: Quote]>.Continuation
private var book: [String: Quote] = [:]
private var resyncing = Set<String>() // symbols waiting for a snapshot
init() {
let pair = AsyncStream.makeStream(of: [String: Quote].self, bufferingPolicy: .bufferingNewest(1))
updates = pair.stream
out = pair.continuation
}
func snapshot(_ q: Quote) { resyncing.remove(q.symbol); book[q.symbol] = q; out.yield(book) }
/// Returns true to request a snapshot: once per gap, or when there is no snapshot yet.
/// Ticks for a symbol that is waiting for its snapshot are ignored.
func tick(_ q: Quote) -> Bool {
if resyncing.contains(q.symbol) { return false }
let cur = book[q.symbol]
if let cur, q.seq <= cur.seq { return false } // duplicate or old
guard let cur, q.seq == cur.seq + 1 else { // no snapshot yet, or a gap
resyncing.insert(q.symbol)
return true
}
book[q.symbol] = q
out.yield(book)
return false
}
}
// Consumer, e.g. in a @MainActor view model. The sleep caps the render rate.
// for await latest in quoteBook.updates { self.quotes = latest; try? await Task.sleep(for: .milliseconds(100)) }
The actor keeps the same resyncing set as the Kotlin version. tick returns true only when the caller should send requestSnapshot for that symbol. That happens once for each gap. Every later tick of that symbol returns false until snapshot clears the set. The same limit applies if a snapshot is lost.
10. More practice prompts
- How do you test this? (A fake socket that sends ordered, duplicate, late and gapped sequences; a fake clock for stale changes.)
- How would you add a home screen widget? (Widgets do not hold a socket; use snapshot timelines. Check the platform refresh limits before you promise a frequency.)
- What would make you add a news feed to the same screen? (See the news feed case study for the cache-first contrast with an always-online screen.)
Further reading (official docs)
- The interview part of this case: scope, decisions, failures, follow-ups and levels
- Flow.conflate (kotlinx.coroutines)
- Flow.sample (kotlinx.coroutines)
- StateFlow (kotlinx.coroutines)
- Architecture recommendations (Android)
- Producing UI state (Android)
- Use Kotlin coroutines with lifecycle-aware components (Android)
- Graphics modifiers in Compose: drawBehind and drawWithCache (Android)
- Semantics in Compose, including live regions (Android)
- Reading network state (Android)
- Doze and App Standby (Android)
- Foreground service restrictions on background starts (Android)
- BigDecimal (Java SE 17 API, Oracle)
- AsyncStream (Swift)
- AsyncStream.makeStream(of:bufferingPolicy:) (Swift)
- AsyncStream.Continuation.BufferingPolicy (Swift)
- URLSessionWebSocketTask (Apple)
- ScenePhase (SwiftUI)
- Swift Charts (Apple)
- AccessibilityTraits.updatesFrequently (SwiftUI)
- TimeZone (Foundation)
- Generating a remote notification (Apple)
Verification notes (last reviewed 13 Oct 2026)
this is the build part of the case. It moved here from the single live-ticker page on 2026-10-14, and the interview page now carries the decisions. The six code blocks (Quote, the REST listing, the WebSocket messages, QuoteStream, Subscriptions and the Swift QuoteBook) are byte-identical to the audited page. I compared them on 2026-10-14. Their earlier checks still apply and I did not repeat them. All three Kotlin snippets compiled offline with Gradle (compileDebugKotlin), with Kotlin 2.3.21, AGP 9.1.0, compileSdk 36 and kotlinx.coroutines 1.10.2. I stubbed only the app-defined Event, QuoteSocket and MarketState. I type-checked the Swift sketch with swiftc -typecheck (Swift 6.4, iOS 27 simulator SDK, language modes 5 and 6). Compiling does not run the code. The "asked once per gap" behavior comes from reading the code, not from a test. Verified by fetch on 2026-10-13: kotlinx.coroutines conflate, sample (@FlowPreview, and its note that the latest element may not be sent) and StateFlow conflation. Android collectAsStateWithLifecycle, Canvas, drawBehind, drawWithCache and the live-region warning. Java SE 17 BigDecimal. Apple's pages for AsyncStream and BufferingPolicy, URLSessionWebSocketTask (silent on reconnect), ScenePhase, Swift Charts (iOS 16+), updatesFrequently, TimeZone and the 4 KB APNs payload. The availability of AsyncStream.makeStream(of:bufferingPolicy:) (iOS 13+, back-deployed before iOS 17). Read on 2026-10-14: Android's reading-network-state page (a captive-portal network can have the internet capability without the validated one; callbacks are preferred to polling). The foreground-service background-start page (most background starts are barred; a high-priority FCM message is one exemption). The WorkManager page (coroutines stop when the app closes). Changed in the prose on 2026-10-14, with the code untouched: "stale" now depends on the heartbeat of the connection and on cached prices, while a quiet symbol is only old (this is my reasoning, not a documented rule). The paragraph on when a sequence gap matters now names the live candle. seq is described as a gateway promise, not a property of exchange feeds. The platform-limits table in section 8 is new and restates the facts above.sleep(for:) availability (iOS 16+). All numbers in code and prose (50 symbols, 300 points, 100 ms, 2,000 symbols) are examples. No iOS engineer has reviewed the iOS sections yet. Review pass on 2026-10-14: I compared the six code blocks with the audited old page again, and they are byte-identical. Only the prose changed. Long paragraphs were split. The derived-values paragraph now says the previous close needs a field that the section 2 snapshot listing leaves out (the listing is unchanged code). The network-state sentence now follows the page (a captive-portal network has the internet capability but is not validated). The live-region sentence now follows the Compose semantics page (not for content that updates often, such as countdown timers). I fetched these pages again and confirmed them the same day: StateFlow conflation and equals-based suppression, sample's note about the latest element and its @FlowPreview mark, the AsyncStream default buffer (unbounded), URLSessionWebSocketTask (ping method present, silent on reconnect and background sessions), and the reading-network-state, Doze and foreground-service restriction pages.A plain-English pass on 2026-10-14 changed the wording only. It did not change any fact or any code.
Questions or corrections?
Add them to the discussion thread for this case on GitHub. To post, you need a free GitHub account. What you write there is public and follows GitHub's terms. Good answers are added to this page.
Keep reading
Get the next case study when it is published.
You get an email when a new case study is published, and nothing else.
You can unsubscribe at any time. Privacy.