The whole design, in writing
Learn system design by building a food delivery system like DoorDash, Uber Eats or Deliveroo step by step. An interactive guide covering the three-sided marketplace, the order lifecycle state machine, the dispatch/matching engine that assigns couriers, real-time location tracking, ML-based ETA prediction, batching, geo-sharding and surge, and the unhappy paths (courier supply, cancellations, restaurant delays).
Every step of the build above, written out: the problem each piece solves, the option that was taken and the ones that were not, the numbers, and how it fails in production.
The big idea
What makes food delivery hard?
Three parties who don't know each other — a hungry customer, a busy restaurant, and a moving courier — must be coordinated in real time so hot food gets from kitchen to door. The courier should arrive just as the food is ready, the ETA must be honest, and it all has to work across a city where supply and demand shift by the minute.
A food delivery system is a three-sided marketplace plus a real-time logistics engine. The core pieces: an order lifecycle state machine, a dispatcher that matches couriers to orders optimally, live location tracking, and ETA prediction — all sharded by geography and tuned to the balance of couriers and orders.
What the new pieces do
- Customerclient
- Places an order, pays, and watches a live map until food arrives. One of three parties in the marketplace.
Step 1 · Three parties, one order
The marketplace shape
Unlike a normal app with users and a backend, here three independent actors must be coordinated per order, each with their own app and their own state. Why does that shape drive everything?
What makes a delivery app structurally different from a typical two-party app?
Every order threads together three actors who don't know each other, each with their own app and lifecycle. The system's core job is matching (courier↔order) and coordination (customer↔restaurant↔courier) — a logistics problem, not just CRUD.
It's not about screen count. The defining challenge is real-time coordination and matching across three independent parties with shifting supply and demand — a logistics/optimization problem a normal app doesn't have.
Perishability adds timing pressure, but every app has a database. The structural difference is the three-sided marketplace and the real-time dispatch that matches couriers to orders.
It's a three-sided marketplace: each order coordinates a customer, a restaurant, and a courier — three independent apps and lifecycles. The system's real job isn't storing orders; it's matching (which courier for which order) and coordinating the three parties in real time, with the added twist that supply (couriers) and demand (orders) fluctuate constantly. That's a logistics problem.
Why this piece earns its place
Calling this a marketplace has a consequence the diagram hides: we own exactly one of the three sides. Couriers decide when to come online, restaurants decide whether to accept, and neither of them responds to us adding servers. So every decision after this one is made under a supply we can influence but never command, and the thinnest side in any given minute sets the ceiling on the whole product — a city full of idle couriers at 3am is as broken as a Friday dinner rush with none. That makes liquidity, measured per market and per hour, the first thing worth instrumenting, well before dispatch gets clever: a beautiful matching engine with nothing to match is just an elegant queue. It also means the system needs a way to say no — hide a restaurant, quote a longer wait, stop taking orders in a neighbourhood — because an order accepted into a market that cannot serve it is a refund and a lost customer wearing the costume of a sale.
Step 2 · The order is the spine
A lifecycle state machine
An order passes through many stages — placed, paid, accepted, cooking, ready, picked up, en route, delivered — with different parties acting at each. How do you model something that changes hands and state so many times, reliably?
Model the order as an explicit state machine with well-defined states and allowed transitions (placed → accepted → cooking → ready → picked_up → delivered, plus cancelled/refunded branches). Each transition is triggered by a party (restaurant accepts, courier picks up) and recorded durably, ideally as an event log so the full history is auditable. The order service owns this machine and coordinates everyone against it.
Why this piece earns its place
Every transition in that machine arrives over a mobile network from a party we don’t control, which means it arrives twice, late, and out of order. A courier on a weak connection taps picked up, sees nothing happen, and taps again; a restaurant tablet flushes a queued ready ten minutes after the fact. So a transition can’t be a command that sets a field — it has to name the state it expects to move from, and be rejected as a harmless no-op once the order has left that state. That single rule makes retries free and stops a replayed stale event from dragging a delivered order backwards. The race worth naming out loud is cancellation: if cancel is a flag written beside the state rather than a guarded transition through the same machine, you get an order that is simultaneously cancelled and sitting in a courier’s bag. Cancel competes for the same lock as every other move, and whoever lands first wins — the other party is told, not overruled.
- statesplaced → delivered
- transitionsparty-triggered
- event logauditable history
What the new pieces do
- Order Servicegateway
- Orchestrates the order: takes payment, drives the state machine, and coordinates restaurant, dispatch and courier.
Step 3 · Bring in the kitchen
The restaurant side
Once paid, the order needs the restaurant to accept and cook it — and the kitchen's behavior (accept/reject, prep time) directly affects when a courier should show up. How does the restaurant fit in?
Send the order to the restaurant (via their tablet/app/POS integration) to accept or reject, then track it through cooking → ready. Capture the restaurant's prep time (estimated and actual) — it's a first-class signal: dispatch uses it to time the courier's arrival, and the ETA model learns from it. Handle rejections/86'd items by refunding or re-routing. The kitchen is a party whose timing the whole system must model, not just a passive step.
Why this piece earns its place
The uncomfortable part is that prep time, the signal everything downstream leans on, is reported by the party with the strongest reason to shade it. A kitchen that notices a courier turns up sooner once it taps ready will tap while the order is still being bagged; one judged on lateness will pad its estimate instead. Either way we end up training on a button rather than on food. The tempting correction is to learn from accept to pickup, since we witness both ends — but pickup also contains however long we took to get a courier to that counter, and that delay is produced by a dispatch decision this very prediction drives. Train on it and the model learns our own lateness, then feeds it straight back into the choice that caused it. So bracket the measurement with the courier instead: the moment they are standing at the counter, and the moment the bag is handed over. On the orders where the courier was already waiting when the food came up, accept-to-pickup is a clean prep label too; where they arrived after it was up, the gap is ours rather than the kitchen’s and the order is dropped from training. That same restriction is what makes the tap worth comparing at all — a kitchen whose ready consistently leads a handoff a waiting courier was there for is one the dispatcher should discount.
What the new pieces do
- Restaurantclient
- Accepts (or rejects) the order, cooks it, and marks it ready. Its prep time is a key input to timing and dispatch.
- Order Storedata
- Durable record of each order and its state (placed → accepted → cooking → ready → picked → delivered).
Step 4 · The heart: matching
The dispatch engine
Now the hard part. An order is cooking; somewhere in the city are hundreds of couriers in various states. Which courier gets this order — and when do you even assign it? Get this wrong and food is cold, couriers idle, or customers wait forever.
An order needs a courier from hundreds in the city. What's the right matching?
Nearest-and-immediate is naive: assign too early and the courier waits at the restaurant while food cooks (wasted); "nearest" ignores who's about to free up, traffic, and batching opportunities. Good dispatch optimizes globally over time, not greedily per order.
Free-for-all grabbing leads to poor global outcomes — cherry-picking, unbalanced load, bad ETAs. A central optimizer assigns to maximize overall efficiency and fairness.
The dispatcher solves an ongoing assignment/optimization problem: match couriers to orders to minimize total delivery time and courier idle time, timing the assignment to the food being ready, and batching nearby orders. It weighs who'll be free soon, traffic, and prep time — far beyond "nearest free courier".
Build a dispatch engine that treats assignment as an ongoing optimization, not a greedy nearest-match. It decides which courier and when to assign, weighing: predicted ETA and travel time, restaurant prep time (assign so the courier arrives as food is ready), courier utilization and who's about to free up, and batching nearby orders onto one trip. It's a continuous matching problem over a live pool of couriers and orders — the algorithmic core of the whole system.
Why this piece earns its place
The knob this step introduces is how long dispatch waits before committing an order to a courier, and it cuts both ways. Hold the order and the solve gets better — more couriers come free, a batch partner shows up, the arrival lands closer to food-ready. Hold it too long and nobody in range can reach the kitchen before the bag is up, so the food waits, and because the optimizer runs continuously that slip propagates into every order queued behind it. The dark side of re-solving every few seconds is churn: an assignment that keeps improving on paper can be taken back from a courier who has already started driving. That costs real miles, and it costs trust, which is the one input we can’t buy back quickly. So the rolling solve should treat unassigned orders as freely movable and accepted ones as very nearly frozen — reassigning only when the gain is large, or when the courier has plainly stopped moving toward the restaurant.
- who + whennot nearest-now
- batchnearby orders → one trip
- optimizeETA + prep + utilization
What the new pieces do
- Courierclient
- The driver/rider. Gets assigned an order, picks it up, and delivers it — streaming GPS the whole way.
- Dispatcherservice
- The matching engine: assigns the right courier to each order, optimizing ETA, batching and courier utilization.
Step 5 · Where is my courier?
Real-time location tracking
Dispatch needs to know which couriers are near a restaurant, and the customer wants a live dot moving toward them. That means ingesting GPS from thousands of couriers and answering "who's nearby?" and "where is order X's courier?" continuously.
Run a location service that ingests courier GPS pings (every few seconds) and maintains a geo-spatial index (geohash/quadtree) so dispatch can query "couriers within N minutes of this restaurant" cheaply. Push each order's courier position to the customer's app over WebSockets for the live map. Keep location updates on a high-throughput path (they're frequent and lossy-tolerant — a missed ping is fine) separate from the transactional order data.
Why this piece earns its place
Both consumers of this feed are being handed something far noisier than it looks. A raw GPS trace in a dense city snaps between streets, jumps backwards, and goes quiet in a tunnel or whenever the phone decides to sleep the app — so a customer’s live dot, rendered faithfully, shows a courier driving the wrong way down a road they were never on. The map should therefore be a rendering, not a feed: snap positions to the road network, animate along the predicted route between pings, and when the pings stop, show the last known position with its age rather than a confident frozen dot. Dispatch needs the opposite correction. Its question is never "who is within a kilometre" but "who is within a few minutes" — straight-line proximity cheerfully picks the courier across the river, one bridge and a long left turn away, over the one four streets back. Index by position, rank by travel time.
- GPSevery few seconds
- geo-indexnearest-courier queries
- WebSocketlive map to customer
What the new pieces do
- Locationservice
- Ingests courier GPS, maintains a geo-index of nearby couriers for dispatch, and powers the live tracking map.
Step 6 · Honest timing
ETA prediction
"Arrives in 35 min" is a promise that shapes the whole experience — and it's the sum of several uncertain parts: how long the kitchen takes, how long until a courier is free and drives there, pickup wait, and drive to the customer. How do you predict it well?
Use an ML model to predict ETA as the sum of its parts: restaurant prep time (learned per restaurant/item/load), courier assignment + travel time (real-time traffic and distance), pickup wait, and drop-off travel. Feed it live signals (current courier positions, restaurant backlog, weather, time of day) and learn from actual delivery times to keep improving. The prediction feeds the customer quote and the dispatcher (when to assign).
Why this piece earns its place
An ETA isn’t really a prediction, because we publish it and it becomes a promise. That makes the error asymmetric in a way a plain accuracy score can’t see: arriving early is close to free, arriving late is the complaint, the refund and the churn. So the model should be trained and judged with an asymmetric loss and reported on its tail — the share of deliveries that blow past the quoted time — rather than on average error, which a model that is wildly early half the time and wildly late the rest can post a lovely number on. It also means the figure we show and the figure we optimize against are allowed to differ: quote the customer with some cushion, dispatch against the unpadded estimate. That cushion is its own dial, because padding it until we are never late is the same move as pushing the customer to order from somewhere that promises sooner.
- prep + travel + waitsummed
- live signalstraffic, backlog, weather
- learnfrom actual times
What the new pieces do
- ETA Modelservice
- Predicts prep + travel + wait times so quotes are honest and dispatch times the courier to arrive as food is ready.
Step 7 · A city at a time
Geo-sharding, batching & surge
A national service isn't one giant matching problem — dispatch in New York has nothing to do with couriers in LA. And within a city, demand spikes (Friday dinner) while courier supply is finite. How do you scale and balance it?
The service runs in 200 cities, and Friday dinner rush floods one of them with orders. What is the architecture and the fix?
New York couriers can never serve Los Angeles orders, so solving them jointly is pure wasted compute on a problem that has zero cross-city interaction — it doesn't scale and doesn't need to.
Each market is a self-contained, tractable optimization near its own data. Within a flooded city, surge pay pulls more couriers online and gently cools demand — a live pricing loop that rebalances the one market that is actually stressed.
A blunt national cap punishes cities that are perfectly balanced to fix a problem local to one city. The imbalance is local; the fix should be too.
Geo-shard the system by city/region so dispatch, location and matching run per market — each a self-contained, tractable optimization near its data (low latency, natural partition). Batch orders heading the same way onto one courier trip to raise efficiency. And balance supply vs demand with surge/incentives: when orders outnumber couriers, raise courier pay (and possibly customer fees) to pull more couriers online and gently smooth demand — a live marketplace-pricing loop per region.
Why this piece earns its place
Sharding by city bakes in a claim that is true nationally and false at the seams. A boundary drawn on a municipal line runs straight through neighbourhoods where the best courier for this restaurant is two blocks away and in the other shard — and since boundaries tend to follow the dense parts, the bad matches cluster exactly where the volume is. So draw markets from where deliveries actually happen rather than from an administrative map, and let candidate pools overlap at the edges so a courier near a border is visible to both sides. Overlap then creates the failure it was always going to: two shards offering the same courier a different order in the same second. However the map is cut, a courier has exactly one owner deciding their next job, and an assignment has to be confirmed against that owner before it is communicated to anyone. Shards also aren’t equal — one metro can carry more load than several others combined, so expect to split the hot one rather than assuming a city is a unit.
- geo-sharddispatch per market
- batchshared-route orders
- surgebalance supply/demand
Step 8 · The sharp edges
Supply, cancels & cold food
Reality is messy: not enough couriers at peak, restaurants that reject or run slow, customers who cancel mid-cook, and the constant risk of food arriving cold or late.
Handle courier shortage with surge incentives, batching, and honest longer ETAs (or pausing a restaurant) rather than accepting orders you can't deliver. Manage cancellations through the state machine with clear refund rules depending on stage (free before cooking, partial after). Absorb restaurant delays by learning real prep times and re-timing or re-assigning couriers. Guard reliability with idempotent payments, timeouts and reassignment if a courier goes unresponsive, and monitor food-cold risk (dispatch-to-ready mismatch) as a core quality metric.
Why this piece earns its place
Worth saying out loud in an interview: none of these are refunds, they are three-way settlements. A cancel after cooking has started means the customer’s money goes back, but the restaurant is standing over food it has already paid for and the courier may be halfway there. Refunding the customer and stopping isn’t neutrality — it quietly pushes the cost of our worst minutes onto the two sides whose supply we depend on and cannot conjure. So every terminal state carries a payout rule alongside its refund rule, and both are decided by the state the order was in when it went wrong, which is exactly what makes the recorded transitions earlier worth their trouble: the settlement is derived from the history rather than argued about afterwards. The cheap instinct is to make the customer whole and sort the other two out case by case in a support queue. That is the most expensive version of this, because it is manual at precisely the moments when volume spikes.
You did it
You just designed a food delivery system.
Everything you assembled, in order
- It's a three-sided marketplace (customer/restaurant/courier) plus a real-time logistics engine.
- The order is a durable state-machine spine — placed → accepted → cooking → ready → picked → delivered.
- The restaurant's prep time is a first-class signal for timing, not a passive step.
- The dispatcher is the heart: an ongoing optimization of who and when, weighing ETA, prep, batching, utilization — not nearest-now.
- A location service (geo-index + GPS streaming) powers nearest-courier queries and the live map.
- ETA is an ML sum of prep + wait + travel, feeding both the customer quote and dispatch timing.
- Geo-shard by city, batch shared-route orders, and use surge pricing to balance supply and demand.