How to read this. Sections 1 to 8 are the platform-neutral design, with Kotlin/Android code. Section 9 shows what changes on iOS. The architecture is identical on both; the platform primitives differ.
TL;DR. The server is the source of truth for prices; the client holds a last-known snapshot and applies deltas on top of it. Subscribe only to symbols on screen or in the watchlist, and release them when the screen goes away. Conflate ticks so the UI renders the latest value, not every value. Number every update per symbol so you can detect a gap and resync with a snapshot. Never use a binary float for money. Always show how old a price is.
1. Clarify the requirements (about 5 min)
Questions worth asking:
- Which assets and how many symbols? (Assume equities, a watchlist of up to about 50 symbols, a universe of thousands for search.)
- How real-time? (Assume real-time for the visible screen, delayed or snapshot data is acceptable elsewhere.)
- Which screens? (Watchlist, symbol detail with a chart, price alerts.)
- Which markets? (Assume several exchanges in different time zones, so "market closed" is a per-symbol fact.)
- Offline? (Always-online product: show last known prices marked stale.)
Functional: search symbols, manage a watchlist, see live prices, open a chart for a range (1D, 1W, 1Y), set a price alert and get a push when it triggers.
Non-functional:
| Concern | Target |
|---|---|
| Render rate | At most one UI update per frame, regardless of tick rate |
| Correctness | Prices are exact decimals; no silent gaps after reconnect |
| Freshness | Every price shows its age; market-closed state is explicit |
| Battery and data | Socket only while visible; subscribe only to what is on screen |
| Cold start | Watchlist rendered from cache, marked stale, then refreshed with a snapshot |
| Accessibility | Screen readers are not flooded by tick updates |
Out of scope: order entry, news, options, order books. Say this out loud.
2. High-level architecture (about 10 min)
Official architecture mapping: Compose screen plus ViewModel form the UI layer (the ViewModel is the state holder);
QuoteRepositoryis the data layer entry point, with the REST client,QuoteStreamsocket, and local DB as data sources; no domain layer (add use cases only if logic is shared). See the Android architecture recommendations.
Two decisions shape the rest. First, live ticks do not go to the database. Writing every tick to disk costs battery and I/O for data that is obsolete in a second. Ticks live in a conflated in-memory map; only the watchlist and the last snapshot per symbol are persisted. Second, the socket exists only while the app is visible: nothing is worth delivering while the user is not looking, and alerts go through push.
3. Data model and API
Money is a decimal
Never use Double or Float for a price. Binary floating point cannot represent most decimal fractions exactly, so sums and comparisons drift. On Android use BigDecimal, which stores an integer unscaled value plus a scale (Oracle's Javadoc, also used by Android). On iOS use Decimal, a base-10 number type. On the wire, send prices as strings ("187.42"), so JSON number parsing never touches a float. One trap: BigDecimal equality includes scale, so 1.0 and 1.00 are not equals. Compare with compareTo, and normalize scale at the parse boundary.
Models
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.
REST
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)
WebSocket messages
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 so the server downsamples and the client never receives more points than pixels.
4. The hard part: stream, conflate, render
A busy symbol can tick many times per frame (about 16 ms at 60 Hz). Rendering per tick wastes CPU and battery, because nobody can read the intermediate values. The rule is: process every tick, render the latest.
Pipeline on Android: socket frames are parsed on a background dispatcher, applied to an in-memory map, and exposed as a StateFlow. The kotlinx.coroutines docs state that a StateFlow always conflates updates, so a slow collector skips intermediate values and gets the most recent one, and that equal consecutive values are suppressed using equals. For a plain Flow, conflate() does the same thing: the emitter is never suspended by a slow collector, and the collector always gets the most recent value. sample(period) emits at most the latest value per period, but it is marked @FlowPreview, and the docs warn the latest element is not emitted if it does not fit the sampling window.
// Sketch: applying ticks. Single collector coroutine, so reads of book.value are consistent.
class QuoteStream(private val socket: QuoteSocket) {
private val book = MutableStateFlow<Map<String, Quote>>(emptyMap())
val quotes: StateFlow<Map<String, Quote>> = book.asStateFlow()
suspend fun run() = socket.events().collect { e ->
when (e) {
is Event.Snapshot -> 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 {
cur != null && t.seq <= cur.seq -> Unit // duplicate or old
cur == null || t.seq != cur.seq + 1 -> socket.requestSnapshot(t.symbol) // gap
else -> book.update { it + (t.symbol to cur.copy(price = t.price, seq = t.seq, serverTime = t.ts)) }
}
}
}
The ViewModel exposes one immutable uiState, built from the repository's flows with stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), initial), so the pipeline stops shortly after the screen stops collecting. The socket loop is app-oriented work: the data layer runs QuoteStream.run() in an injected external CoroutineScope, started on the first subscriber (not in viewModelScope, and not in WorkManager, since nothing needs to survive process death). In Compose, collect with collectAsStateWithLifecycle(), which by default starts collecting at STARTED and stops at STOPPED, so the UI stops observing in the background. Compose then redraws on frame boundaries, so the state write rate no longer matters much. If a list still recomposes too often, hand each row only its own symbol's value so one tick recomposes one row, and optionally add .sample(100) to cap the text update rate. The value is a tuning knob.
Replacing the whole map on every tick allocates. That is fine at tens of symbols; say that you chose the simple form.
5. Subscriptions: only what is visible
A socket subscribed to everything burns data and CPU. Subscribe to the union of (a) symbols on the visible screen and (b) the watchlist if it is on screen. Use a reference count per symbol, so two screens showing AAPL cause one subscribe, and the unsubscribe happens only when both 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
}
}
Tie acquire and release to visibility, not to object creation: on Android the screen calls ViewModel methods (for example onSymbolsVisible) from a DisposableEffect or lifecycle effect, and the ViewModel calls the repository, never Subscriptions directly; on iOS use onAppear and onDisappear. Delay the release briefly so a quick back-and-forth does not thrash the server. After a reconnect, re-send the whole current set.
6. Correctness: sequence numbers, gaps, resync
A reconnect loses whatever was in flight. Each symbol carries a server seq that increases by one per update:
- A tick with
seq <= lastis a duplicate or 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 and ignore deltas for it until the snapshot arrives. - On reconnect, re-subscribe; the server replies with a snapshot per symbol, and the client resets
seqfrom it.
The client never replays history; a snapshot is cheap and self-healing. Reconnect with exponential backoff and jitter so a server restart does not produce a thundering herd.
For a plain last-price display, a missed tick is harmless. The gap check matters when the stream carries state that is not self-contained, such as volume totals or an order book. State which case you are in.
7. Charts, stale data, time zones
Charts. Fetch the history for the selected range, already downsampled by the server (a 1Y daily chart needs a few hundred points). Append live ticks to the last candle in memory; do not refetch. On Android, draw with the Compose Canvas composable, which wraps Modifier.drawBehind. Build the Path once and cache it with Modifier.drawWithCache, which keeps objects until the size or read state changes. Do the downsampling and 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 Canvas in SwiftUI.
Stale data. Three states, always visible: live (a tick within the expected interval), stale (connected but no recent update, or restored from cache), closed (the market state says so). Show "Updated 14:03:21" or "2 min ago" next to the price, and gray the price when stale. 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 zones. Store instants in UTC, and format with the device zone for "last updated" and the exchange zone for "opens 9:30 ET". Never hard-code offsets; DST changes them. Apple's TimeZone is keyed by identifiers such as America/Los_Angeles. Let the server send marketState and the next open time, so holidays and half days are not client logic.
Always-online stance. Offline, show the cached watchlist from the last snapshot, every row marked stale with its age, and a banner. Disable alert creation, since the server holds the rules. Nothing is queued: alert creation is an official online-only write, and the watchlist is a local-first write synced by a plain PUT. Quote sync is pull-based (snapshots on demand) with push-style deltas while visible.
8. Lifecycle, alerts, accessibility, failure modes
Backgrounding. On the way to the background, close the socket and clear subscriptions. On return, reconnect, re-subscribe, and take snapshots; do not try to replay missed ticks.
Price alerts. The server evaluates alert rules against the feed, so alerts fire when the app is closed. When one triggers, the server sends a push. Treat the push as a hint plus a short text ("AAPL crossed 190.00"); tapping opens the symbol, which loads a fresh snapshot. APNs limits payloads to 4 KB for most notifications, so send identifiers, not data.
Accessibility. Do not announce ticks. The Android docs say live regions should not be used on content that updates frequently, such as countdown timers, because they overwhelm users. Leave the price as plain readable text, and use a polite live region only for rare state changes such as "Market closed" or "Alert triggered". On iOS, the updatesFrequently trait tells assistive technology to poll for changes instead of expecting notifications. Show movement with a sign or arrow, not color alone.
| Scenario | Handling |
|---|---|
| Socket drops | Mark rows stale, reconnect with backoff and jitter, re-subscribe, snapshot |
Gap in seq | Drop the delta, fetch a snapshot for that symbol |
| Duplicate or late tick | Ignore when seq <= last |
| Device clock wrong | Age from serverTime; clamp negative ages to zero |
| Market closed or halted | Show close price and state, stop the stale timer |
| Cold start without network | Cached prices marked stale, with the snapshot time |
9. How to build a stock ticker app on iOS (Swift)
The design is the same. Only the primitives 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 |
| Rapid values and a11y | Avoid liveRegion on ticks | updatesFrequently trait |
What is genuinely different:
- Conflation is an explicit buffering choice.
AsyncStreambuffers without limit by default (the documented buffer limit isInt.max). A fast feed with a slow consumer then grows memory. Choose.bufferingNewest(1): when the buffer is full, the oldest element is discarded, so the consumer sees the latest state. URLSessionWebSocketTaskhas no automatic reconnect. It reports failure on receive, and you rebuild the task and resubscribe yourself. It supports ping (sendPing) for liveness checks andmaximumMessageSizeas a safety limit. Apple's docs describe the receive API as returning a message once all of its frames are available.- Backgrounding is driven by
scenePhase. Close the socket withcancel(with:reason:)when the phase leaves.active;.backgroundmeans the scene is not visible. - Money.
Decimalis a base-10 type. Initialize it from a string, not aDouble, or you import the binary error you were avoiding.
// Sketch: sequence-checked book exposed as a conflated stream.
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] = [:]
init() {
let pair = AsyncStream.makeStream(of: [String: Quote].self, bufferingPolicy: .bufferingNewest(1))
updates = pair.stream
out = pair.continuation
}
func snapshot(_ q: Quote) { book[q.symbol] = q; out.yield(book) }
/// Returns false on a gap, so the caller can request a snapshot.
func tick(_ q: Quote) -> Bool {
guard let cur = book[q.symbol] else { return false }
if q.seq <= cur.seq { return true }
if q.seq != cur.seq + 1 { return false }
book[q.symbol] = q
out.yield(book)
return true
}
}
// Consumer, e.g. in a @MainActor view model. The sleep caps the render rate.
// for await book in hub.updates { self.quotes = book; try? await Task.sleep(for: .milliseconds(100)) }
10. What changes by level
- Mid-level: a WebSocket for live prices, REST for history, a local watchlist, throttled UI updates, decimal prices.
- Senior: ref-counted subscriptions tied to visibility, conflation with a reason, per-symbol
seqwith gap detection and snapshot resync, stale and market-closed states, closing the socket in the background, not announcing ticks. - Staff: the feed's cost model (fan-out, subscription limits, real-time versus delayed entitlements), metrics (time to first price, gap and resync rate), shared stream and chart modules across apps and widgets, and rollout via feature flags.
11. Follow-up questions to expect
- How would you add a home screen widget? (Widgets do not hold a socket; use snapshot timelines. Verify platform refresh limits before promising a frequency.)
- How do you handle delayed versus real-time data? (The server decides per user and tags each quote; the client labels it.)
- How do you test this? (A fake socket emitting ordered, duplicate, late and gapped sequences; a fake clock for stale transitions.)
- How do you measure it? (Time to first price, reconnect rate, gap rate, dropped frames, battery per session.)
Further reading (official docs)
- 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)
- BigDecimal (Java SE 17 API)
- AsyncStream (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 verified 13 Oct 2026)
Android architecture audited against the official guidance on 2026-10-13; remaining deviations: snippets omit the repository, ViewModel, and dispatcher injection, and the official docs do not cover socket-driven streams. Verified (fetched): kotlinx.coroutines conflate, sample (@FlowPreview) and StateFlow conflation; Android collectAsStateWithLifecycle, Canvas/drawBehind/drawWithCache and the live-region warning; Java SE 17 BigDecimal; Apple JSON for AsyncStream and BufferingPolicy, URLSessionWebSocketTask, ScenePhase, Swift Charts (iOS 16+), updatesFrequently, TimeZone and the 4 KB APNs payload. Decimal was verified only as "a base-10 number". Unverified: BigDecimal equals-includes-scale (follows from the Javadoc, not quoted), the Android LiveRegionMode reference page (did not load), FCM alert behavior, widget refresh limits, that Compose coalesces to frames, Task.sleep(for:) availability (iOS 16+), and the makeStream(of:bufferingPolicy:) signature (reconstructed from the page text). All numbers (50 symbols, 300 points, 100 ms) are examples. The Kotlin and Swift sketches were compiled or type-checked (see below); Event, QuoteSocket and MarketState are app-defined. The iOS sections have not yet been reviewed by an iOS engineer. Kotlin snippets compile-checked offline on 2026-10-13 against Kotlin 2.3.21 (AGP 9.1.0, compileSdk 36), kotlinx.coroutines 1.10.2, with stubs only for app-defined placeholders. Swift snippets type-checked on 2026-10-13 against the iOS 27 simulator SDK (Swift 6.4, language modes 5 and 6) with stubs only for app-defined placeholders.
Keep reading
Follow new case studies
Subscribe with any RSS reader. Found a mistake? Open an issue on GitHub.
New case studies appear in the feed first. Any RSS reader works.