← System design

Marketplace & Booking

Design DoorDash / Uber Eats

A food order is a small logistics contract between three parties who never meet: a customer who wants dinner hot, a restaurant that needs time to cook it, and a courier who gets paid per trip and loses money every minute spent waiting at a counter. The engineering problem is not listing restaurants. It is deciding which courier should arrive at which kitchen at the moment the bag is sealed, thousands of times a minute, while keeping the money and the order state correct when any of the three apps drops offline.
Core: batched dispatch against predicted food-ready time4.5M orders a day, 900 a second at dinner peakThree apps, three sets of promisesCourier assigned in under 30 s

01

What each side needs

02

What each side needs

Functional
  • 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.
Non-Functional
  • 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.
Set the scope early

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

03

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.

QuantityWorkingResult
Average order rate4.5M / 86,400 sabout 52 / s
Dinner peak order rateabout 17x average, evening hours stacked across time zonesabout 900 / s
Courier location pings220,000 / 5 s44,000 / s
Browse requestsabout 25 page and search views per order, at peakabout 22,000 / s
Delivery quotesETA and fee shown on every listing card, cached per H3 cellabout 6,000 model calls / s
Live tracking viewersorders in flight at peak, about 35 min eachabout 1.9M open sessions
Order record sizeitems, modifiers, prices, status history, payment refsabout 8 KB
Order data per day4.5M x 8 KBabout 36 GB, 13 TB a year
Location history per day44,000 / s average-weighted, about 2B fixes x 40 Babout 80 GB, kept 90 days
Where the load really is

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

04

API

GET/v1/stores?lat=12.9719&lng=77.6412&sort=etaStores that deliver here
{ "stores": [{ "id": "st_40912", "name": "Masala Lab", "eta_min": [28, 38], "fee": 2.49, "open": true }], "cell": "8860145b4bfffff" // H3 cell the listing was cached under }
POST/v1/ordersPlace an order
// Idempotency-Key: 7a1f-checkout-cart_88231 { "store_id": "st_40912", "cart_id": "cart_88231", "quote_id": "q_5c90", // locks price, fee and ETA shown at checkout "drop_off": { "lat": 12.9719, "lng": 77.6412, "notes": "Gate 2" }, "payment_method": "pm_card_77", "tip": 3.00 } // 201 Created { "order_id": "o_9KQ2", "state": "AWAITING_STORE", "total": 24.87, "eta": "19:42" }
POST/v1/couriers/me/locationsCourier GPS batch
{ "fixes": [{ "t": 1790011200, "lat": 12.9661, "lng": 77.6380, "acc_m": 6, "speed": 7.1 }], "status": "en_route_pickup" } // 204; fire and forget, a lost batch is replaced by the next one
POST/v1/offers/{offer_id}/acceptCourier takes a delivery
// 200 with pickup details, or 410 if the offer expired or went to someone else
GET/v1/orders/{order_id}/liveTracking stream
// Server-sent events: state changes immediately, courier position every 4 to 5 s { "state": "PICKED_UP", "courier": { "lat": 12.9690, "lng": 77.6401 }, "eta": "19:39" }

04

Three domains, one event stream

05

Three domains, one event stream

Catalogue, orders and logisticsDiagram
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.

H3 courier index

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.

Orders DB

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.

ETA and prep models

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.

Store integration

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

06

Deep dive: who picks up this order, and when

The real objective

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.

One dispatch cycle for one cityDiagram
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

07

Choices weighed

Chosen
Batched assignment every few seconds

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 couriers
Rejected
Greedy nearest courier on arrival

Simple 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 poor
Chosen
Saga with compensations

Each 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 behaves
Rejected
One distributed transaction

A 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 here
Chosen
Latest positions in memory, history in a log

Fast 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 data
Rejected
Location rows in the main database

Durable and queryable, but the database spends its capacity overwriting rows that are stale seconds later, and order writes queue behind them.

Wrong store
Chosen
Push tracking over a stream

One connection per watching customer, with updates only when something changes. Needs a connection tier and reconnect logic on flaky mobile networks.

Cheap per update
Rejected
Clients poll order status

1.9 million screens polling every two seconds is close to a million requests a second, almost all returning "no change".

Self-inflicted load

07

What goes wrong on a Friday night

08

What goes wrong on a Friday night

🌧️
Rain doubles demand and halves the couriers

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.
🍳
The kitchen is slower than it said

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.
📵
The courier's phone goes silent mid-trip

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.
📟
The store never sees the order

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.
💳
Payment provider is slow at peak

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

🚫
Dispatching the instant an order is placed

The courier reaches the store long before the food is cooked and waits unpaid, while orders placed a minute later get worse matches.

Instead: plan the assignment time from predicted prep time, then match in short batches.
🚫
One global table of courier positions

Every city's pings and every city's dispatch hit the same structure, and a hot spot in one metro slows all the others.

Instead: partition the live index by city and by H3 cell, with each city's dispatcher reading only its own.
🚫
Trusting the restaurant's prep estimate as given

Kitchens are optimistic at the worst times. Plans built on the stated number put couriers at counters for twenty minutes.

Instead: predict prep time from each store's history and live queue, and treat the stated time as one input.
🚫
Computing ETAs as distance over average speed

It ignores prep time, parking, apartment lobbies and traffic, so promises are wrong by ten minutes in exactly the busy hours that matter.

Instead: model each leg separately from millions of past deliveries and update the estimate as the trip progresses.

09

Interview advice

09

Interview advice

1

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.

2

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.

3

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.

4

Treat location as a stream. Latest position in memory, history in a log, a hexagonal index for nearby queries. Mention H3 by name.

5

Call ETA a prediction. Split it into prep, travel to store, wait and travel to customer, each with its own model and features.

6

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

11

How the platform grows

Stage 1: one city, a few hundred orders a day
A monolith and a human dispatcher

Orders land in one Postgres database and an operations person assigns couriers from a map. It validates demand before any algorithm exists.

Stage 2: tens of thousands of orders a day
Automatic nearest-courier dispatch

A courier app with GPS pings, a simple nearest available rule, and store tablets. Orders shard by city once the single database gets busy.

Stage 3: hundreds of thousands a day
Batched matching and an H3 index

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.

Stage 4: millions of orders a day
Prediction-driven dispatch

Prep-time and travel models drive assignment timing, stacking becomes routine, and ETAs update throughout the trip. Each city's dispatcher runs independently.

Stage 5: beyond restaurants
A general logistics platform

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.