Mobile System Design Interview

Course Content

Mobile System Design Interview

11 sections · 23 lessons

Stock Trading: requirements, the price stream and coalescing updates


Prompt: "Design a stock trading app."

This is the most different problem in the course. Everywhere else the difficulty is that data arrives too rarely and too unreliably. Here it arrives far too often, and the design problem is deciding what to throw away.

It is also the one case study where the offline answer is mostly "no", and saying that clearly is part of a good answer. This lesson scopes the problem and builds the path from the price stream to the screen; the next covers charts, order placement and the follow-ups.

Scoping a trading clientTrading scopeWatchlist or trading?Real-time or delayed?Which order types?Charts, what history?Which markets, hours?
Whether orders are in scope decides if this is a streaming problem or a write-safety problem.

The questions worth asking

"Real-time quotes, or delayed?" This is the first question and it changes the product. Many retail apps show quotes delayed by around fifteen minutes for users without a market data subscription, because live data is licensed and expensive. Delayed data can be polled; live data needs a stream. Ask, and note that both may exist in the same app for different users.

"How many symbols are on screen, and how many in a watchlist?" Eight to twelve visible on a phone screen. A watchlist might hold 200. That gap is the whole subscription design in the update transport below: you subscribe to what is visible, not to what the user owns.

"Is order placement in scope?" Ask explicitly, because it is a different problem — a financial write path on an unreliable network — and it is where the strongest answers are made. If the interviewer says yes, budget ten minutes for it.

"What update frequency does the product actually need?" The best question in the list. A liquid symbol can tick tens of times per second in an active market. A retail user glancing at a watchlist cannot read a number that changes more than a few times a second. Establishing early that the product needs perhaps 5–10 visible updates per second gives you permission to discard the rest, and discarding is the design.

"Charts — what time ranges and resolutions?" A one-day chart and a five-year chart are different problems: one is streamed and appended, the other is a fixed series of thousands of points that must be downsampled to fit a few hundred pixels.

The scope to state back

In scope: a watchlist with live prices, a symbol detail screen with a price chart, order placement with confirmation, and a positions view.

Out of scope: the matching engine, market data licensing, research content, and options. Design a Stock Exchange in System Design Interview designs the exchange side if that comes up.

Requirements and constraints

Functional requirements

  1. A watchlist showing live or delayed prices for the user's symbols.
  2. A symbol detail screen with a price chart over selectable ranges.
  3. Place a buy or sell order and see its status through to completion.
  4. View current positions and their profit or loss.
  5. Clear indication when data is stale, delayed, or the market is closed.

The non-functional targets

TargetValueWhy
Visible update rate5–10 per second, cappedFaster is unreadable and costs frames
Frame budget16.7 ms at 60 HzSame as every other list
Price-to-screen latencyUnder ~250 ms from the streamBeyond this, the number is misleading
Reconnect and resyncUnder ~2 s, then full snapshotA stale price is worse than no price
Order status certaintyAlways resolvableNever leave the user unsure whether they bought

The four constraints

Network. Same as everywhere: drops, tower handoffs, and variable latency. The difference is the consequence. A stale feed post is a stale feed post. A stale price shown as live can cause a user to trade on a number that no longer exists.

Stale data here is not like stale data elsewhereA stale feed post• Slightly old content on screen• User refreshes and moves on• No lasting consequenceA stale price shown as live• User trades on a dead number• Money moves on bad information• Regulatory and trust damage
The arithmetic that defines this design is that a wrong price costs more than a missing one.

Battery. A continuous stream keeps the radio busy. On a watchlist screen the socket is receiving constantly, and this is one of the few designs where the app is genuinely expensive to leave open. That is an argument for subscribing narrowly and unsubscribing aggressively.

CPU and rendering. The binding constraint here, and it is worth doing the arithmetic out loud.

Memory. A five-year daily chart is about 1,250 points, which is nothing. An intraday tick series is not: a full trading day of every tick on a liquid symbol can be hundreds of thousands of points, and holding several symbols' worth will not fit comfortably.

The arithmetic that defines the design

Say those numbers in the room. "Two hundred updates a second against sixty frames" is the sentence that opens the whole design.

Offline, explicitly: no live prices, so the watchlist shows the last received price for each symbol with a visible timestamp and a stale treatment (greyed, no colour flash, an "offline — prices from 14:32" banner). Positions render from the local database. Order placement is disabled with a clear reason, not a silent failure — and an order already submitted shows its last known status with "we will confirm when you reconnect".

The update transport

Prices are server-to-client, continuous, and high-volume. That points at a stream — with two mobile-specific twists.

Polling against a server-pushed streamPolling• Client asks on a timer• Radio wakes even when nothing changed• Latency is at best the interval• Trivial to implement and debugPushed stream• Server sends only on change• Radio idles between real updates• Latency near the wire time• Needs reconnect and snapshot logic
Push wins on both latency and battery, and pays for it with the reconnect path you must design.

Choosing the transport

Polling is wrong: a one-second poll for ten symbols is 600 requests an hour for data that is already a second old on arrival. Server-sent events would work and are simpler than a socket, since prices flow one way.

A WebSocket is usually the answer anyway, because the client needs to send too — subscribe and unsubscribe messages, constantly, as the user scrolls. That bidirectional need is what tips it.

Subscribe to what is visible, not to what is owned

The naive design subscribes to the user's whole watchlist. With 200 symbols, most of them not on screen, the client receives thousands of updates per second to display twelve.

The design that works:

Text
onVisibleRangeChanged(symbols):    toAdd    = symbols - subscribed    toRemove = subscribed - symbols - recentlyVisible   // small hysteresis buffer    if toAdd or toRemove:        socket.send({ subscribe: toAdd, unsubscribe: toRemove })        subscribed = symbols ∪ recentlyVisible

Three details that matter:

  • Debounce the resubscribe. A fast scroll changes the visible range every frame. Wait ~200 ms after scrolling settles before sending, or you send a subscription message per frame.
  • Keep a hysteresis buffer. Hold a few rows above and below the visible range subscribed, so a small scroll back does not show an empty price.
  • Batch into one message. One {subscribe: [...], unsubscribe: [...]} per change, not one message per symbol.

The saving is direct: twelve to twenty subscriptions instead of two hundred, so roughly a tenth of the incoming data, a tenth of the parsing, and a tenth of the radio time.

Reconnect, and the snapshot that must follow

Chat resyncs by asking for messages after a sequence number. Prices cannot work that way — the client does not want the ticks it missed, it wants the current price, and replaying a minute of missed ticks would animate the display through history.

So the reconnect path is: reconnect with backoff and jitter, resubscribe to the currently visible symbols, and request a full snapshot for those symbols before resuming the stream. Everything before the snapshot is discarded.

Until the snapshot lands, prices are marked stale — which brings up the rule that governs this entire case study.

Offline: no stream. Everything is stale by definition, and the stale treatment is already built, which is why designing it here rather than as a follow-up is the better sequence.

Throttling and coalescing updates

Narrow subscriptions cut the volume by ten. They do not solve the 200-updates-against-60-frames problem on the symbols that remain. The core technique of this case study, and one of the most transferable ideas in the course, does: buffer incoming updates and render at a fixed cadence, keeping only the latest value per key.

The naive path, and why it collapses

Each incoming tick updates its row directly:

Text
onTick(t):  view[t.symbol].price = t.price   // triggers a re-render

At 200 ticks per second this schedules 200 view updates per second. Each one costs layout and draw for that row, and they arrive in bursts rather than evenly, so several land within a single 16.7 ms frame. The result is dropped frames, a device that gets warm, and — the part that is genuinely counter-intuitive — a display that is harder to read, because numbers flickering 20 times a second cannot be parsed by a human eye.

Coalescing

Keep a map of pending updates keyed by symbol. Each tick overwrites the entry for that symbol. A timer flushes the map to the screen at a fixed rate.

Text
pending = { }                       // symbol → latest tickonTick(t):    pending[t.symbol] = t           // overwrite; older tick discardedevery 100 ms (on the frame callback):    if pending is empty: return    batch = pending; pending = { }    applyToViews(batch)             // one update pass, all symbols

Three properties fall out of six lines:

  • Bounded work. The flush costs at most one update per visible symbol, regardless of whether 200 or 20,000 ticks arrived. Render cost stops depending on market volatility.
  • The latest value always wins. Discarded ticks are intermediate prices that would have been overwritten within milliseconds anyway.
  • Predictable cadence. Ten flushes a second is readable, and the render work is evenly spaced rather than bursty.
Main-thread render work per second (illustrative)050010001500200025000200400600800Naive: update per tick (ms/s)Coalesced at 10 Hz (ms/s)Main thread saturated (1000 ms/s)
Main-thread render work per second (illustrative)

The shape is the point: naive cost rises linearly with market activity and crosses the point where the main thread cannot keep up, while coalesced cost is flat and set by the flush rate you chose. The absolute milliseconds are illustrative and vary by device — the linear-versus-flat contrast does not.

Choosing the flush rate

RateFeelUse
60 Hz (every frame)Smooth, expensiveOnly for a single focused symbol
10 Hz (every 100 ms)Readable and responsiveThe default for a watchlist
4 Hz (every 250 ms)Calm, noticeably laggingBackgrounded tabs, low-power mode
1 HzStatic-feelingDelayed-quote users; battery saver

Drop the rate when the device enters low-power mode, when the app is not the foreground screen, and when the connection is metered. That is a two-line change and a real battery saving.

Where coalescing appears elsewhere

The same pattern shows up in the Google Drive app (batching file-change events before touching the database) and the YouTube app (buffering playback progress before writing it). Any time events arrive faster than you can usefully act on them, the answer is a keyed buffer and a fixed flush.

Offline: no ticks arrive, so the buffer stays empty and the flush timer can be stopped entirely — a small but genuine battery saving worth mentioning.