Design a stock brokerage (Robinhood)

Systems design · Fintech and real-time · Jul 2026

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.

The one-line version. A brokerage is a read-fan-out problem stacked on a write-consistency problem. Prices are one-to-many and disposable, so we decouple the exchange feed from clients with Redis pub/sub and push over SSE. Orders are money and must never be lost or duplicated, so we model them as a state machine, write our intended state before we call the exchange, and run a reconciliation job to converge after failures.

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, timestamps

The 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 clients

The 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.

Clientsmany Symbol Servicetracks symbol → Set<userId>fans out over SSE Symbol Cachelatest price Redis pub/subchannel per symbol Price Processorholds exchange feed Exchangeexternal subscribe SSE prices subscribe channel consume ticks publish initial update cache

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.

Order Servicewrites both stores Orders DBsharded by userId External-order KV storeexternalOrderId → (orderId, userId) Dispatch Gatewayto exchange Exchangeexternal Trade Processortails fills feed 1. write pending 2. submit execute 3. map externalOrderId 4. fills feed 5. look up 6. update the right 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.

pending submitted filled partially_filled pending_cancel cancelled failed accepted fill partial user cancels confirmed error / rejected Clean-up job scans stuck pending & pending_cancel, asks the exchange, reconciles

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.

Intent firstrecoverable write pending submit to exchange reconcile settles it crash safe Submit firstuntracked submit to exchange no record kept crash a live order rests at the exchange with no trace on our side

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.

Intuition traps to watch for.
  • 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.

1A customer submits a LIMIT order to buy 50 shares at a target price. Compared with a market order, what does the limit order specify, and what can it do at the exchange?
  • 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.
Why: The natural assumption is that every order fills the moment you send it, but that is only true for market orders. A limit order carries a target price plus a share count and its defining trait is that it can sit unfilled, or rest, on the exchange until its price is met or the customer cancels it. A market order carries only a share count and fills right away at the current price.
2In this design, a market order carries a target priceInCents that the exchange must match before it will execute.
  • True
  • False
Why: It is tempting to assume every order has a price field, but a market order specifies only a share count and executes immediately at the current market price, so it has no target to match. The priceInCents target belongs to the limit order, the one that can rest until that price is reached.
3Put the steps of the order create sequence in the correct order. The goal here is crash-safety, not raw speed.
  1. Write the order as pending to the Orders DB, recording intent first
  2. Submit the order to the exchange and receive the externalOrderId back
  3. Write the externalOrderId to (orderId, userId) mapping and mark the order submitted
  4. Respond success to the client
Why: Recording intent as pending first is the whole point. If the service submits to the exchange and then crashes, a pending row already exists, so the reconciliation job can find it and settle it. Submitting to the exchange first would risk a live, untracked order with no record on your side. The cancel path mirrors this: mark pending_cancel, submit the cancel, then mark cancelled.
4The create sequence writes the order as pending to the Orders DB before submitting it to the exchange. What does this ordering buy you if the service crashes right after the exchange accepts the order but before anything else is recorded?
  • 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.
Why: The ordering buys crash-safety, not speed, so the latency framing misses the point. Writing pending first means a crash right after the exchange accepts leaves a recoverable trace that reconciliation settles by asking the exchange what happened. It does not give exactly-once submission (the guarantee is at-least-once with idempotent reconciliation, and client-retry duplicates are stopped by an idempotency key), and the externalOrderId mapping is written after the submit, not before.
5A client's POST /orders request times out, so the client retries it with the same idempotency key. What does that key actually buy you, and what guarantee does the order system aim for overall?
  • 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.
Why: It is tempting to read the idempotency key as an exactly-once guarantee, but it is not. The key only stops a client retry from creating a duplicate order. Across a network boundary to an external exchange you cannot get true exactly-once, so the design settles for at-least-once and makes retries safe by keeping reconciliation idempotent.
6Order Service crashes mid-submit and the reconciliation job later cleans up. Put the events in the order that makes the order recoverable.
  1. Order Service writes the order as pending to the Orders DB before doing anything at the exchange
  2. The submit reaches the exchange but the service crashes before it records the externalOrderId mapping
  3. The reconciliation job later scans for orders stuck in pending
  4. It asks the exchange what actually happened, keyed by clientOrderId
  5. It settles the order record to match reality, safely because the operation is idempotent
Why: The instinct is to write pending last (or skip it) to save a round trip, but the ordering here is about crash safety, not speed. Recording intent as pending first means a crash after submit leaves a recoverable trace the reconciliation job can find and settle, instead of a live untracked order sitting at the exchange. Keying the exchange query by clientOrderId matters because a crash before the externalOrderId mapping is written leaves clientOrderId as the only handle you still hold.
7The Orders DB is a relational store sharded by userId. What does this choice buy you, and what does it cost that forces a separate externalOrderId -> (orderId, userId) KV map?
  • 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.
Why: The tempting move is to flip the benefit and the cost. Sharding by userId makes per-user reads cheap (one partition) but makes an externalOrderId lookup expensive because that id is not the shard key, so a fill would have to scan every shard. The KV map fixes exactly that. It has nothing to do with global ordering (partition-by-userId gives no global order) or with idempotency (an idempotency key on POST /orders handles retries).
8As an alternative to the externalOrderId -> (orderId, userId) KV map, you can attach your own clientOrderId to the order that the exchange echoes back on each fill, so the Trade Processor recovers the orderId and userId straight from the fill. Because the exchange controls what it echoes, you validate or sign that id and keep the ids opaque.
  • True
  • False
Why: The KV map is not the only option. The clientOrderId echo is a real alternative that removes the lookup by carrying your own routing id back on the fill. The catch people miss is trust. You cannot take an id the exchange hands back on faith, so you validate or sign it and keep it opaque rather than embedding raw internal ids.
9Put the price-update path in the order events actually happen, from the exchange reporting a trade to the number changing on a user's screen.
  1. The Price Processor receives the executed trade from the exchange feed
  2. The Price Processor publishes the tick to that symbol's Redis pub/sub channel
  3. A subscribed Symbol Service server receives the tick from its Redis channel
  4. That Symbol Service server fans the tick out over SSE to every subscribed user
Why: A common wrong mental model has each Symbol Service listening to the exchange directly. It does not. Only the small Price Processor pool holds the exchange feed and publishes to a per-symbol Redis channel, and Redis delivers only to the Symbol Service instances that currently subscribe because they have a client watching that symbol. Those instances are the ones that push over SSE.
10In the price fan-out design, why is SSE chosen for pushing price updates instead of WebSockets or polling?
  • 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.
Why: The temptation is to reach for WebSockets because they feel more capable, but bidirectional framing is wasted on a one-way price push. WebSockets do hold persistent connections and SSE is not the only option, so that reasoning is wrong. SSE itself is server-to-client only, so a client cannot push its symbol list back over it. Polling can read the Symbol Cache fine, it just wastes requests and can lag by a full interval, which is what breaks the sub-200ms target.
11The Order Dispatch Gateway funnels all exchange traffic through a small fixed set of egress IPs mainly to shave network latency by keeping the number of outbound connections low.
  • True
  • False
Why: The fixed egress IPs exist because trading venues allowlist source IPs, so you need a small stable set to stay authorized to connect. It is not a latency trick, and low connection count is not the goal on this path. The gateway also centralizes retries, backoff, circuit-breaking, rate-limit pacing, and format mapping to the exchange, but the egress IP decision is specifically about matching the venue allowlist.
12The order-submit path sends orders straight through the dispatch gateway with no durable queue in front of it. Why is a queue avoided on this specific path?
  • 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.
Why: The tempting intuition is that a queue absorbs bursts and protects you. On the submit path it does the opposite for latency, since orders buffer while the tier scales and latency is the one thing you cannot spend during a burst. Queues are fine off the hot path, like trade processing and reconciliation, where a small delay does no harm.
13The Orders DB is sharded by userId. A teammate wants a feed that lists every order placed across all users in exact global creation-time order. What is the honest answer about getting that from this design?
  • 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.
Why: It is tempting to assume a timestamp column plus a cross-shard read gives you global creation order, but independent shards and hosts do not share a clock, so skew and ties make that merge unreliable. A true total order would need a global sequencer or synchronized clocks the design never built, and per-user ordering within a shard is all the product actually uses. The externalOrderId is an opaque exchange id, not a creation sequencer, and consistency-over-availability here is about not double-submitting, not a global log.
14In this design, keeping per-user ordering for a given user's orders and per-symbol ordering for a given symbol's price ticks is enough. A single global total order across every event in the whole system is not required.
  • True
  • False
Why: The reflex is that a distributed system must impose one global total order on everything, but that is expensive and usually unnecessary. Here per-user ordering of orders and per-symbol ordering of ticks cover what the product needs, and fills are matched per order by externalOrderId, so no system-wide total order is required.

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 submitted at 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 normal pending_cancel flow.
  • 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

  1. Payment for order flow, on how a commission-free broker is compensated.
  2. Redis, Pub/Sub documentation.
  3. MDN, Using server-sent events.
  4. Kleppmann, Designing Data-Intensive Applications (2017), on idempotence, secondary indexes, and consistency.
  5. 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.
  6. 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.
  7. 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.