Marketplace & Booking
Design DoorDash / Uber Eats
01
What each side needs
What each side needs
- Customers browse restaurants that deliver to their address, open menus with modifiers, and see a delivery time estimate and fee before ordering.
- Placing an order authorises the payment and sends the order to the restaurant's tablet or point-of-sale system, which accepts it and confirms a prep time.
- The platform offers the delivery to a courier, who accepts or declines; a courier may carry two orders at once from nearby kitchens.
- Customers watch the courier on a map with an ETA that updates as the trip progresses.
- After delivery the payment is captured, the tip can be adjusted for a window, and refunds can be issued for missing items.
- Restaurants can pause their store, mark items sold out, and change prep time when the kitchen gets busy.
- Checkout completes in under 800 ms at p99 and never charges twice for one order.
- A courier is assigned within 30 seconds for most orders, and the assignment respects the predicted ready time.
- The tracking map refreshes at least every 5 seconds while a courier is moving.
- Ordering in one city keeps working if another city's dispatch has a problem.
- Order and payment records are strongly consistent; restaurant listings and ETAs may be slightly stale.
Name all three apps and say which you will go deep on. Dispatch and the order lifecycle are where the interesting work is. Search and menus are a fairly standard geo-filtered catalogue, so keep them brief unless the interviewer asks.
02
Sizing the marketplace
Sizing the marketplace
Assume 4.5 million orders a day across 900 cities, with dinner concentrating demand. Assume 220,000 couriers online at the busiest moment, each phone reporting a GPS fix every 5 seconds while on shift.
| Quantity | Working | Result |
|---|---|---|
| Average order rate | 4.5M / 86,400 s | about 52 / s |
| Dinner peak order rate | about 17x average, evening hours stacked across time zones | about 900 / s |
| Courier location pings | 220,000 / 5 s | 44,000 / s |
| Browse requests | about 25 page and search views per order, at peak | about 22,000 / s |
| Delivery quotes | ETA and fee shown on every listing card, cached per H3 cell | about 6,000 model calls / s |
| Live tracking viewers | orders in flight at peak, about 35 min each | about 1.9M open sessions |
| Order record size | items, modifiers, prices, status history, payment refs | about 8 KB |
| Order data per day | 4.5M x 8 KB | about 36 GB, 13 TB a year |
| Location history per day | 44,000 / s average-weighted, about 2B fixes x 40 B | about 80 GB, kept 90 days |
900 orders a second is modest for a transactional database. The heavy streams are 44,000 location writes a second that are useful for only a few seconds each, and browse traffic that is almost entirely cacheable. Treat them as three different systems.
03
API
API
04
Three domains, one event stream
Three domains, one event stream
flowchart LR
CU["Customer app"] --> GW["API gateway"]
ST["Store tablet or POS"] --> GW
CR["Courier app"] --> GW
GW --> CAT["Catalogue and search"]
GW --> ORD["Order service, saga"]
ORD --> PAY["Payments"]
ORD --> ODB[("Orders DB, sharded by city")]
ORD --> EV[["Kafka order events"]]
GW --> LOC["Location ingest"]
LOC --> GEO[("H3 courier index, in memory")]
EV --> DSP["Dispatcher per city"]
DSP --> GEO
DSP --> ETA["ETA and prep-time models"]
EV --> TRK["Tracking push"]Split the product along the lines where data and failure behaviour differ. The catalogue owns stores, menus, hours and search. It is read-heavy and tolerant of staleness, so it lives behind caches keyed by H3 cell and is rebuilt from a store database and a search index. The order service owns money and promises: it runs each order as a saga, stores it in a relational database sharded by city, and publishes every state change to Kafka. Logistics owns couriers, their locations, dispatch and ETAs, and keeps most of its working state in memory per city.
The three talk through events, not through synchronous calls on the hot path. When an order is accepted by the store, the dispatcher for that city learns about it from the event stream, and when a courier is assigned, the order service learns about it the same way. That boundary is what lets dispatch in one city slow down without stopping checkout everywhere.
Courier pings arrive at a stateless location ingest tier, which drops implausible fixes, snaps them to roads where cheap, and writes the latest position into an in-memory geo index partitioned by city. The same ping goes onto a Kafka topic for the tracking service, the ETA model's features, and cold storage for analytics. Only the latest position is kept hot; history goes to a log.
The tracking push service holds the customers' open streams. It subscribes to order events and to the location topic for couriers on active orders, and forwards only what each customer needs. Nobody polls the order database to watch a pin move.
Each city keeps a map from H3 cell to the couriers inside it, at resolution 8 (cells about 0.7 km2). A nearby query reads a ring of cells, which is a handful of hash lookups rather than a spatial scan.
Postgres sharded by city, with order rows, line items and an append-only status history. A single city's peak is a few dozen writes a second, so each shard is comfortable.
Served behind a feature store. Prep time comes from the store's history, current queue and time of day; travel time from road graph routing corrected by recent trips.
Tablets keep a persistent connection for new orders, with SMS and a phone call as fallbacks. Large chains integrate their point-of-sale systems through a webhook adapter.
05
Deep dive: who picks up this order, and when
Deep dive: who picks up this order, and when
Dispatch is not "find the closest courier". It is get a courier to the counter just as the food is ready, without stranding the courier for twenty minutes and without letting the food go cold. Every decision trades customer wait, courier idle time and how many couriers the city needs.
Timing first, then matching
When a store accepts an order and says "ready in 18 minutes", the dispatcher does not need a courier right away. It needs a courier whose travel time to the store is about 18 minutes from now, or less with a small buffer. So the first output of dispatch is a target assignment time: the moment at which assigning a courier lets them arrive close to the predicted ready time. The prep-time model matters as much as the routing model, because restaurants are often optimistic during a rush. A store that says 15 minutes but historically needs 22 at Friday dinner gets 22 in the plan.
Orders whose target time has arrived enter the matching pool. Assigning them early wastes courier time at the counter; assigning them late makes the customer wait. Couriers enter the pool when they become free or when they are about to finish a drop-off nearby.
flowchart TD
A["Every 5 s: collect orders whose target time has arrived"] --> B["For each order, find couriers within a few H3 rings"]
B --> C["Score each pair: travel ETA, wait at store, lateness risk, courier fairness"]
C --> D["Add pair options for stacking two orders on one courier"]
D --> E["Solve min-cost assignment across the whole pool"]
E --> F["Send offers with a 40 s expiry"]
F --> G{"Accepted?"}
G -->|yes| H["Commit assignment, publish event"]
G -->|"no or timed out"| I["Return order to next cycle with a higher urgency"]Why batch the decision
Assigning each order the moment it is ready to dispatch, to the best courier available, is greedy. Picture two orders and two couriers. Order A arrives first and takes courier X, who is 2 minutes away. Order B, a second later, gets courier Y, who is 12 minutes away. Had A taken Y (4 minutes from A) and B taken X (3 minutes from B), total travel would have been 7 minutes instead of 14. Collecting a few seconds of orders and couriers and solving them together finds that.
The scoring turns each order and courier pair into a cost: predicted pickup lateness, predicted courier wait, drop-off lateness against the promise, and a small term that spreads work fairly so the same couriers do not get every short trip. A min-cost bipartite assignment over a few hundred orders and a thousand couriers solves in milliseconds with the Hungarian algorithm or a min-cost flow solver. Large cities are split into zones that are solved in parallel, with couriers near a boundary eligible in both and resolved by whichever zone commits first.
The cycle length is a tuning knob. A 5-second window is invisible to customers, who are waiting minutes for food anyway, and gives the solver enough options to beat greedy by a clear margin. Longer windows find more stacking opportunities but start to cost real minutes.
Stacking two orders
When two orders come from the same or adjacent stores and go to nearby addresses, one courier can carry both. The solver sees this as an extra option: a single courier assigned to a pair, with a cost that includes the delay to whichever customer is dropped second. Stacking lowers cost per delivery and lets the city run with fewer couriers at peak, which matters most in bad weather. It is capped by a hard rule: no customer's ETA may slip by more than a few minutes because of a stack, or the food arrives cold and the rating drops.
The order saga
No single transaction can span the payment processor, the restaurant's tablet and a courier's phone. The order service runs a saga instead: authorise the payment, create the order, send it to the store, wait for acceptance, dispatch, pick up, deliver, capture. Each step is idempotent and each failure has a compensating action. If the store rejects or times out after three minutes, the authorisation is voided and the customer is told immediately. If no courier is found within fifteen minutes, the order is escalated to a support queue and the customer can cancel for a full refund. Every transition is written to the order's status history before the event is published, through a transactional outbox, so a crash never leaves Kafka and the database disagreeing about where an order is.
06
Choices weighed
Choices weighed
Finds globally better matches and enables stacking, at the cost of a few seconds of delay and a solver to run per zone.
Better use of couriersSimple and immediate. In a busy city it repeatedly gives short trips to whoever happens to be close and leaves later orders with distant couriers.
Locally good, globally poorEach step commits on its own and failures roll forward or compensate. Requires careful idempotency and a state machine that handles every out-of-order event.
Matches how the world behavesA restaurant tablet and a courier's phone cannot take part in a two-phase commit, and holding locks for the twenty minutes a kitchen needs is not workable.
Not possible hereFast reads for dispatch and cheap writes for 44,000 pings a second. A restarted index rebuilds from the next round of pings within seconds.
Fit for short-lived dataDurable and queryable, but the database spends its capacity overwriting rows that are stale seconds later, and order writes queue behind them.
Wrong storeOne connection per watching customer, with updates only when something changes. Needs a connection tier and reconnect logic on flaky mobile networks.
Cheap per update1.9 million screens polling every two seconds is close to a million requests a second, almost all returning "no change".
Self-inflicted load07
What goes wrong on a Friday night
What goes wrong on a Friday night
A storm arrives at 19:00. Orders jump 2x while many couriers log off. The matching pool fills with orders nobody can reach in time, and every promised ETA becomes a lie.
Raise courier pay incentives in affected zones, widen ETA ranges and fees at checkout, shrink delivery radii so stores far away stop appearing, and allow more stacking.A store accepts with a 12-minute prep time but is buried in dine-in orders. Couriers arrive and wait 25 minutes, then decline future offers from that store.
Use the prep-time model rather than the store's estimate, feed courier arrival and pickup times back into it, and let the store bump prep time with one tap.A tunnel, a dead battery or a bad cell. The customer's map freezes on one spot, and the dispatcher thinks the courier is still where they were.
Show "on the way" instead of a stale pin after about 30 seconds, extrapolate along the planned route for ETAs, and after several minutes of silence prompt the courier and alert support.A tablet loses Wi-Fi. Orders sit unacknowledged, customers watch "waiting for restaurant" for ten minutes, and some cancel.
Escalate an unacknowledged order to an automated phone call after 90 seconds, auto-pause the store after repeated misses, and void the payment authorisation if nothing confirms within a few minutes.Authorisations start taking 6 seconds. Checkout requests time out, customers tap "place order" again, and without care some get charged twice.
Idempotency keys on checkout and on every payment call, a short circuit breaker that fails fast with a clear message, and a second processor for failover in large markets.08
Designs that look right and are not
Designs that look right and are not
The courier reaches the store long before the food is cooked and waits unpaid, while orders placed a minute later get worse matches.
Every city's pings and every city's dispatch hit the same structure, and a hot spot in one metro slows all the others.
Kitchens are optimistic at the worst times. Plans built on the stated number put couriers at counters for twenty minutes.
It ignores prep time, parking, apartment lobbies and traffic, so promises are wrong by ten minutes in exactly the busy hours that matter.
09
Interview advice
Interview advice
Name all three parties. Customer, store and courier each have an app, a promise and a way to fail. Many candidates forget the restaurant entirely.
Spend your deep dive on dispatch. Explain why the target is food-ready time, not distance, then why batching beats greedy with a two-order example.
Use a saga for the order. Walk through what happens when the store declines, when no courier is found and when payment capture fails. Mention idempotency keys.
Treat location as a stream. Latest position in memory, history in a log, a hexagonal index for nearby queries. Mention H3 by name.
Call ETA a prediction. Split it into prep, travel to store, wait and travel to customer, each with its own model and features.
Plan for weather. Supply and demand both move. Pricing, radius limits and stacking are product levers that keep the system honest.
10
How the platform grows
How the platform grows
Orders land in one Postgres database and an operations person assigns couriers from a map. It validates demand before any algorithm exists.
A courier app with GPS pings, a simple nearest available rule, and store tablets. Orders shard by city once the single database gets busy.
Move to short batch windows with a min-cost assignment and an in-memory H3 courier index per city. Split catalogue, order and logistics into separate services with events between them.
Prep-time and travel models drive assignment timing, stacking becomes routine, and ETAs update throughout the trip. Each city's dispatcher runs independently.
Groceries, pharmacy and parcels reuse the same dispatch with new item types, longer prep and different vehicles, and the platform offers delivery as a service to other merchants.
Watch and read
Try next
Free to read · better with landed
Whiteboard this with landed
landed runs it as a live system design round on the whiteboard and grades your trade-offs.
