00. Design a Food Delivery Service¶
~20 min read · Level: advanced · Part 1 of 4 (Overview → HLD → LLD → Q&A)
Problem¶
A food delivery service connects three parties who never meet: a customer who orders dinner, a restaurant that cooks it, and a courier who carries it the last few kilometers. This is the product behind DoorDash, Swiggy, and Uber Eats. A customer taps "order," a restaurant confirms and starts cooking, and somewhere within a few seconds the platform has to decide which of thousands of moving couriers should carry that meal — ideally arriving at the kitchen just as the food comes out of the fryer, then reaching the customer while it is still hot.
What makes this a hard system-design problem is that all three sides are moving at once. Couriers drive around the city, their positions changing every few seconds. Orders arrive and complete continuously. Kitchens run ahead or behind schedule. The platform is not answering a stored question; it is running a live control loop that re-decides who-carries-what as the map shifts underneath it. And the decision has a physical deadline baked in: food gets cold.
To keep the reasoning concrete, thread one scenario through the whole design: the 7pm dinner rush in a single dense city — 50,000 orders in that peak hour, all wanting to be dispatched, cooked, and delivered inside a 35-minute window. During that hour the platform must batch nearby orders onto shared courier trips to stretch a limited courier fleet, without letting any single meal sit so long that it arrives cold or the promised ETA turns into a lie. That tension — squeeze more orders onto each courier, but keep food hot and ETAs honest — tests every decision below.
Functional requirements¶
- Place order: a customer submits a cart against a restaurant and pays; the order enters a lifecycle we track to delivery.
- Restaurant flow: the restaurant accepts (or rejects) the order and signals an estimated prep time, which the platform uses to time the courier's arrival.
- Dispatch: assign a courier to each order — including batching two or more nearby orders onto one courier trip when it saves supply without ruining any order's ETA.
- Live tracking: the customer watches the courier move on a map in near-real-time and sees a continuously updated ETA; the courier app streams GPS.
- ETA prediction: produce an estimated delivery time at order placement and keep it current as conditions change.
De-scoped for this round, and worth naming so the interviewer knows it is a choice: menu management and catalog search, restaurant discovery and ranking, promotions and surge pricing internals, payment processing (we treat "charge succeeded" as a signal), ratings and reviews, and fraud. These are real subsystems, but they hang off the core and do not change its shape. The core is the three-sided matching loop and the tracking that surrounds it.
Non-functional requirements¶
The dominant constraint is decision freshness under a moving, deadline-bound optimization. The dispatch engine must commit courier assignments fast enough that food stays hot and ETAs stay honest, while the inputs it reasons over — courier positions, the queue of unassigned orders, kitchen readiness — change every few seconds. Everything downstream follows from that.
- Dispatch latency: an order should be matched to a courier within a few seconds of becoming dispatchable, so the courier can be at the kitchen when the food is ready. A one-minute dispatch delay is a one-minute-colder meal.
- Optimization quality: the assignment should minimize total food-in-transit time and courier miles, not just find a courier. A greedy nearest-courier match is fast but leaves the fleet badly allocated during a rush.
- Tracking freshness: courier location on the customer's map should lag reality by no more than a few seconds; a frozen dot destroys trust even when the courier is moving fine.
- Availability: the order and tracking paths must stay up through the rush. A customer who cannot see their order, or a courier who cannot receive an assignment, is a lost delivery and a refund.
- Consistency: a courier must be assigned to a batch exactly once. Double-assigning one courier to two conflicting trips is the one place we cannot be eventually consistent — it strands an order.
Scale estimation¶
Take the scenario: 50,000 orders in the 7pm peak hour, one city. That is 50,000 / 3,600 ≈ 14 orders/second sustained through the peak. Modest as a write rate — the interesting load is not order inserts, it is the live matching and tracking around them.
Concurrent orders in flight. An order lives from placement to delivery for roughly 35–40 minutes. At 14/s and a 40-minute (2,400 s) lifetime, the number of orders active at once is 14 × 2,400 ≈ 33,600. So at the peak the city holds about 33,000 live orders, each one a customer watching a map.
Active couriers. Single-dispatch (one order per trip) would need one courier per active delivery leg; at ~2.5 deliveries/courier/hour, serving 50,000 orders/hour takes 50,000 / 2.5 ≈ 20,000 couriers on the road. This is exactly where batching earns its place: at an average of 1.4 orders per courier trip, the same 50,000 orders need 20,000 / 1.4 ≈ 14,000 couriers — a ~30% reduction in fleet for the same demand. That saving is the whole reason batching exists, and its cost is measured in minutes of extra food-in-transit time per batched order.
Location write load. Each active courier's app pings GPS every ~4 seconds. At 14,000 couriers that is 14,000 / 4 ≈ 3,500 location writes/second at peak, each a tiny (courier_id, lat, lng, ts) tuple of ~100 bytes. High frequency, low durability need — these are ephemeral and live in memory.
Tracking read/push load. Each of the ~33,000 live-order customers gets a location-and-ETA update every ~5 seconds over a push channel: 33,000 / 5 ≈ 6,600 pushes/second, ~200 bytes each, so ~1.3 MB/s of tracking egress. Trivial in bandwidth; the cost is holding 33,000 open connections, not the bytes.
ETA recompute load. If every live order recomputed its ETA with a fresh routing call every 5 seconds, that is those same ~6,600 routing calls/second. A routing engine call costs ~50 ms of compute; 6,600/s is infeasible to serve per-order. This forces the design to a precomputed cell-to-cell travel-time matrix (below), turning a per-order ETA into a memory lookup and cutting routing-engine load by ~99%.
Storage. Order rows are ~2 KB (items, addresses, timestamps, state history). A city doing 50k/hour at peak lands roughly 300k orders/day; 300k × 2 KB ≈ 600 MB/day of durable order data per city — a few hundred GB/year, comfortably a sharded relational store. The high-volume stream is location pings (~3,500/s), but those are held in memory with a short trail and only downsampled into cold storage for ML training, not kept hot.
Reconciling: 14 orders/s in → 33,000 concurrent orders → 14,000 active couriers → 3,500 location writes/s → 6,600 tracking pushes/s. The numbers are individually small; the difficulty is that they must all stay coherent inside a few-second decision window.
API sketch¶
POST /api/v1/orders
body: { "restaurant_id": …, "items": [...], "drop_lat": …, "drop_lng": …,
"idempotency_key": "uuid" }
201: { "order_id": …, "state": "CREATED", "eta_minutes": 34 }
POST /api/v1/restaurants/{rid}/orders/{oid}/accept
body: { "prep_minutes": 12 }
200: { "state": "CONFIRMED", "ready_at": "19:14:00Z" }
POST /api/v1/couriers/{cid}/location # courier app, every ~4s
body: { "lat": …, "lng": …, "ts": …, "heading": … }
204: accepted
GET /api/v1/orders/{oid} # status + current ETA
200: { "state": "PICKED_UP", "courier": {...}, "eta_minutes": 8 }
WS /api/v1/orders/{oid}/track # live location + ETA stream
→ { "courier_lat": …, "courier_lng": …, "eta_minutes": 7 } (every ~5s)
POST /internal/dispatch/assign # dispatch engine → order+courier
body: { "assignments": [{ "courier_id": …, "order_ids": [...] }] }
Solutioning¶
Start from the fact that all three sides move, and the architecture separates into two rails. There is a slow, transactional rail for order state — placement, restaurant acceptance, payment, delivery confirmation — which is a straightforward state machine over a durable store at ~14 writes/second. And there is a fast, in-memory rail for the live world — courier positions, the geospatial index of who-is-near-what, the dispatch optimizer, and the tracking fanout — which runs at thousands of updates per second and tolerates losing a ping. Keeping these apart is the first structural decision: the transactional rail must never be blocked by the firehose of GPS pings, and the tracking firehose must never wait on a durable order write. The reframe that drives everything: 50,000 orders/hour is not a database-write problem; it is a matching-under-deadline problem — the orders will be stored trivially, the hard question is which courier carries which orders, decided fast enough that the food stays hot.
The first defining tradeoff is batch versus single dispatch. Single dispatch — one order, one courier, matched to the nearest free driver — is simple and gives each order the fastest possible trip, but during the 7pm rush it needs the full ~20,000-courier fleet and still leaves demand unserved when couriers run short. Batching two nearby orders onto one trip cuts the fleet need ~30% (to ~14,000) and is often the only way to serve the whole rush, but every batched order pays for it: the second drop-off waits while the first is completed, adding minutes and risking cold food. The resolution is not "always batch" or "never batch" but a bounded batch: bundle a second order onto a trip only when its incremental delay stays under a food-hot budget (say +8 minutes) and the detour is small; otherwise dispatch it alone. Batching is not a routing optimization applied to orders you already have; it is a bet that a nearby compatible order is worth waiting a moment to pair — a bet the optimizer re-evaluates every tick.
The second tradeoff is ETA accuracy versus compute. An honest ETA needs prep time (from the kitchen), pickup travel, wait-at-restaurant, and drop-off travel under live traffic — a real routing computation per order. At 6,600 ETA recomputes/second that computation cannot be done fresh per order. The resolution is to precompute a cell-to-cell travel-time matrix: partition the city into geospatial cells, compute travel time between cell pairs once per minute with live traffic, and reduce each per-order ETA to a couple of matrix lookups plus the kitchen's prep estimate. This trades a small amount of spatial precision (cell granularity) for a ~99% cut in routing load, and it is what lets 33,000 orders all show a moving ETA without a routing engine melting down. A wrong ETA is not just a bad customer experience; it feeds back into dispatch, because the optimizer times courier arrival to the predicted ready moment — so ETA error and dispatch quality are the same problem viewed twice.
The third tension is supply–demand balancing when the rush overwhelms the fleet. At 7pm demand can exceed the couriers physically available, and no amount of clever matching conjures a driver from nothing. The system does not silently drop orders or let ETAs quietly rot; it degrades on price and promise, not on correctness: it lengthens promised ETAs honestly (so customers set expectations), raises courier incentives to pull more supply onto the road, and may throttle new-order promises in the worst-hit cells. The dispatch loop keeps running; what changes under pressure is the promise, not the pipeline. A dinner rush is not a throughput problem you scale your way out of; it is a supply-allocation problem you manage with honest ETAs and incentives.
The result is a system with a durable order state machine on one side, a real-time geospatial-and-dispatch loop on the other, a precomputed travel-time matrix feeding ETAs, and a websocket fanout carrying the live map to customers. The following files take each of these down to components (HLD) and then to schemas, the batch-matching algorithm, and the concurrency corners where a courier gets double-booked (LLD).