Stock Ticker
Design a system that aggregates live stock prices from multiple exchanges worldwide and serves them to millions of users with sub-second latency, covering feed normalization, pub-sub fanout, WebSocket delivery, and cache strategies.
What is a real-time stock price viewer?
A stock price viewer aggregates live trade data from exchanges like NYSE, NASDAQ, and CBOE and displays current prices to millions of users watching their portfolios. The apparent simplicity hides a brutal engineering challenge: during market hours, 50,000 symbols each tick up to 10 times per second, producing roughly 1 million price events per second. Each of those events must fan out to potentially millions of subscribed clients within one second.
This is a write-broadcast problem, and it tests every layer from feed normalization through pub-sub routing to WebSocket delivery.
Functional Requirements
Core Requirements
- Users can view the current price for any listed stock or ETF.
- Prices update in near real time, within one second of a trade.
- Users can watch a portfolio of symbols simultaneously on a single connection.
Below the Line (out of scope)
- Historical charting with full OHLCV data
- Order execution or trading
Historical charting is below the line because it does not touch the live feed path at all. To add it, we would stream all normalized price events to a columnar time-series store (ClickHouse or TimescaleDB) and serve historical queries from a separate read API, a background pipeline sitting beside the live viewer rather than inside it.
Order execution is out of scope because placing orders requires an order routing layer, a matching engine, real-time risk checks, and regulatory compliance infrastructure. That is an entirely separate system that consumes price data from this one rather than sharing its internals.
The hardest part in scope: Fan-out at scale. One price update for AAPL during a volatile market must reach potentially millions of subscribed clients within one second. Every decision about the pub-sub layer, WebSocket servers, and delivery protocol traces back to this fan-out challenge.
Non-Functional Requirements
Core Requirements
- Latency: Price updates delivered to clients within 1 second of a trade. New subscribers receive an immediate snapshot of the current price within 200ms of opening a connection.
- Throughput: Ingest up to 1M price events per second across all symbols during market hours.
- Scale: Support 10M DAU with up to 5M concurrent WebSocket connections at peak. Each symbol averages 100 subscribers but popular symbols like AAPL can reach millions.
- Availability: 99.9% uptime during market hours. A client seeing a price that is one tick stale for a moment is acceptable. A client disconnected entirely for minutes is not.
- Consistency: Eventually consistent. Correctness within the 1-second window is sufficient for a consumer viewer.
Below the Line
- Sub-millisecond latency (that is co-location trading infrastructure, not a consumer application)
- Financial compliance audit logs
- Cross-currency normalization across global exchanges
Read/write ratio analysis: The system generates 1M price events per second during market hours. With 5M concurrent subscribers each watching an average of 5 symbols, and approximately 100 subscribers per symbol, every price update on average fans out to 100 delivery operations. That makes the effective delivery rate 100M targeted pushes per second against 1M source writes, a 100:1 fan-out multiplier. This is not a read-heavy system in the traditional sense; it is a write-broadcast system. The primary design challenge is not serving reads cheaply, it is routing writes efficiently to only the clients who care.
I call out this distinction early in every stock-price interview because candidates who treat it as a read-heavy caching problem design the wrong system entirely.
Core Entities
- PriceUpdate: A single trade event from an exchange; contains symbol, price, volume, and exchange timestamp.
- LatestPrice: The most recent price per symbol; cached in Redis and updated on every tick; the primary read target for snapshots.
- Symbol: A tradeable instrument (stock or ETF); identifies which exchange lists it and the minimum tick size.
- Subscription: An in-memory mapping from a connected client to the set of symbols it is watching; ephemeral and lives only on the WebSocket server.
Schema and indexing decisions are deferred to a data-model deep dive. These four entities are enough to anchor the design through High-Level Design.
I keep the entity list short here because the real complexity in this system is not the data model. It is the delivery pipeline. In an interview, spending five minutes on price schemas when the interviewer wants to hear about fan-out is a common trap.
API Design
Start with one endpoint per functional requirement, then evolve where the naive shape breaks.
FR 1 -- View current price (REST snapshot):
GET /prices/{symbol}
Response: { symbol, price, currency, exchange_timestamp, server_timestamp }
A simple REST GET is the right shape for an on-demand snapshot. The response includes both exchange timestamp (when the trade occurred) and server timestamp (when the system ingested it) so clients can compute propagation lag for display.
FR 2 -- Real-time updates:
Naive approach: poll the REST endpoint every second per symbol.
GET /prices/{symbol}
// Client calls this every 1,000ms for each watched symbol
This breaks immediately. With 10M users each watching 5 symbols and polling every second, the system absorbs 50M HTTP requests per second purely to deliver updates that may not have changed. The evolved approach is a persistent server-push connection.
Evolved approach: WebSocket connection with subscription commands.
WS /stream
// Client subscribes after opening connection
{ "action": "subscribe", "symbols": ["AAPL", "MSFT", "TSLA"] }
// Server immediately sends a snapshot per subscribed symbol
{ "type": "snapshot", "symbol": "AAPL", "price": 185.42, "timestamp": "2026-03-29T14:30:01.234Z" }
// Server then streams deltas as prices change
{ "type": "update", "symbol": "AAPL", "price": 185.43, "timestamp": "2026-03-29T14:30:01.891Z" }
A single WebSocket connection per client multiplexes all symbol subscriptions over one TCP connection. Subscription changes (add or remove a symbol) travel over the same connection without a separate HTTP round trip.
FR 3 -- Watch multiple symbols and reconnect handling:
WS /stream
// Subscribe to multiple symbols in one message
{ "action": "subscribe", "symbols": ["AAPL", "MSFT", "GOOGL", "NVDA"] }
// On reconnect after a drop, client sends last-seen timestamps per symbol
{ "action": "reconnect", "last_seen": { "AAPL": "2026-03-29T14:30:05.000Z", "MSFT": "2026-03-29T14:30:05.100Z" } }
// Server sends snapshots only for symbols whose price changed during the gap
{ "type": "snapshot", "symbol": "AAPL", "price": 186.10, "timestamp": "2026-03-29T14:30:07.220Z" }
I always include the reconnect action in the WebSocket protocol because clients on mobile networks drop connections frequently. Without delta-on-reconnect, a user who loses signal for 30 seconds sees prices frozen at their last value until the next natural tick on each symbol.
High-Level Design
1. View current price
The simplest system that satisfies FR1: read the latest cached price for a symbol and return it.
Components:
- Client: Web or mobile application sending
GET /prices/{symbol}requests. - Price API Server: Validates the symbol and routes the read to the cache first.
- Redis Price Cache: Holds the latest price per symbol as a key-value entry (
latest:AAPL). Sub-millisecond reads. The Exchange Ingestion process updates this key on every tick. - Price Store: Persistent fallback for the case where Redis restarts or a symbol has never been written (cold start).
- Exchange Ingestion: Background service normalizing feeds from multiple exchanges and writing the latest price into Cache and Store on every tick. Treated as a black box here; the normalization design is addressed in the High-Level Design for FR2.
Request walkthrough:
- Client sends
GET /prices/AAPL. - Price API Server validates the symbol is in the known symbol list.
- Server reads
latest:AAPLfrom Redis Price Cache (under 1ms). - On cache miss, server reads from Price Store as a fallback.
- Server returns
{ symbol, price, exchange_timestamp, server_timestamp }.
The cache hit rate approaches 100% after market open because Exchange Ingestion writes every symbol on the first tick; the Price Store is a safety net for cold starts, not a primary path.
I spend about 30 seconds on this diagram in an interview, just enough to show I can handle the trivial case, then move immediately to the real-time delivery challenge.
2. Real-time price updates
REST polling at this scale is not viable: 10M clients polling 5 symbols every second generates 50M HTTP requests per second just to check for updates that may not have changed. The fix is a persistent connection with server push.
I always split the Exchange Ingestion layer from the WebSocket delivery layer because they scale on completely different axes. Ingestion scales with the number of exchange feeds and total symbol count; delivery scales with the number of connected clients. Coupling them into a single service means scaling for 5M WebSocket connections also scales your exchange feed handlers, which is wasteful and operationally dangerous.
Components added to FR1:
- WebSocket Service: Maintains persistent connections with clients. Receives price updates from the Pub-Sub layer and pushes them to subscribed clients. Sends a Redis snapshot immediately when a client subscribes to a new symbol.
- Kafka (Pub-Sub Layer): Exchange Ingestion publishes one
PriceUpdateevent per trade to a Kafka topic partitioned by symbol. WebSocket servers consume from relevant partitions. This decouples ingestion throughput from delivery throughput and provides a brief replay buffer. - Exchange Ingestion (expanded): Handles feed normalization across multiple formats: FIX protocol messages from NYSE, proprietary binary feeds from CBOE, and REST snapshot APIs for smaller exchanges. Every normalized event is published to Kafka, and
latest:{symbol}in Redis is updated atomically.
Request walkthrough:
- Client opens a WebSocket connection to the WebSocket Service.
- Client sends
{ "action": "subscribe", "symbols": ["AAPL"] }. - WebSocket Service reads
latest:AAPLfrom Redis and sends a snapshot immediately. - Exchange Ingestion receives a FIX message from NYSE for AAPL, normalizes it to a
PriceUpdateevent, publishes to Kafka topicpriceswith partition keyAAPL, and updateslatest:AAPLin Redis. - WebSocket Service consumes the Kafka message and pushes
{ type: "update", symbol: "AAPL", price: 185.43 }to all clients subscribed to AAPL.
The two writes from Exchange Ingestion (Kafka publish and Redis SET) happen together on every tick; Kafka carries the ordered event stream, Redis carries the current value for new subscribers joining mid-session.
3. Watch multiple symbols
A single WebSocket connection already supports multiple subscriptions through the protocol defined in API Design. The engineering challenge at this stage is fan-out at scale: when a popular symbol like AAPL ticks, the WebSocket tier potentially needs to deliver that update to millions of connections spread across many server instances.
Components evolved:
- WebSocket Servers (scaled out): Multiple instances, each managing tens of thousands of connections. Clients that reconnect after a drop land on any available instance; state is reconstructed from Redis.
- Fan-Out Service: A dedicated tier between Kafka and WebSocket servers. Each Fan-Out instance owns a subset of symbols (sharded by symbol hash). WebSocket servers register interest with the symbol's owning Fan-Out instance when a client subscribes. Fan-Out instances forward updates only to the WebSocket servers that have at least one subscriber for that symbol.
Request walkthrough (multi-symbol subscribe):
- Client sends
{ "action": "subscribe", "symbols": ["AAPL", "MSFT", "TSLA", "NVDA"] }. - WebSocket Service sends four snapshot messages in parallel, one per symbol, from Redis.
- WebSocket Service registers interest for all four symbols with their respective Fan-Out instances.
- Fan-Out Service consumes a
PriceUpdatefor MSFT from Kafka (owned by this Fan-Out instance). - Fan-Out Service looks up which WebSocket servers have registered interest for MSFT.
- Fan-Out Service forwards the update to only those servers; other servers receive nothing for this tick.
- Each targeted WebSocket server pushes the update to its local MSFT subscribers.
The Fan-Out tier is what prevents every WebSocket server from receiving every price event for every symbol regardless of whether any of its clients care. This is the single most important component in the entire system.
In my experience, candidates who jump straight to "just use Kafka consumer groups" miss the fan-out problem entirely. Kafka consumer groups distribute partitions across consumers, but every consumer in the group still receives all events for its assigned partitions. The dedicated Fan-Out tier is what turns a broadcast into targeted delivery.
Potential Deep Dives
1. How do you fan out one price update to millions of subscribers?
With 1M price events per second and an average of 100 subscribers per symbol, the fan-out tier must route approximately 100M delivery operations per second. This is the make-or-break design decision. Three approaches with increasing quality:
2. What delivery protocol maximizes throughput and minimizes connection overhead?
With 5M concurrent connections, the protocol choice directly affects server memory, CPU per connection, and the recovery experience for clients on mobile networks.
Final Architecture
The Fan-Out tier is the core scalability insight: Exchange Ingestion writes to Kafka exactly once per price event, Fan-Out routes each event to only the WebSocket servers that have subscribers for that symbol, and no server ever processes events for symbols none of its clients are watching.
Interview Cheat Sheet
- Scope to three core features at the start: snapshot reads via REST, real-time updates via WebSocket, and multi-symbol subscriptions on a single connection. Place historical charts and order execution explicitly out of scope.
- State the fan-out challenge early: 1M price events per second with 100 average subscribers per symbol equals 100M targeted delivery operations per second, and that number drives every architectural decision.
- Split Exchange Ingestion from WebSocket delivery because ingestion scales with feed volume (number of exchanges and symbols) while delivery scales with connected clients; they are independent scaling axes.
- Use Kafka partitioned by symbol between Ingestion and Fan-Out. Kafka's 7-day retention lets Fan-Out instances replay missed events after a restart without data loss, and partition-level ordering ensures clients never see a price roll backward.
- Redis stores the latest price as
latest:{symbol}. New subscribers get an immediate snapshot from Redis in under 1ms before the next natural tick arrives, satisfying the 200ms initial display latency target. - WebSocket with binary framing (MessagePack or Protobuf) beats SSE for multi-symbol portfolios because one TCP connection multiplexes all subscriptions bidirectionally. SSE requires one connection per symbol and a separate HTTP channel for subscription commands.
- The snapshot-on-reconnect pattern handles mobile network drops: the client sends last-seen timestamps per symbol on reconnect, and the server sends full snapshots only for symbols whose price changed during the gap.
- Fan-Out Services shard by consistent hash on symbol name so each instance owns a deterministic subset of symbols. Hot symbols like AAPL during earnings can be pinned to dedicated instances to prevent one symbol from saturating a shared instance.
- A 45-second server-side WebSocket ping detects silent mobile connections. Without it, stale connections accumulate in the Fan-Out subscription registry and waste CPU delivering to dead sockets.
- At 5M concurrent connections with 50K connections per WebSocket server instance, the tier requires 100 WebSocket instances at baseline. Size each instance for memory first since each connection holds a subscription list in RAM.
- Long polling fails at this scale before it even reaches the fan-out layer: 50M concurrent long-poll connections for 10M users each watching 5 symbols requires far more server resources than 5M multiplexed WebSocket connections delivering the same workload.
- Redis Pub/Sub is viable for prototypes but saturates at roughly 500K publishes per second on a single node. Redis Cluster does not support cross-slot pub-sub without application routing. The dedicated Fan-Out tier removes this ceiling entirely.