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.
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
- A watchlist showing live or delayed prices for the user's symbols.
- A symbol detail screen with a price chart over selectable ranges.
- Place a buy or sell order and see its status through to completion.
- View current positions and their profit or loss.
- Clear indication when data is stale, delayed, or the market is closed.
The non-functional targets
| Target | Value | Why |
|---|---|---|
| Visible update rate | 5–10 per second, capped | Faster is unreadable and costs frames |
| Frame budget | 16.7 ms at 60 Hz | Same as every other list |
| Price-to-screen latency | Under ~250 ms from the stream | Beyond this, the number is misleading |
| Reconnect and resync | Under ~2 s, then full snapshot | A stale price is worse than no price |
| Order status certainty | Always resolvable | Never 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.
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.
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:
onVisibleRangeChanged(symbols): toAdd = symbols - subscribed toRemove = subscribed - symbols - recentlyVisible // small hysteresis buffer if toAdd or toRemove: socket.send({ subscribe: toAdd, unsubscribe: toRemove }) subscribed = symbols ∪ recentlyVisibleThree 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:
onTick(t): view[t.symbol].price = t.price // triggers a re-renderAt 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.
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 symbolsThree 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.
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
| Rate | Feel | Use |
|---|---|---|
| 60 Hz (every frame) | Smooth, expensive | Only for a single focused symbol |
| 10 Hz (every 100 ms) | Readable and responsive | The default for a watchlist |
| 4 Hz (every 250 ms) | Calm, noticeably lagging | Backgrounded tabs, low-power mode |
| 1 Hz | Static-feeling | Delayed-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.