Design a food ordering and delivery platform
Open in the interactive app: practice, quiz, mock interview →In one minute
- This is a three-sided marketplace: eaters, restaurants and couriers. The interesting system is not the menu or the cart but the dispatch loop that decides which courier takes which orders and when to send them.
- Dispatch is a periodic optimization, not a nearest-courier lookup. Every few tens of seconds per zone, score candidate (courier, order bundle) pairs and solve an assignment problem, because a greedy instant match wastes couriers and makes food wait.
- Batching two orders from the same or nearby restaurants onto one courier is the biggest efficiency lever, and it is constrained by a promised delivery time. The core trade-off is courier cost against eater wait.
- Everything depends on two predictions: when the food will be ready and how long travel takes. Bad ETAs create either cold food (courier late) or idle couriers (courier early).
- Keep the order and payment state machine strongly consistent and the courier location stream lossy. They have different volumes, different stores and different failure handling.
Requirements
Functional:
- Eaters browse nearby restaurants and menus, place an order, pay, and track it live.
- Restaurants receive orders (tablet or POS integration), accept them, and mark them ready.
- Couriers go online, stream their location, receive offers for one or more orders, accept or decline, and confirm pickup and drop-off.
- The system quotes an ETA at checkout and keeps it updated.
Non-functional:
- An order is assigned to at most one courier at a time, and a courier never receives conflicting offers.
- Offer latency: a new order gets a courier within a minute or two in a healthy market, and dispatch decisions are made in seconds.
- Ordering and payment availability above 99.95%. Dinner peaks are several times the daily average, so the system must scale to the peak, not the mean.
- Tracking freshness: courier positions on the eater's map lag by no more than about 5 to 10 seconds.
Out of scope: restaurant onboarding, promotions, ratings, grocery and retail, courier pay calculation and fraud. Search and recommendations get one paragraph, not a deep dive.
Back-of-envelope
Planning numbers only. Assume 10 million orders per day across the platform. The daily average is 10M / 86,400, which is about 115 orders per second. Food is peaky: lunch and dinner might carry 60% of orders in about 5 hours, so dinner peak is roughly 10M x 0.6 / 18,000 seconds, about 330 per second, call it 500 per second with regional skew.
Couriers: assume 1 million online at the national peak, each sending a location every 5 seconds. That is 200,000 updates per second. At about 100 bytes each, that is 20 MB/s of ingress, a small bandwidth but a high message rate.
Dispatch work: a busy metro might have 3,000 open orders and 8,000 couriers at once. A full cost matrix would be 24 million pairs, but restricting each order to couriers within a few kilometres leaves maybe 50 candidates per order, so about 150,000 scored pairs per optimization round per metro. Each needs a travel-time estimate, which is why travel time comes from a precomputed or cached model, not a live routing call per pair.
Storage: orders at about 3 KB each (items, prices, addresses, state history) give 30 GB per day, roughly 55 TB over five years. Menus: 1 million restaurants x 200 items x 1 KB is about 200 GB, read-heavy and cacheable. Location history at 200,000 updates per second x 100 bytes is about 1.7 TB per day raw. Keep on-delivery traces at full resolution for disputes and ETA training, and downsample the rest.
Read traffic: tracking screens poll or subscribe for each active order. With perhaps 1 million active orders at peak and an update every 5 seconds, that is another 200,000 pushes per second out, mirroring the ingest.
API
POST /v1/orders Idempotency-Key: <uuid>
body { restaurant_id, items: [ { item_id, qty, options } ], address, payment_method_id }
-> 201 { order_id, state: "PLACED", quoted_eta: "2026-10-15T19:42:00Z" }
GET /v1/orders/{id} -> { state, courier: { lat, lng }, eta }
WS /v1/orders/{id}/stream <- { state | courier_location | eta } events
// Restaurant
POST /v1/restaurant/orders/{id}/accept body { prep_minutes }
POST /v1/restaurant/orders/{id}/ready
// Courier, over a persistent connection
-> LocationUpdate { courier_id, lat, lng, ts, status }
<- Offer { offer_id, orders: [ { order_id, pickup, dropoff } ], route, pay, expires_at }
-> OfferResponse { offer_id, accept: bool }
POST /v1/courier/orders/{id}/pickup | /dropoffData model
orders (relational, partition key order_id, with a secondary index on (restaurant_id, created_at) and (eater_id, created_at)): items, totals, addresses, state, courier_id, offer_id, version, timestamps per state. Partitioned by order because almost all writes are to one order; the restaurant and eater indexes serve their dashboards. Strong consistency, since state drives money and the courier contract.
order_events (append-only log, Kafka topic partitioned by zone_id): every state change, consumed by dispatch, tracking, ETA and analytics. Partitioning by zone means one dispatcher instance sees all events for its zone in order.
courier_state (in memory in the dispatch service for its zone, backed by a replicated key-value store): courier_id -> { status, lat, lng, last_ts, active_orders, offer_id, version }. The offer_id and version fields make "this courier has an outstanding offer" a compare-and-set, which prevents conflicting offers.
geo index (in memory, keyed by H3 cell at a resolution of roughly 0.1 to 0.7 square kilometres, that is resolution 9 or 8 (H3 cell statistics)): cell -> set(courier_id), used to find candidates near a restaurant with a ring search.
menus and restaurants (document store plus cache, partition key restaurant_id): read-mostly; published to a CDN-backed cache and to a search index with a geo_point and opening hours.
location_history (wide-column or object store, partition (courier_id, day)): written asynchronously from the location stream.
High-level design
flowchart LR
E["Eater app"] --> GW["API gateway"]
R["Restaurant tablet"] --> GW
C["Courier app"] --> WS["Connection gateway"]
GW --> OS["Order service"]
OS --> ODB[("Orders DB")]
OS --> PAY["Payments"]
OS --> BUS["Order events (Kafka, by zone)"]
WS --> LOC["Location ingest"]
LOC --> GEO["Geo index (H3, in memory)"]
LOC --> HIST["Location history"]
BUS --> DSP["Dispatch (per zone)"]
GEO --> DSP
ETA["ETA and prep-time models"] --> DSP
DSP --> WS
DSP --> OS
BUS --> TRK["Tracking fan-out"]
TRK --> EWrite path (order): the eater checks out. The order service validates the cart against the menu, authorizes payment with an idempotency key, writes the order as PLACED and publishes an event. The restaurant accepts and gives a prep estimate. The event lands in the zone's dispatch partition, and the order joins that zone's pool of unassigned orders with a target ready time and a promised delivery deadline.
Dispatch loop: every 20 to 60 seconds (shorter at peak), the zone's dispatcher takes the pool, generates candidate bundles (single orders and pairs from the same or nearby restaurants), finds nearby couriers through the H3 ring search, predicts pickup and drop-off times for each (courier, bundle), discards pairs that break a deadline, and solves the assignment. It may also choose to wait: if the food will not be ready for 15 minutes, sending a courier now only makes them idle at the restaurant. Chosen offers are written as a conditional update on the order (state = UNASSIGNED AND version = v) and the courier (offer_id IS NULL), then pushed over the courier's connection with an expiry of around 30 to 60 seconds. A decline or a timeout returns the orders to the pool for the next round.
Read path (tracking): the eater's app subscribes to the order's stream. The tracking service joins order events with the assigned courier's location stream and pushes an update every few seconds with a refreshed ETA.
Deep dives
Assignment and batching
The options, from simplest to best:
- Greedy on arrival: give each new order to the nearest idle courier. Fast and simple, but it ignores orders arriving seconds later, never batches, and sends couriers to food that is not ready.
- Periodic bipartite matching: collect orders for a short window, build a cost matrix of (courier, order), and solve minimum-cost matching (the Hungarian algorithm or a min-cost-flow solver). Better global outcomes, but one courier still gets one order.
- Periodic matching over bundles: candidates include pairs (and sometimes triples) of orders, and the solver picks a set of (courier, bundle) pairs so that each order and courier appears at most once. This is a set-packing problem; in practice it is solved with a mixed-integer program under a time limit, or with a greedy heuristic plus local improvement. DoorDash has written publicly about moving from Hungarian matching to a MIP solver for this reason: the Hungarian algorithm is polynomial but too slow on large real-time instances and cannot express routes with two or more deliveries. In its benchmarks Gurobi solved the matching up to 10 times faster than the Hungarian algorithm and averaged 34 times faster than the open-source CBC solver, and the optimizer runs on several regional instances multiple times a minute (DoorDash Engineering, 2020).
I choose option 3 with a heuristic fallback. The cost of a candidate combines courier travel and wait time, eater lateness against the promise, and food sitting time. The hard constraint is that every order in a bundle meets its deadline. Only pair orders whose restaurants are close and whose drop-offs are roughly on the way. The candidate generator is where most of the speed comes from: pruning to the few dozen couriers and a handful of partner orders that are plausible keeps the problem small enough to solve within a few seconds.
The practice task implements the greedy version of this with an explicit deadline check, which is the baseline you would ship first and then measure the optimizer against.
ETA and prep-time prediction
The pickup time is max(courier arrival, food ready) and the delivery time adds travel. Both terms are predictions with errors. Prep time comes from the restaurant's estimate, corrected by a model trained on history (time of day, order size, current queue at that restaurant). Travel time comes from a routing engine for the final quote and from a cheaper learned model or cached zone-to-zone matrix for the hundreds of thousands of candidate pairs per round. Uber has described a similar split for its ETA system: a routing-engine estimate corrected by a learned residual model, which it calls ETA post-processing. Its DeepETA model must answer in a few milliseconds, so it uses a linear-time transformer and bucketized inputs, and each prediction touches only about 0.25% of the parameters (Uber Engineering, 2022). Two things matter in design review: use quantiles, not means, when checking deadlines (a p80 travel time protects the promise), and feed actual pickup and drop-off timestamps back as training labels.
Exactly one courier per order
Offers can race: two dispatch rounds overlap after a restart, or a courier accepts an offer just as it expires and the order is reassigned. Make every transition a conditional write. Creating an offer sets order.offer_id only if it is null and the order is UNASSIGNED; accepting succeeds only if the offer ID still matches and has not expired, using the server's clock. One dispatcher owns each zone through a lease (etcd or ZooKeeper), and every write carries the lease's fencing token, so a paused old owner cannot write after a new one takes over. The token only helps if the orders store checks it and rejects anything lower than the highest it has seen; checking the lease in the dispatcher just before writing is not enough, because the pause can land between the check and the write (Kleppmann). Accepting a batch must also update every order in it and the courier atomically, or not at all; Uber called out exactly this all-or-nothing requirement as hard to guarantee on its old fulfillment stack and moved those writes into single transactions (Uber Engineering, 2021). The courier app treats offers as idempotent by offer_id.
Down the stack
Layer 1The contract
For each order, at most one accepted offer exists at any time, and an accepted offer is never silently replaced; reassignment requires an explicit unassign event. A dispatch round in a zone finishes within its time budget (say 5 seconds) or falls back to the greedy result. Location updates are at-most-once and may be dropped; order events are at-least-once and consumers are idempotent by (order_id, version). ETAs carry a confidence bound, and the quoted promise is the upper bound.
Layer 2The mechanism
Order events flow through a Kafka topic partitioned by zone, so the dispatcher for a zone is a single consumer with ordered input. Its in-memory state (open orders, courier positions, outstanding offers) is rebuilt on startup by replaying the topic from the last snapshot. Candidate generation uses H3: convert the restaurant to a cell, then expand rings with gridDisk (named kRing before H3 v4) until enough idle couriers are found or a radius cap is hit. Near one of the 12 pentagons per resolution the fast variant can fail, so production code falls back to gridDiskDistances (H3 traversal API). Scoring calls an ETA model batched over all candidate pairs. The solver gets a time limit and returns its best feasible solution so far. Writes go to the orders database as single-row compare-and-set operations keyed by order_id, with the fencing token stored on the row.
Layer 3The machine
A zone's state is small: 3,000 orders and 8,000 couriers at a few hundred bytes each is a few megabytes, so it lives in the heap of one process. 150,000 candidate pairs with a 32-byte cost record is about 5 MB, which fits in L3 cache on a modern server, so scoring is memory-bandwidth friendly if laid out as arrays instead of maps of objects. A batched ETA model on CPU might score 150,000 pairs in tens of milliseconds. The location stream (200,000 small messages per second nationally) is mostly per-message overhead: TLS records, a syscall or two per message unless batched, and serialization. Batching reads with epoll and coalescing writes into the geo index per tick keeps it to a modest number of cores. On a JVM or Go runtime, a large heap of short-lived location objects can cause GC pauses in the tens of milliseconds, so reuse buffers and keep per-courier state in preallocated structs.
Why it's built this way
- Couriers are the scarce, expensive resource, so each courier hour should carry as many deliveries as the promise allows, and that means batching.
- Batching needs a view of several orders at once, so dispatch waits a short window and optimizes, instead of matching instantly.
- Optimizing needs thousands of time predictions per second, so travel time comes from cheap models, with a routing engine only for the final route.
- Optimizing in one place needs consistent local state, so each zone has one owner fed by an ordered log, and a fencing token protects against two owners.
- Delivery promises are made to people, so feasibility is a hard constraint on quantile ETAs, and courier cost is what gets minimized within it.
Failure modes
- 10x load (a storm or a big sporting event): the candidate set explodes. Shrink the search radius, lengthen the dispatch window, raise delivery fees or quoted times, and throttle new orders per zone before couriers drown. Keep checkout up even if the promise grows.
- Dispatcher crash: the lease expires in seconds, another instance replays the zone's partition from the snapshot and resumes. Outstanding offers survive because they are rows in the orders database.
- Region outage: orders DB has a synchronous replica in another zone and asynchronous cross-region replication. Zones are independent, so fail over market by market. Couriers already carrying food keep their instructions on the device and can complete drop-off offline, syncing later.
- Hot restaurant (a viral promotion): one restaurant gets hundreds of orders. Cap its intake by prep capacity, quote longer prep times, and batch aggressively from it, since many orders share a pickup.
- Bad ETA model deploy: deadlines start being missed. Shadow-score new models first, compare quantile error per city, and keep a one-click rollback.
- Slow shared dependency: one database gets slower, callers time out and retry, and the extra in-flight requests hit connection and request limits in the shared service proxy, which then rejects traffic for unrelated flows too. Envoy's per-cluster circuit breakers default to 1,024 connections, 1,024 pending requests, 1,024 active requests and 3 concurrent retries (Envoy docs), so audit them rather than inherit them. Cap retries with a budget and propagate deadlines so doomed work stops early (Google SRE book, Ch 22).
- Restaurant tablet offline: orders sit unaccepted. Fall back to phone calls through an ops tool after a short timeout and auto-cancel with a refund if nobody confirms.
In the wild
- DoorDash, 2022: a database resize becomes a three-and-a-half-hour outage. At 9:40 am on 12 May a routine operation began shrinking the delivery service's database cluster. Latency alerts came and went for hours; at 4:04 pm Dashers could not accept orders or confirm drop-offs, and pausing the resize made things worse. Slow responses plus retries filled connection and request limits in the shared Envoy layer, left at its default circuit-breaker settings, so Envoy returned 503s to unrelated flows, including the website. Recovery came at 7:30 pm. DoorDash audited Envoy settings, removed a migration-era database proxy, and refunded customers and compensated Dashers (DoorDash Engineering).
- DoorDash, 2020: from Hungarian matching to a MIP solver. DoorDash's earlier dispatch framed assignment as one delivery per route and solved it with the Hungarian algorithm, which was too slow on large instances and could not build multi-delivery routes. Reformulated as a mixed-integer program, it was solved up to 10 times faster by Gurobi, which also benchmarked 34 times faster than CBC on DoorDash's problems; the speed was spent on solving larger regions at once (DoorDash Engineering).
- Uber, 2022: ETAs as a routing estimate plus a learned correction. Uber's DeepETA predicts the residual between the routing engine's estimate and observed arrival times. The team tested seven architectures, chose a self-attention encoder-decoder, and used a linear transformer, which approximates attention without building the full attention matrix, to meet a few-millisecond serving budget (Uber Engineering).
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| Greedy nearest courier | Simple, instant | No batching, ignores near-future orders | Launching a small market |
| Periodic bipartite matching | Global view, fast solvers | One order per courier | Low order density |
| Bundle matching with MIP | Batching, best courier utilisation | Solver time and tuning cost | Dense markets at peak |
| Dispatch early | Courier arrives before food is ready | Courier idle time | Fast-prep restaurants, scarce couriers |
| Dispatch just in time | Little idle time | Risk of late pickup if ETA is wrong | Reliable prep-time predictions |
Leadership lens
- Build first: the order state machine with conditional transitions and the zone-partitioned event log. A greedy dispatcher on top is good enough to launch; the optimizer is an upgrade behind the same interface, measured by A/B tests per market (deliveries per courier hour, lateness rate).
- Ownership: Ordering and Payments, Logistics (dispatch, ETA), Courier platform (app, location, offers), and Restaurant integrations. Logistics owns the dispatch contract; the others integrate through events.
- Cost drivers: courier pay dominates everything; a few percent better batching outweighs the entire infrastructure bill. On the infrastructure side, the location pipeline and map or routing API calls are the largest lines.
- Rollout: switch dispatch algorithms per zone with a flag, run new ones in shadow mode first, and compare predicted against realised outcomes before cutover.
- Questions to ask: what is the deadline the solver enforces and who sets it; what happens to an order no courier can serve in time; how do we detect a zone with no owning dispatcher.
Check yourself
Why does dispatch run as a periodic optimization over bundles rather than assigning each new order to the nearest idle courier?
Show answer
Greedy matching on arrival ignores orders arriving seconds later, never batches, and sends couriers to food that is not ready. Collecting orders for a short window lets the solver pair orders from nearby restaurants onto one courier within the promised deadline, at the cost of solver time and tuning.
A dispatcher instance pauses and a new owner takes over the zone. What stops the old one from issuing conflicting offers?
Show answer
Each zone is owned through a lease, and every write carries the lease's fencing token, so a paused old owner's writes are rejected. Offers are also conditional writes that set
order.offer_idonly if it is null and the order is UNASSIGNED, so an order never has two accepted offers.Why are deadlines checked against quantile ETAs, and what is the trade-off between dispatching early and just in time?
Show answer
A p80 travel time protects the promise, whereas a mean would miss the deadline often when predictions run long. Dispatching early means the courier waits idle for food, while dispatching just in time cuts idle time but risks late pickup if the prep-time or travel ETA is wrong.
In a busy metro with 3,000 open orders and 8,000 couriers, how large is the scoring problem per round and how is it kept tractable?
Show answer
A full matrix would be 24 million pairs, but limiting each order to about 50 nearby couriers via H3 ring search leaves around 150,000 scored pairs per round. Travel times for those pairs come from a cheap cached or learned model rather than a live routing call per pair.
Sources and further study
Primary
- Next-Generation Optimization for Dasher Dispatch (DoorDash Engineering): the MIP formulation, solver benchmarks and why the Hungarian algorithm was dropped.
- DoorDash's May 12th Outage (DoorDash Engineering): a detailed timeline of a cascading failure through a shared proxy layer.
- How to do distributed locking (Martin Kleppmann): fencing tokens for the zone-ownership lease.
Systems docs
- Envoy circuit breaker configuration: the default connection, request and retry limits, and retry budgets.
- H3 grid traversal API:
gridDisk,gridRingand the pentagon fallbacks used for courier candidate search.
Books
- Designing Machine Learning Systems, Chip Huyen, Ch 7 "Model Deployment and Prediction Service" and Ch 8 "Data Distribution Shifts and Monitoring": batch versus online prediction for ETAs, and catching a model whose errors drift.
- Site Reliability Engineering, Beyer et al. (Google), Ch 22 "Addressing Cascading Failures": retry budgets, deadline propagation and load shedding for the order and dispatch paths.
Other curricula
- AWS Builders' Library: Timeouts, retries, and backoff with jitter: frames retries as a load amplifier that must be capped and jittered, the mechanism behind the 2022 outage.
- Azure Architecture Center: Leader Election pattern: frames single-owner-per-zone dispatch as electing one coordinator among instances, with leases and failover.
Watch
- System Design of DoorDash: Geo-Hashing and WebSockets (Gaurav Sen): location indexing and persistent connections for courier tracking.
- Design Navigation Systems like Google Maps, Uber and DoorDash (Irtiza Hafiz): the routing and ETA services that dispatch depends on.
Free resources
- Uber's Fulfillment Platform re-architecture (Uber Engineering): how Uber models deliveries and batch offers, including all-or-nothing updates when a courier accepts a batch.
- DeepETA: How Uber Predicts Arrival Times (Uber Engineering): a routing estimate corrected by a learned model, the pattern used for delivery ETAs.
- H3 documentation: hexagonal cells and ring queries for candidate generation.
- Vehicle routing problem (Wikipedia): the family of optimization problems behind batching with time windows.