Design a Low-Latency Market Data System
- Treat a market-data system as low-latency, low-level design with a microsecond, lossless bar
- Arbitrate the redundant A and B lines, and recover from a sequence gap via snapshot
- Defend one busy-polled thread with no locks, a cache-friendly book, and huge pages
Understanding the problem
Design the market-data system a trading firm runs to consume an exchange's feed. It receives the exchange's multicast stream, which arrives on two redundant lines, and decodes and normalizes each message. It rebuilds the order book from the exchange's incremental updates and publishes every change to the strategies that trade on it. Make the steady state fast and predictable. The time from a packet landing on the wire to the strategy seeing the book change should be the same handful of microseconds on the ten-millionth message as on the first. No update should ever be dropped without the system knowing it.
This is a low-latency low-level design question. The interesting part is what each message costs on the machine, and whether the firm's book stays a faithful copy of the exchange's book under a flood of updates. Then there is whether the worst-case latency stays close to the median instead of spiking whenever the feed bursts. Scaling to a billion users is beside the point. The functional surface is small: receive, decode, apply, publish. The depth is all underneath. Keep the hot path free of allocation and locks, and lay the book out so a mutation stays in cache. Two problems make a feed handler more than a parser: arbitrating the redundant lines and recovering from a lost packet.
Picture the shape before any code. An exchange publishes its book updates as a sequenced multicast stream. A kernel-bypass receive path pulls the packets off the wire without a trip through the kernel. A line arbitrator merges the two redundant feeds and forwards each sequenced message once. A decoder turns the exchange's wire format into an internal update, which is applied to the order book the firm keeps in memory. Every change becomes an entry on an SPSC ring that the strategies read. One direction, one hot thread, and a data layout chosen so the machine keeps up with the feed.
Functional requirements
Keep the core to the pipeline and let the interviewer widen it. The system has to receive the exchange's multicast messages off the wire. It has to arbitrate the two redundant lines the exchange sends, so that a message carried on both is applied exactly once and a message missing from one is still delivered by the other. It has to notice when a sequence number is skipped, meaning a packet was lost on both lines, and recover the missed updates rather than corrupt the book. It has to decode each message from the exchange's wire format into a normalized internal update and apply it to the order book it maintains. Then it publishes the resulting change to every strategy that consumes the feed.
In scope
- Receive the exchange multicast off the wire over a kernel-bypass NIC, busy-polled
- Arbitrate the A and B lines so each sequenced message is applied exactly once
- Detect a sequence gap and recover the missed updates from a snapshot or retransmit channel
- Decode and normalize the exchange wire format into an internal update
- Rebuild and maintain the order book from the exchange's incremental updates
- Publish each book change to strategy consumers over a lock-free SPSC ring
Below the line (name it, then skip)
- Sharding one feed across many machines (a feed handler lives on one core; run many, one per feed)
- The strategies themselves (they consume the book; pricing and order entry sit downstream)
- Matching and order entry (this is the consumer side; the exchange owns the match)
- Persistence, capture, and replay (a journal fed off the hot path, never on it)
- Session and connectivity management (login, heartbeats, and subscription requests sit around the handler)
Non-functional requirements
Reason through each target, because these are what draw the design later. Latency is the property everything bends around, and it is not the average that matters. A firm trading on this feed feels the tail. Take the one message in a million that took an extra microsecond because a buffer reallocated or a page faulted. That message made its price stale while everyone else's was fresh. So aim for a tight distribution. Receiving, decoding, and applying a message should each run in a small constant number of cycles. The worst case should sit close to the median instead of spiking when the feed bursts at the open or on a news print.
Correctness of the book is the second target, and on a feed handler it is not free. The exchange sends incremental updates, so the firm's book is only right if every update is applied, in order, exactly once. Multicast over the network can lose a packet or deliver duplicates, which is why the exchange sends two lines and stamps every message with a sequence number. The system has to be lossless in effect: it either applies each sequenced update or knows it has a gap and stops trusting the book until it has recovered the missing messages. A book that has quietly skipped an update is worse than a slow book, because the firm keeps trading on a picture that is wrong.
Determinism is the third target, and it is why the design leans away from anything that adds variance. No allocation on the hot path, because a call into the allocator is an unbounded pause waiting to happen. No lock on the shared state, because a contended lock turns a fixed-cost operation into one whose cost depends on another thread. No blocking system call in the loop, which is why the receive path bypasses the kernel and the thread busy-polls rather than sleeps. And scope it honestly, as low-level design demands. One feed is owned by one core. Throughput across many symbols or many venues comes from running many handlers side by side instead of parallelizing inside one.
- Latency is a distribution: tail, not average. The message that took an extra microsecond is the one that left a price stale. The target is a tight spread from median to worst case, so each stage is a small fixed cost that does not spike when the feed bursts.
- The book must never silently drift: lossless or known-gapped. Every sequenced update is applied in order exactly once. Otherwise the system knows it has a gap and recovers before trusting the book again. A quietly-skipped update is worse than a slow one.
- Nothing allocates on the hot path: pre-sized pools only. Orders come from a preallocated pool, price levels from a fixed array, and the rings are fixed buffers. The steady state touches only memory that already exists, so no operation waits on the allocator.
- One feed, one core: run many, not one big one. A single feed is owned by one busy-polled thread, so the hot path holds no lock. Throughput across symbols and venues comes from many handlers on many cores, which keeps each one deterministic.
State one framing choice out loud, because it steers everything downstream. There are two hard problems here and they should be reasoned about separately. Getting the bytes off the wire and the book laid out in memory is the latency problem. Applying exactly the right updates in exactly the right order is the correctness problem, and it lives almost entirely in line arbitration and gap recovery. Sketch the pipeline, then spend the design where the difficulty is, in those two places plus the memory layout that keeps the common path fast.
The pipeline, stage by stage
Lay the pipeline out as a straight line from the wire to the strategy, because that shape is the design. Read the diagram by role. Blue is an endpoint at the edge, the exchange upstream and the strategies downstream. Violet is a component we run on the hot path. Green is state we keep in memory. Amber is the one branch that leaves the fast path, the moment a gap sends the system into recovery.
Name the stages and what each owns, because the interviewer grades the boundaries as much as the code. The receive stage gets bytes off the wire with no kernel in the way. The arbitrator turns two lossy lines into one trustworthy ordered stream and raises a flag the moment it cannot. The decoder normalizes the wire format so nothing downstream knows which venue it came from. The book applies updates and holds the state. The publisher hands each change to consumers over a ring it owns alone. Everything on the fast path is one of these, and everything slow, recovery, capture, connectivity, hangs off to the side.
Kernel-bypass receive, busy-polled
A tight loop polls the network card and pulls packets directly into user space, no interrupt and no system call. Spinning instead of sleeping costs a core but removes the wakeup latency and its jitter, so a packet is in hand a few hundred nanoseconds after it lands, message after message.
Line arbitration and sequencing
Both lines feed one arbitrator that forwards each sequence number exactly once. It drops what the other line already delivered, passes the awaited message, and flags a number missing from both as a gap. This is where two lossy UDP streams become one ordered, deduplicated, trustworthy stream.
Decode and normalize
Each raw message is decoded from the venue wire format into an internal update with price in ticks and a normalized id. This is the seam that lets one system carry many venues: everything above it sees one shape, so the book and the strategies never learn which exchange a message came from.
Apply to the book
The normalized update mutates the in-memory book in constant time: index the price level by tick, push, unlink, or adjust quantity, and nudge best bid or ask. No search, no sort, no allocation, because the layout and the pools were chosen to make the common mutation a handful of cache-warm operations.
Publish to consumers
Each change is written as a compact delta to an SPSC ring per strategy and the handler goes straight back to the wire. Encoding for any external destination and any capture to disk happen off this thread, so publishing costs only an append to a queue the handler owns alone.
Sequence gaps and A/B line arbitration
This is the section that separates a feed handler from a parser, so slow down on it. The exchange sends its book as a stream of increments, each stamped with a sequence number that goes up by one. The firm's book is correct only if it applies every increment, in order, once. Multicast gives no such guarantee on its own: a packet can be lost, and the same packet can arrive twice. The exchange's answer is redundancy plus sequencing. It sends two independent lines carrying the identical numbered stream, and it publishes a recovery channel for the rare message that both lines drop.
Arbitration is the logic that turns those two lines into one trustworthy stream. The handler tracks the next sequence number it still owes its consumers. When a message arrives on either line, three things can be true. Its number is below what the handler expects, meaning the other line already delivered it, so it is a duplicate and gets dropped. Its number is exactly what the handler expects, so it is the next message, and it passes through and the expectation advances. Or its number is above what the handler expects, which means the awaited message was missing from both lines, and now there is a real gap. The first two cases are the whole common path, and they are cheap. The third is the case worth designing around.
Approach
Subscribe to a single line, apply each message to the book as it arrives, and hope the network does not drop anything. It is simple, it has no arbitration logic, and on a quiet feed in a lab it looks like it works.
Challenges
It is wrong in exactly the way that costs money. A single multicast line loses packets, so sooner or later the book skips an update. A missed cancel leaves an order lingering that has really gone. A missed add leaves a level showing less size than the market has. Nothing detects it, so the firm keeps quoting against a book that has silently diverged from the exchange's, and the error compounds with every further update to that level. This is the version to name and reject, because it fails the one requirement that is not about speed: the book must never drift without the system knowing.
Approach
Listen to both lines and forward each sequence number once, taking whichever line delivers it first and dropping the duplicate from the other. Track the expected sequence number so a skip is detected the instant it happens. On a gap, stop trusting the affected book, buffer the live messages that keep arriving, and pull the missed range from the snapshot or retransmit channel. Replay the buffer on top of the recovered state until the handler has caught back up to live, then resume normal publishing.
Challenges
This meets the correctness bar and keeps the common path fast, because arbitration is a couple of integer comparisons per message and recovery only ever runs on the rare real gap. State the costs plainly. You need a bounded reorder buffer for messages that arrive slightly out of order across the two lines, and memory to hold live updates while recovery is in flight. You also have to define what consumers see during a gap. That is usually a "book is recovering" state for those symbols instead of deltas you cannot vouch for. This is the version to build, and it is the part of the answer an experienced panel is really listening for.
// The A and B lines carry the SAME sequenced stream on two paths.// The arbiter forwards each sequence number exactly once and flips// into recovery the moment one is missing from both lines.class LineArbiter { uint64_t expected_seq_ = 0; // the next sequence number we still owe consumers bool recovering_ = false;public: // Called for every message off either line. Returns true when the // message should be applied to the book now. bool accept(uint64_t seq) { if (recovering_) { buffer_during_recovery(seq); return false; } if (seq < expected_seq_) return false; // the other line already delivered it if (seq == expected_seq_) { // exactly what we were waiting for ++expected_seq_; return true; } // seq > expected_seq_: the awaited message was lost on BOTH lines. // Do not guess and do not plow on; recover off the hot path. recovering_ = true; request_recovery(expected_seq_, seq); // snapshot / retransmit channel buffer_during_recovery(seq); return false; }};Two details are worth saying by name. First, a small reorder tolerance is normal. The two lines are independent, so a later sequence number can arrive a beat before the one you are waiting for. A short buffer sorts that out without declaring a false gap. The line above treats "ahead" as a gap for clarity, but a production handler waits a bounded moment before it gives up on the awaited message. Second, recovery is a state, not an instant. While it runs, live messages keep coming and have to be held, and the handler is only back to normal once it has replayed the buffer up to the live sequence. Getting that catch-up right, so the book is exactly correct at the seam between recovered and live, is the point an interviewer will probe.
The hot path: pools and a cache-friendly book
With arbitration settled, the steady state is short, because the layout does the work. The book the handler keeps is the same shape as any fast order book. Each side is a flat array of price levels indexed by tick, and each level holds a FIFO of resting orders. An id map turns a cancel or modify into a constant-time unlink. The orders live in a preallocated pool, so applying an update never calls the allocator. Read an update, index the level, splice a node or adjust a quantity, publish the delta. Nothing searches, nothing sorts, nothing allocates once the handler is running.
// An order is an intrusive list node: its links live inside the struct,// so resting it costs no separate allocation and the node plus its links// share a cache line. Hot fields sit together, near the front.struct Order { uint64_t id; // exchange order id, the key in the id-to-node map uint32_t qty; // remaining quantity, adjusted on partial fills int32_t price_tick; // price in whole ticks, the index into the level array Order* prev; // intrusive links within this price level's FIFO Order* next;};// One price level: aggregate resting quantity and the head and tail of// the FIFO of orders at this price.struct PriceLevel { uint64_t total_qty = 0; Order* head = nullptr; Order* tail = nullptr;};// The firm's copy of one symbol's book, rebuilt from the exchange's// increments. Two arrays of price levels indexed by tick, an id map for// O(1) cancel and modify, and a pool so applying an update never mallocs.class Book { std::array<PriceLevel, PRICE_SLOTS> bids; // buy side, index = price tick std::array<PriceLevel, PRICE_SLOTS> asks; // sell side std::unordered_map<uint64_t, Order*> by_id; // exchange id -> node, O(1) cancel ObjectPool<Order> pool; // preallocated, no hot-path malloc int32_t best_bid = -1; int32_t best_ask = PRICE_SLOTS;public: void on_add(uint64_t id, Side side, int32_t tick, uint32_t qty); void on_cancel(uint64_t id); // resolve id, unlink in O(1) void on_exec(uint64_t id, uint32_t filled); // exchange printed a trade};The whole hot path fits in one loop on one core, and writing it out is the clearest way to show it holds no lock, makes no system call, and allocates nothing. Spin on the card, pull a packet, walk its messages through the arbiter, decode what survives, apply it, and publish the change. A packet carries many updates, so the per-packet fixed cost amortizes over a batch, which is one reason the design prefers a few larger operations over many tiny ones.
// The entire fast path on one core: busy-poll the card, arbitrate the// two lines, decode, apply to the book, publish the delta. No syscalls,// no locks, no allocation in the steady state.void FeedHandler::run() { for (;;) { // busy-poll: never sleep Packet* p = nic_.poll(); // kernel-bypass receive, no syscall if (!p) continue; // nothing yet, spin again for (RawMsg m : p->messages()) { // one packet carries many updates if (!arbiter_.accept(m.seq)) continue; // duplicate or gap: skip it Update u = decoder_.normalize(m); // venue wire form -> internal update book_.apply(u); // O(1) mutation, cache-warm out_ring_.push(book_.delta(u)); // hand the change to a consumer } nic_.release(p); }}One thread, busy-poll, and no locks
This is the section the round is really testing, because the instinct to reach for threads is where latency designs go wrong. The book is shared mutable state under a flood of updates, so the reflex is to protect it with a lock and let several threads work the feed in parallel. On a latency-critical path that reflex is the bug. A lock turns a fixed-cost operation into one whose cost depends on what another thread is doing. It is cheap uncontended and a stall when contended. Threads collide at the moment the feed bursts, so the lock adds cost when you can least afford it. Worse, it adds variance, and variance is the tail the whole design exists to flatten.
Approach
Let a pool of threads pull messages off the feed and apply them to one shared book, guarding it with a mutex, or a lock per price level for finer grain. It looks like it uses the cores you paid for, and it passes a single-threaded test cleanly.
Challenges
It is slower where it counts and it is no longer deterministic. Applying an update in order is inherently sequential, since update N+1 assumes N already landed, so the threads serialize on the lock and you get the cost of locking without the parallelism. The tail balloons because a thread can wait an unbounded time for a contended lock, and lock-per-level buys that back only by risking deadlock when a single message touches several levels. You spent complexity to make the common path both slower and less predictable, and you still have to reimpose ordering the lock does not give you. This is the version to show you can reject.
Approach
Give the whole fast path a single owner thread: it busy-polls the card, arbitrates, decodes, applies to the book, and appends the delta to an outbound ring, in arrival order, message after message. Because only that thread ever touches the book, no operation on it takes a lock. The one place other threads meet it are the ring buffers, and a single-producer, single-consumer ring is lock-free by construction, needing only two cache-line-separated indices to hand a slot across.
The tradeoff is honest and worth stating. Removing the lock removes contention instead of managing it. Each stage is a fixed cost and the tail stays tight. Ordering falls out for free, because one thread applies updates in the order it reads them. Parallelizing one feed cannot be done cheaply given the ordering constraint, so throughput comes from partitioning. Assign each feed, or each group of symbols, to its own core and run many single-threaded handlers side by side. A machine carries many feeds while every individual book stays deterministic. The cost is that one very hot feed is bounded by one core's speed. So every stage on that core is kept to the cheapest operations there are: integer compares, array indexing, and list splicing. The thread also spins instead of sleeping, since a wakeup from the kernel would cost more than the work it woke up to do.
Memory, cache, and huge pages
The data structures were chosen for their memory behavior, so make that reasoning explicit, because it is the register the round lives in. A cache miss to main memory costs on the order of a hundred nanoseconds, while an add or a branch costs a fraction of one. On the hot path a single miss can dwarf all the arithmetic around it. That one fact drives almost every layout choice: keep the data a message touches contiguous and small enough to stay in cache, and avoid designs that chase a pointer to somewhere cold. The array of price levels keeps the top of book in a few cache lines. The intrusive links keep an order and its pointers on one line. The pool keeps order nodes packed together, so walking a level's queue touches warm memory.
- Contiguous over pointer-chasing: arrays beat trees. The price-level array keeps the top of book in a handful of cache lines, so working near the best price stays hot. A tree of price nodes scattered across the heap misses the cache on every hop, and the misses, not the comparisons, are the cost.
- Intrusive links, pooled nodes: no wrapper allocation. Links live inside the Order struct and nodes come from a pool, so an order and its list pointers share a line and pooled orders sit near each other. Traversing a level touches packed memory instead of scattered heap.
- Cache-line separation on the ring: kill false sharing. The producer and consumer indices of each SPSC ring sit on their own cache lines, so the two threads never invalidate each other. Sharing one line would trade a nanosecond handoff for a coherence stall.
- Huge pages shrink the page-table walk: fewer TLB misses. Backing the book and pools with 2 MiB or 1 GiB pages instead of 4 KiB means far fewer TLB entries cover the working set. A translation miss, and the dependent memory loads that resolve it, then happen far less often on the hot path.
Spell out false sharing. It is a favorite probe, and it is invisible in the code. Two variables that logically belong to different threads can land on the same 64-byte cache line. Then every write by one thread invalidates the other thread's copy, forcing a reload across cores even though neither touched the other's data. The fix is padding: put the ring's head and tail indices on their own lines. The usual mechanical-sympathy habits apply too. Keep branches predictable so the pipeline is not flushed, and prefetch the next order node while working the current one. Prefer a few larger operations over many tiny ones so fixed costs amortize.
Publishing to strategy consumers
Every change to the book has to reach the strategies, because they price and decide against exactly this feed and it must never disagree with the book the handler holds. The trap is to publish inline: encode the update and push it to wherever it is going from inside the hot loop. That puts serialization, and possibly I/O, on the critical path. The time to process a message now depends on how fast the slowest consumer or destination drains. The design exists to avoid that coupling. The fix is the same lock-free handoff used on the way in, in reverse.
The handler writes each book change as a compact record to a per-consumer SPSC ring and goes straight back to the wire. Each strategy drains its own ring on its own core, so one slow reader cannot stall the handler or any other reader. A strategy that falls behind is served a conflated top of book: the current best bid and ask, without every intermediate delta. It catches up on the state that matters instead of dragging the producer down. Anything genuinely slow, encoding for an external destination, capture to disk for later analysis, monitoring, is drained off yet another ring by a separate thread. The principle holds across the whole design: the one thread whose latency is money does only the work that must happen now, and everything that can wait is moved off it.
What the round rewards
The bar here is mechanical sympathy, determinism, and correctness of the book, not scale. The design conversation reads as a discussion, so narrate the mechanism plainly. Lay out the pipeline from wire to ring and scope it to one feed on one core. Then spend real time on the two places the difficulty lives. Get the line arbitration and gap recovery right, because a feed handler that cannot survive a lost packet is not a feed handler. Defend the book's layout by its behavior in cache, and design the lock away with a single busy-polled owner and lock-free rings. Expect the panel to push down into the memory and systems layer, because that under-the-hood fluency is what this loop is built to test.
Reads as strong
- Scopes to one feed on one core early, and runs many handlers for throughput rather than parallelizing one feed
- Treats the book as lossless-or-known-gapped: arbitrates both lines, detects the skip, recovers from a snapshot channel off the hot path
- Picks the direct-indexed price-level array and the id map, and defends them by cache behavior, not habit
- Designs the lock away with a single busy-polled owner thread and lock-free SPSC rings, and keeps the hot path allocation-free
- Reasons in cache lines, TLB entries, and huge pages, and treats latency as a distribution with a tail
Reads as off-target
- Reaches for sharding one feed across machines, or for capacity estimation, before the single-core pipeline is even sketched
- Trusts one line and applies as it comes, letting the book silently drift when a packet is lost
- Locks the shared book, or locks per level, and accepts the tail spike and the deadlock risk that come with it
- Publishes inline from the hot loop, coupling message latency to how fast the slowest consumer drains
- Allocates on the hot path, or blocks in a system call, and answers only in big-O with no account of what the machine does
Common questions
Why two lines, and what does arbitration do?
The exchange sends its sequenced stream over two independent multicast paths so that a packet lost on one is usually still delivered on the other. Arbitration merges them into a single ordered stream. It tracks the next sequence number it owes consumers and drops a number the other line already delivered. It passes the awaited one and treats a number missing from both as a real gap. It is a couple of integer comparisons per message, so it costs almost nothing on the common path while turning two lossy feeds into one trustworthy one.
How do you handle a sequence gap?
A gap means the awaited message was lost on both lines, so the book can no longer be trusted for the affected symbols. The handler stops publishing normal deltas for them and buffers the live messages that keep arriving. It pulls the missed range from the exchange's snapshot or retransmit channel, a separate, slower path kept off the hot loop. It then replays the buffered live messages on top of the recovered state until it has caught up to the live sequence, and only then resumes normal publishing. The key judgment is that you recover rather than guess, and that recovery never adds cost to the common case.
Why one thread rather than parallelizing the feed?
Because applying increments is inherently ordered: each update assumes the previous one landed. Threads sharing the book serialize on a lock anyway, so you pay for locking without gaining parallelism, and the tail balloons on contention. A single busy-polled owner thread takes no lock on the book, keeps updates in arrival order for free, and stays deterministic. Throughput comes from partitioning, one feed or symbol group per core with many handlers side by side, not from contending over one shared structure. The one cost is that a single hot feed is bounded by one core, which is why every stage is kept to the cheapest operations there are.
How do you keep the hot path allocation-free?
Everything the steady state needs is preallocated. Orders come from a pool with a free list, so applying an add takes a node and a cancel returns it, with no trip to malloc. Price levels are a fixed array sized to the instrument's price band, and the rings are fixed-size buffers. The only allocations happen at startup, ideally on huge pages so the working set needs few TLB entries, so once the handler is running it touches only memory that already exists. That is what keeps each message a fixed cost instead of occasionally paying for an unbounded allocator pause or a page fault.
Why does busy-polling beat an interrupt-driven, sleeping receiver?
Because a sleeping thread has to be woken. The wakeup, the interrupt, the scheduler, and the return to user space can cost more and jitter more than the work it woke up to do. Spinning on a kernel-bypass receive keeps the thread hot and the data path in user space, so a packet is in hand a few hundred nanoseconds after it arrives with very little variance. The price is a burned core, which is a deliberate trade: on a latency-critical feed, a core is cheap and a predictable microsecond is not.
Key Takeaways
- ✓This is low-latency low-level design. The win is a deterministic hot path on one core from the wire to the ring. The target is a tight latency distribution with no allocation and no lock, and a book that never silently drifts. Scope to one feed on one core and say so early.
- ✓The problem that separates a feed handler from a parser is correctness under loss. Arbitrate the two redundant lines so each sequenced message is applied once, and detect a gap the instant a sequence number is missing from both. Recover from the snapshot channel off the hot path instead of guessing or plowing on.
- ✓Win it on concurrency by designing the lock away. A single busy-polled owner thread applies updates in order with no lock on the book, publishing over lock-free SPSC rings. Throughput comes from running many handlers, one per feed, instead of contending over one shared structure.
- ✓Show mechanical sympathy in the register HRT interviews in. That means a cache-friendly book of arrays and pooled intrusive lists, cache-line separation to kill false sharing, and huge pages to shrink the page-table walk and the TLB miss. Reason in cache lines and page-table loads, not big-O alone.
Hudson River Trading interview guideKnow what Hudson River Trading asks before you walk in.
7 coding · 1 system design · 16 behavioral questions
Get their questions first.
InterviewLegend is independent and not affiliated with or endorsed by any company it covers.