Mobile Design Rounds
Build guideAndroid + iOS16 min read

Build it: a stock ticker app on Android and iOS

Last reviewed . Drafted with AI assistance. Checked against official documentation where noted (how this is checked). Part of: Design a stock ticker app (Android and iOS): streaming, joining updates and resync · The framework.

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.

A reconnect loses whatever was in flight. Each symbol has a seq that increases by one for each update:

  1. A tick with seq <= last is a duplicate or a late packet. Drop it.
  2. A tick with seq == last + 1 is applied.
  3. A tick with seq > last + 1 is a gap. Do not apply it. Request a snapshot for that symbol, once. Ignore ticks for that symbol until the snapshot arrives.
  4. On reconnect, subscribe again. The server replies with a snapshot for each symbol, and the client resets seq from 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 updatesFrequently trait 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.

LimitAndroidiOSSo the design changes by
Socket in the backgroundDoze suspends network access; foreground services are for immediate work the user can see, and most cannot start from the backgroundClose the socket when the scene leaves .active; I did not confirm from an Apple page whether a WebSocket task can run in a background sessionNo socket in the background; alerts go by push notification, and the return to the foreground is reconnect, subscribe again, snapshot
ReconnectingOkHttp has pingInterval; how it reports a missed pong is uncheckedApple documents no reconnect for URLSessionWebSocketTask; it has sendPingI own the rebuild, the new subscribe and the snapshot on both platforms
Alert push notificationFCM alert behavior is unverified hereAPNs limits most payloads to 4 KBThe push carries identifiers and a short text; the symbol screen loads a fresh snapshot
Fast-changing textLive regions are not meant for content that updates oftenupdatesFrequently tells assistive technology to pollPrices stay plain text; only rare state changes are announced

9. Doing it on iOS (Swift)

The design is the same. Only the tools change.

ConcernAndroidiOS
SocketOkHttp WebSocketURLSessionWebSocketTask
ConflationStateFlow, conflate(), sample()AsyncStream with .bufferingNewest(1)
MoneyBigDecimalDecimal
LifecyclecollectAsStateWithLifecycle, DisposableEffectscenePhase, onAppear / onDisappear
ChartsCompose CanvasSwift Charts LineMark, or Core Graphics
PushFCMAPNs
Fast-changing values and accessibilityAvoid liveRegion on ticksupdatesFrequently trait

What is really different:

  • Conflation is an explicit buffering choice. AsyncStream buffers 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 failed receive as the end of the task. Then rebuild it and subscribe again yourself. It supports ping (sendPing) for liveness checks and maximumMessageSize as 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 with cancel(with:reason:) when the phase leaves .active. The .background phase means the scene is not visible.
  • Money. Decimal is a base-10 type. Create it from a string, not from a Double. Otherwise you bring in the binary error you wanted to avoid. This is also why the wire format uses strings: JSONDecoder reads a JSON number through a Double. I have not verified either point against Apple's docs. If you use Decimal(string:), pass Locale(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

  1. How do you test this? (A fake socket that sends ordered, duplicate, late and gapped sequences; a fake clock for stale changes.)
  2. 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.)
  3. 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)

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.