The single most important thing to get right about Robinhood is that it is a brokerage, not an exchange. We do not match a buyer to a seller ourselves. We take a customer's order, hand it to a market maker or an exchange that does the actual matching, and we get paid through payment for order flow. If you want the system that matches orders and holds the order book in memory at microsecond latency, that is a different design and I wrote it up separately in design a stock exchange. This article builds the layer that sits on top of an exchange and faces millions of retail users.
Framed that way, the product has two jobs, and each one is a distinct hard problem. The first is fanning live prices out to a huge number of watching clients without opening a fresh connection to the exchange for every one of them. The second is keeping an order consistent when it lives half in our database and half on the exchange, across a network that drops messages, while real money is on the line. Underneath the two hard problems sit the ordinary but load-bearing pieces, a clean REST API, a normalized order model, and a price cache, and the design leans on each of them.
Scope and requirements
Functionally, a user watches live prices for a set of symbols and manages orders, meaning they can place a market or limit order, cancel an outstanding one, and list their orders. That is a deliberately small surface, and the smallness is the point, because the difficulty here is not feature count but the non-functional pressure behind those two features.
Non-functionally, four constraints shape the whole design. Orders demand strong consistency, because a user who sees a stale or wrong order state and trades on it can lose money, so on the order path this system chooses consistency over availability. CAP only speaks to the rare case of a network partition, so the sharper lens is PACELC, which adds the common case too. If there is a Partition you pick Availability or Consistency, and Else, in normal running, you pick Latency or Consistency. Orders are a PC and EC system. Under a partition we keep consistency and refuse to risk a lost or double order, and even with no partition we still favor consistency over shaving a few milliseconds, because a wrong trade costs far more than a slightly slower confirmation. Latency is tight, under a couple hundred milliseconds to reflect a price tick or acknowledge an order, because a trading UI that lags feels broken and unsafe. Scale is large but lopsided, roughly 20 million daily users placing a handful of trades each against a few thousand symbols, which as we will see makes the trade rate modest and the price fan-out enormous. And connections to the exchange are precious, because a broker's data feeds and order sessions to a venue are expensive, rate-limited, and often locked to an allowlist of source IPs, so minimizing and concentrating that traffic is a first-class requirement, not an afterthought. Trading outside market hours, options and crypto, showing the full order book, and fee calculation are all real features I would explicitly push below the line to protect the time.
Sizing the problem
Twenty million daily users at five trades each is 100 million orders a day, and spread over a trading day that is only a few thousand order writes per second at the average and maybe tens of thousands at the open and close. That is a completely ordinary write rate for a sharded relational database, so the order-storage problem is about correctness, not volume.
Prices are the opposite story. A few thousand symbols each tick many times a second, but the raw tick rate is small. The load is in the fan-out. Say a million clients are connected at once and each watches twenty symbols, that is 20 million live subscriptions, and a single popular ticker on a volatile day can have hundreds of thousands of those clients watching it at the same moment. One price tick on that symbol therefore has to become hundreds of thousands of individual pushes, and it has to happen within our latency budget. The scaling story of a brokerage is price fan-out, not trade throughput, and a design that spends its effort sharding the orders table has optimized the easy half.
The API
The interface is a small REST surface. REST models the system as resources named by URLs and acted on with HTTP verbs, GET to read, POST to create, DELETE to remove, and it is stateless, so every request carries its own authentication rather than relying on server-side session affinity. Prices stream over server-sent events (SSE), which is a single HTTP response the server holds open and keeps writing events into. The browser gets a live one-way feed with automatic reconnection built in, none of the wasted requests polling would make, and no bidirectional machinery to run for what is only a downstream push.
GET /symbols/:ticker -> Symbol { ticker, name, priceInCents, ... }
GET /symbols/subscribe?tickers= -> SSE stream of price-update events
POST /orders -> Order
{ "position": "buy", "symbol": "META", "priceInCents": 52210, "shares": 10 }
DELETE /orders/:orderId -> { ok: true }
GET /orders?cursor= -> Order[] (paginated)Money is an integer number of cents rather than a float, because binary floating point cannot represent 0.10 exactly and rounding drift in a financial system is how you get off-by-a-penny reconciliation bugs and, in the worst case, exploitable ones. Identity and price never come from the client body either. The userId comes from the JWT or session, and the authoritative price comes from the server, because anything the client can put in a request body the client can also forge. Trusting a client-supplied userId is the classic red flag in these designs, and the same rule shows up in rideshare (never trust a client-sent fare) and every other system here.
Core entities and the data model
Three entities carry the design: a User, a Symbol (a tradable stock and its latest price), and an Order, which is where the interesting state lives.
Order
orderId -- our primary key
userId -- owner, and the shard key
externalOrderId -- the id the exchange gives us on submit
symbol
shares
priceInCents
state -- pending | submitted | filled | partially_filled
-- | pending_cancel | cancelled | failed
... -- execution data, timestampsThe Order table is normalized, which just means each fact lives in one place: the order stores a userId foreign key rather than copying the user's name and email onto every row, so a profile edit does not have to rewrite a million orders. We keep this data in a relational store because order state transitions need real transactions and the consistency requirement outranks raw write throughput, which is exactly the trade-off a document store would push the wrong way.
We shard the Orders table by userId, so that GET /orders for a user is a single-shard, single-partition read. That is the right call, and it quietly creates the hardest bug in the system, because the exchange later reports fills keyed by externalOrderId, which has nothing to do with userId. Finding the right shard from the wrong key is the whole subject of the fill-tracking section below, and it is exactly the kind of collision that good indexing instincts catch early.
The pieces, and how they fit
Two independent paths hang off the gateway. On the read path, a client subscribes through the API gateway to the Symbol Service, which serves initial prices out of a Symbol Cache and streams updates over SSE. A Price Processor holds the connection to the exchange's trade feed, updates the cache, and forwards ticks toward the clients. On the write path, a client's order goes to the Order Service, which records it in the Orders DB and hands it to an Order Dispatch Gateway that talks to the exchange. A Trade Processor tails the exchange for fills and writes them back into the Orders DB. The exchange itself is drawn dashed, because it is outside our trust boundary and outside our control.
Live prices, and fanning them out
The naive version couples clients directly to the feed. Every Symbol Service instance subscribes to every symbol a connected client cares about, straight from the exchange, which means each of our N instances holds its own connection and does its own duplicate work, and together they hammer the one expensive, rate-limited feed we are supposed to conserve. That does not scale and it burns the resource we said was precious.
The fix is to decouple the feed from the clients with a message bus. A small pool of Price Processors owns the exchange connection and publishes each symbol's ticks to a per-symbol channel in Redis pub/sub. Each Symbol Service instance keeps an in-memory map of symbol → Set<userId> for the clients connected to it, and subscribes to a Redis channel only while it has at least one client watching that symbol. When a tick arrives, Redis delivers it to exactly the instances that have interested clients, and each instance fans it out over SSE to its own subscribers. The design is self-regulating, because when the last client for a symbol disconnects, the instance drops the Redis subscription, so we only ever carry the fan-out we are actually serving.
# Price Processor: the only listener on the exchange feed, one publish per tick
r = redis.Redis()
for trade in exchange.trade_feed():
r.publish(f"price:{trade.symbol}", trade.price_cents)
# Symbol Service instance: subscribe only to the symbols its clients watch
watchers = defaultdict(set) # symbol -> set of user ids on THIS server
pubsub = redis.Redis().pubsub()
def on_subscribe(user_id, symbol):
if not watchers[symbol]:
pubsub.subscribe(f"price:{symbol}") # first watcher here opens the channel
watchers[symbol].add(user_id)
for msg in pubsub.listen(): # only ticks for subscribed symbols arrive here
symbol = msg["channel"].removeprefix("price:")
for user_id in watchers[symbol]:
sse_send(user_id, symbol, msg["data"]) # fan out to this server's clientsThe Symbol Cache is worth its own note, because the caching pattern here is unusually clean. It holds the latest price per symbol, the Price Processor writes through to it on every tick, and a new subscriber reads its starting prices straight from the cache instead of waking the exchange feed. There is almost no cache-invalidation problem, because a price is never corrected in place, it is simply overwritten by the next tick, so the usual hard part of caching, knowing when an entry has gone stale, mostly disappears. The cache is there to shield the expensive upstream feed and to answer that first read fast, which is the cache-aside and read-through pattern applied to data that refreshes itself.
The Price Processor is the only thing holding the exchange feed. Redis pub/sub sits between it and the Symbol Service instances, so a tick reaches only the instances with a client watching that symbol.
Why SSE and not WebSockets or polling. The price stream is one-directional, the server pushes and the client only listens, so SSE fits exactly and comes with automatic reconnection and a Last-Event-ID for gap recovery for free. WebSockets are bidirectional and would be the right call if the client also streamed data up, but here that is extra complexity for nothing. Polling would fire a request per client per interval and waste the request whenever the price has not moved. Millions of open SSE connections are themselves a resource, so the connection-holding tier needs generous file-descriptor limits, heartbeats to detect dead clients, and a plan for the reconnect storm when a connection server restarts and its clients all reconnect at once.
# SSE endpoint: hold one HTTP response open and stream events as prices move
@app.get("/symbols/subscribe")
def subscribe(request):
user_id = auth(request) # identity from the JWT, never the query
def stream():
yield "retry: 3000\n\n" # client auto-reconnects ~3s after a drop
for symbol, cents in price_feed(user_id): # fed by the pub/sub fan-out above
data = json.dumps({"symbol": symbol, "cents": cents})
yield f"event: price\ndata: {data}\n\n"
return Response(stream(), mimetype="text/event-stream")Where else this pattern lives. This is the same shape as fan-out in the news feed, the same pub/sub decoupling behind the chat system and the notification system, and the symbol → Set<userId> map is just a subscription registry, the same idea as tracking which clients care about which channel anywhere real-time updates show up. Once you have built it once, you recognize it everywhere.
Placing orders, and tracking their fills
Here is the collision I flagged in the data model. When we submit an order, the exchange hands back an externalOrderId. Later, the exchange's trade feed reports fills and partial fills, and it identifies them by that same externalOrderId, because that is the only id the exchange knows. The Trade Processor receives a fill and needs to update our order row, but our Orders DB is sharded by userId, and the fill event does not carry a userId. Looking the order up by externalOrderId alone would mean querying every shard, since the row could be on any of them, which is a scatter-gather that gets slower as we grow.
This is the cross-shard secondary index problem in its purest form: you partition by one key but you need to look up by another. A local index on externalOrderId does not help, because an index inside each shard still leaves you asking which shard to search. What you need is a global mapping, and the clean way to get one is a small dedicated key-value store that maps externalOrderId → (orderId, userId), written by the Order Service the moment the exchange returns the id. Now the Trade Processor does one fast point lookup, learns the userId, and goes straight to the correct shard.
The mapping store turns an impossible cross-shard search into a two-step point lookup: read externalOrderId → (orderId, userId), then use the userId to hit the one shard that holds the order.
The senior nuance worth saying out loud. A second store is real operational cost and a new consistency surface, so the better move when the venue allows it is to skip the mapping entirely: most exchange APIs let you attach your own clientOrderId metadata on submit and echo it back on every fill, so you can put (orderId, userId) there and read it straight off the fill event. The things to watch out for are that you must not leak or trust internal ids blindly, since a compromised or buggy venue could echo back something you did not send, so you sign or validate the metadata, and you keep the ids opaque. If you cannot rely on echoed metadata, the KV store is the fallback. Either way, the underlying lesson generalizes: whenever you shard by one key and query by another, you need a global index, and the same problem appears in the URL shortener and in social graph search.
Keeping orders consistent when the network fails
An order exists in two places at once, our database and the exchange, connected by a network that will drop a message at the worst possible moment. The discipline that keeps this correct is to model the order as a state machine and always record our intended state before we act on it. Write first, then call the exchange, so that a crash never leaves a live order at the venue that we have no record of.
Green states are the happy terminal outcomes, red is the terminal error, and the amber note is the background job that rescues orders stranded by a mid-flight failure.
The create flow is: write the order as pending in the Orders DB, submit it to the exchange, and on success write the returned externalOrderId into the mapping store and move the order to submitted. The reason pending is written first is what keeps a crash recoverable. If we recorded nothing and submitted first, a crash right after the exchange accepted would leave real money working in the market with no row on our side to ever reconcile it. Writing our intent first means the worst case is a pending order that never got submitted, which is safe and recoverable. The cancel flow mirrors it: move to pending_cancel first, submit the cancel, then settle to cancelled.
def create_order(user_id, req):
# 1. record intent FIRST so any later crash stays recoverable
order = orders_db.insert(user_id=user_id, symbol=req.symbol, shares=req.shares,
price_cents=req.price_cents, state="pending",
client_order_id=req.idempotency_key) # dedupes retries
# 2. synchronous submit, the exchange returns its id right away
ext_id = exchange.submit(order, client_order_id=order.client_order_id)
# 3. only now record the reverse mapping and flip the state
ext_map.put(ext_id, (order.id, user_id)) # externalOrderId -> (orderId, shard)
orders_db.update(order.id, state="submitted", external_order_id=ext_id)
return order # 4. respond to the client
# A crash between step 2 and step 3 leaves a `pending` row. The reconciliation job
# scans stuck pending rows, asks the exchange by client_order_id, and settles them.Now walk the failures, because that is where the design earns its keep. A failure writing the initial row just fails the request cleanly, nothing is live. A failure submitting to the exchange marks the order failed and tells the user. The nasty one is a failure after the exchange accepted but before we recorded the id, which leaves an order stuck in pending that is actually working in the market. This is why we need a reconciliation job. A background clean-up worker periodically scans for orders sitting too long in pending or pending_cancel, asks the exchange what actually happened (using the clientOrderId we attached on submit), and settles our record to match reality, recording the fill or marking it failed. That job is what turns "we might crash between two writes" from a correctness bug into a bounded, self-healing delay.
Exactly-once across a network is a fiction. What we actually build is at-least-once with idempotent reconciliation: every external call can be retried, every write can be replayed, and the state machine plus the clean-up job drive us to converge on the true state. That is the same backbone as the payment system and the digital wallet, where an append-only intent log and idempotency keys protect money, and the same at-least-once-plus-dedupe contract as the notification system. The rule worth stating outright is do not let a client retry place a second order. You prevent it with an idempotency key on POST /orders, so a repeated request returns the existing order instead of a duplicate.
Minimizing exchange traffic, and why the dispatch gateway is its own box
It is fair to ask why the Order Service does not just call the exchange itself. The answer is a clean separation of ownership. The Order Service owns the user-facing truth, meaning auth, validation, balance and risk checks, the order row, and the state transitions. The Dispatch Gateway owns exchange-facing delivery, meaning it concentrates all outbound traffic behind a small, fixed set of source IPs (the venue usually allowlists them for security, the same way a bank allowlists IPs for ACH), it centralizes retries, backoff, and circuit breaking, it paces requests under the venue's rate limits, and it translates our internal order format into the exchange's. If you only ever talk to one venue from one caller, you can fold this into the Order Service and move on. You split it out once you have many internal producers or more than one exchange, which is exactly when centralized rate-limit and IP control stop being optional.
One design tension is worth stating plainly, because it has no free answer. You could put a durable queue between the Order Service and the dispatch, which buys reliability and absorbs bursts, but it adds latency, and for order placement latency is a hard requirement. The reasonable position is that a queue on the order-submit path fights the latency budget, so you keep that path direct and make the dispatch tier absorb spikes by autoscaling aggressively or over-provisioning, while you happily use queues off the hot path, for the trade-processing and reconciliation work where a hundred milliseconds does not matter. Knowing where a queue helps and where it hurts is the actual signal.
Correcting the intuition about ordering
The first instinct most engineers have when they see the order-create sequence is to read it as a performance recipe. Write to the database, then call the exchange, then write the mapping, then respond. It looks like a chain you could shorten or reorder to shave a few milliseconds off the hot path. That reading is wrong. The sequence is a crash-safety decision, and every step is placed where it is so that a process dying at any point leaves behind a record you can recover from. We write the order as pending to the Orders DB before we ever talk to the exchange. If the box dies right after the exchange accepts the order but before we finish our own bookkeeping, we still have a durable trace saying an order with this idempotency key was in flight. Flip the order to save time and you create the one state you cannot tolerate, which is a live order resting at the exchange that your system has no memory of. Cancel works the same way. You mark pending_cancel first, then ask the exchange to cancel, because the intent to cancel has to survive a crash just as much as the intent to place did.
Same steps, two orders. Writing the pending record first means a crash is recoverable. Submitting first means a crash strands a real order in the market that nothing on your side remembers.
The second trap is conflating two channels that look like one. Placing an order is a synchronous request that returns an externalOrderId the moment the exchange accepts it. It is tempting to treat that response as confirmation that the trade happened. It is not. All it confirms is that the exchange took custody of your order. Whether and when shares actually change hands arrives later, on a completely separate asynchronous trade feed that pushes fills keyed by that same externalOrderId. A market order might fill in the same breath, and a limit order might rest for hours or never fill at all, and both return an id instantly on submit. So the id means accepted, not executed. Your UI and your ledger have to keep those two facts apart, which is exactly why we persist the id-to-order mapping on submit. It is the join key that lets an out-of-band fill find its way back to the right order and the right user shard once the money actually moves.
Third, give up on exactly-once before it costs you a week. The appeal is obvious. You want each order placed exactly one time, each fill applied exactly one time, and no doubles ever. But across a network boundary you cannot get it, because a timeout tells you nothing about whether the other side acted. Your request may have been lost on the way out, or the response may have been lost on the way back, and those two look identical to the caller. So the honest model is at-least-once with idempotent reconciliation. An idempotency key on POST /orders means a client that retries after a timeout collapses back onto the same order instead of placing a second one. A reconciliation job periodically sweeps anything stuck in pending or pending_cancel, asks the exchange what actually happened using our own client-side id, and settles the record to match reality. You are not preventing duplicate attempts. You are making duplicate attempts harmless, which is a far cheaper thing to build and a far more durable thing to trust.
Fourth, people reach for a global total order far more often than they need one. It feels safer to say every event in the system is stamped into one true sequence. That guarantee is expensive, because a single global sequence is a single point everything has to funnel through, and it is almost never what the business actually requires. What you usually need is per-key ordering. Events for one user, or one symbol, arriving in the order they were produced. That is cheap, and it falls out of partitioning by the key you already shard on. Be clear-eyed about what that does and does not buy you. Sharding the Orders DB by userId gives you a clean per-user order and a single-partition read for GET /orders, and it explicitly does not give you a global order of all orders by creation time across users. That is a feature, not a gap. You paid nothing for the ordering you need and skipped the bill for the ordering you do not.
This connects straight to how a real matching engine works, which is the one place a strict total order genuinely earns its keep. An exchange cannot let price-time priority be fuzzy, because who was first at a given price decides who gets filled. So it does not try to globally order the entire world. It runs a sequencer per symbol, a single logical writer that stamps a monotonic sequence number on every event for that one book. Inside a symbol you get a hard total order. Across symbols there is no shared clock and none is needed, because AAPL and TSLA never compete for the same fill. That is the same shape as our brokerage, just turned up to its strictest setting. Scope ordering to the key where it carries meaning, run one writer for that key, and refuse to pay for coordination between keys that never interact. When someone insists the system needs one global timeline, the right question back is which single key actually requires it, and the usual answer is none of them.
- Getting an externalOrderId back does not mean the order filled. It means the exchange accepted it. Fills arrive later on the async trade feed.
- Reordering the create steps to save latency can leave a live untracked order at the exchange after a crash. Write intent first, always.
- Sharding orders by userId gives per-user ordering, not a global order of all orders by creation time. Do not assume one implies the other.
- Chasing exactly-once across a network boundary is a trap. A timeout cannot tell you if the other side acted, so design for at-least-once plus idempotency instead.
- A global total order is not the default you fell back to for safety. It is an expensive choice you should only make for one specific key, the way a matching engine sequences per symbol.
Questions and answers
The same design, one more pass, as questions with the answers given outright. For the multiple-choice ones each wrong option is marked with why it is wrong, since the wrong turns are where the intuition actually forms. The ordering ones show the correct sequence.
- ✓It specifies both a target price and a share count, and it can rest on the exchange until it is filled or cancelled
- ✗It specifies only a share count, and it executes immediately at whatever the current market price is. That describes a market order, not a limit order.
- ✗It specifies both a target price and a share count, but like every order it either executes immediately or is rejected. A limit order can rest unfilled, it is not execute-or-reject.
- ✗It specifies only a target price, and the exchange fills as many shares as that price allows. A limit order also names a share count, not only a price.
- ✗True
- ✓False
- Write the order as pending to the Orders DB, recording intent first
- Submit the order to the exchange and receive the externalOrderId back
- Write the externalOrderId to (orderId, userId) mapping and mark the order submitted
- Respond success to the client
- ✓A pending record already exists, so a reconciliation job can later ask the exchange what happened and settle it, instead of a live order sitting at the exchange with no trace on your side
- ✗It shaves latency off the request, since the local DB write is faster than the synchronous exchange call. The ordering is for crash-safety, not latency.
- ✗It provides exactly-once order submission, guaranteeing the same order can never reach the exchange twice. The guarantee is at-least-once with idempotent reconciliation, not exactly-once.
- ✗It lets the Trade Processor derive the order's shard directly from the externalOrderId, because the mapping is written before the submit. The externalOrderId mapping is written after the submit, not before.
- ✗It guarantees exactly-once delivery of the order to the exchange from end to end.. You cannot get end-to-end exactly-once across a network boundary.
- ✓It stops the retry from placing a second order, and the system targets at-least-once processing made safe by idempotent reconciliation.
- ✗It tells the reconciliation job to skip that order so it is never re-checked.. The key does not exempt an order from reconciliation.
- ✗It lets the Trade Processor find the order by externalOrderId without needing the mapping store.. Finding an order by externalOrderId is the mapping store's job, not the idempotency key's.
- Order Service writes the order as pending to the Orders DB before doing anything at the exchange
- The submit reaches the exchange but the service crashes before it records the externalOrderId mapping
- The reconciliation job later scans for orders stuck in pending
- It asks the exchange what actually happened, keyed by clientOrderId
- It settles the order record to match reality, safely because the operation is idempotent
- ✓Benefit: GET /orders for a user hits a single partition. Cost: you cannot look up an order by externalOrderId without scanning every shard, so the KV map lets the Trade Processor find the order and shard from a fill.
- ✗Benefit: fills can be applied without any lookup. Cost: GET /orders must scan every shard, so the KV map exists to speed up per-user order listing.. This flips the benefit and the cost. The single-partition read is the benefit.
- ✗Benefit: it gives a global total order of all orders by creation time. Cost: per-user listing gets slow, so the KV map restores per-user ordering.. Partitioning by userId gives no global order.
- ✗Benefit: it lets the order path choose availability over consistency. Cost: idempotency keys stop working, so the KV map dedupes client retries.. The order path chooses consistency, and the map has nothing to do with idempotency keys.
- ✓True
- ✗False
- The Price Processor receives the executed trade from the exchange feed
- The Price Processor publishes the tick to that symbol's Redis pub/sub channel
- A subscribed Symbol Service server receives the tick from its Redis channel
- That Symbol Service server fans the tick out over SSE to every subscribed user
- ✗WebSockets cannot hold a persistent connection open, so SSE is the only transport that keeps the stream alive for sub-200ms updates.. WebSockets do hold a persistent connection.
- ✗SSE lets the client push its watched-symbol list back to the server over the same stream, which WebSockets cannot do.. SSE is one-way server to client, so the client cannot push its symbol list back over it.
- ✓Price updates flow one way, server to client, so SSE fits. WebSockets add bidirectional machinery the price push does not need, and polling either wastes a request when the price has not moved or adds up to a full polling interval of worst-case delay.
- ✗Polling is rejected because it cannot read from the Symbol Cache, so a polling client can never see the latest price.. Polling can read the cache fine. Its problems are wasted requests and up to a full interval of lag.
- ✗True
- ✓False
- ✗A queue would stop the exchange from returning the externalOrderId synchronously, which breaks the order-create sequence.. The exchange returns the externalOrderId synchronously whether or not there is a queue.
- ✗Queues cannot preserve per-user ordering, so one user's orders could reach the exchange out of sequence.. A queue can preserve per-key ordering, so that is not the issue.
- ✓During a trading burst, orders would sit in the queue while the dispatch tier scales up, adding latency that can blow the sub-200ms SLA exactly when volume peaks.
- ✗A queue gives at-most-once delivery, so orders would be silently dropped under load.. The system is at-least-once, not at-most-once, so orders are not silently dropped.
- ✓Sharding by userId gives cheap ordering within a single user's orders, but reconstructing a true global creation-time order across shards would need something like a global sequencer or synchronized clocks the design does not have, and the design does not need one anyway.
- ✗Each order row has a created-at timestamp, so a fan-out read across all shards sorted by that timestamp is guaranteed to be the exact global creation order.. Independent shards share no clock, so skew and ties break that timestamp merge.
- ✗The externalOrderId the exchange returns is globally monotonic, so sorting by it gives the exact order-creation sequence for free.. The externalOrderId is an opaque exchange id, not a creation sequencer.
- ✗Because the order path chooses consistency over availability, every write already flows through a single global log that records a total order.. Consistency here means not double-submitting, not funneling every write through one global log.
- ✓True
- ✗False
Follow-up questions
- Why Redis pub/sub instead of Kafka for price fan-out? Prices are ephemeral and we want lowest-latency delivery to whoever is listening right now, with no need to replay history, so pub/sub's fire-and-forget fits. Kafka shines when you need durable, replayable, ordered logs, which is the right tool for the trade and audit streams, not for a price tick that is worthless a second later.
- What happens to a limit order that sits unfilled for days? It stays
submittedat the exchange and in our Orders DB, and its mapping stays in the KV store, which is why that store must be durable rather than a short-TTL cache. The Trade Processor updates it if and when a partial or full fill arrives, and the user can cancel it at any time through the normalpending_cancelflow. - How does a client see its order fill in real time? The same way it sees prices. When the Trade Processor updates an order, it publishes an order-update event the user is subscribed to, and the client gets it over its existing SSE connection, so a fill lights up the UI without a poll.
- Where is the idempotency key and what does it protect? On
POST /orders, generated by the client. On a network retry the Order Service recognizes the key and returns the existing order rather than placing a second one, which is the single guard against a flaky connection turning one intended trade into two. - What breaks first at ten times the load? The price fan-out tier, specifically the number of open SSE connections and the pub/sub delivery to hot symbols. It scales by adding connection servers and sharding pub/sub by symbol. The order path, being a few tens of thousands of writes per second even at peak, grows linearly and much later.
References
- Payment for order flow, on how a commission-free broker is compensated.
- Redis, Pub/Sub documentation.
- MDN, Using server-sent events.
- Kleppmann, Designing Data-Intensive Applications (2017), on idempotence, secondary indexes, and consistency.
- Investopedia, Market Order vs. Limit Order, Plain-language explainer of the two core order types (immediate execution vs. price-guaranteed), the foundation of any order-entry model.
- SEC, Trade Execution: What Every Investor Should Know, Primary/official source on order routing, execution, best execution, and payment for order flow, the regulatory backdrop a brokerage must design around.
- Martin Kleppmann, Distributed Systems lecture notes (Cambridge, free PDF), Free 87-page notes on logical time and FIFO/causal/total-order broadcast, the theory behind consistently ordering trades across nodes.