Engineering Atlas › System Design Interviews

Design a food ordering and delivery platform

19 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

Requirements

Functional:

Non-functional:

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 | /dropoff

Data 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 --> E

Write 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:

  1. 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.
  2. 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.
  3. 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

  1. Couriers are the scarce, expensive resource, so each courier hour should carry as many deliveries as the promise allows, and that means batching.
  2. Batching needs a view of several orders at once, so dispatch waits a short window and optimizes, instead of matching instantly.
  3. Optimizing needs thousands of time predictions per second, so travel time comes from cheap models, with a routing engine only for the final route.
  4. 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.
  5. 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

In the wild

Trade-offs

OptionStrengthsWeaknessesChoose when
Greedy nearest courierSimple, instantNo batching, ignores near-future ordersLaunching a small market
Periodic bipartite matchingGlobal view, fast solversOne order per courierLow order density
Bundle matching with MIPBatching, best courier utilisationSolver time and tuning costDense markets at peak
Dispatch earlyCourier arrives before food is readyCourier idle timeFast-prep restaurants, scarce couriers
Dispatch just in timeLittle idle timeRisk of late pickup if ETA is wrongReliable prep-time predictions

Leadership lens

Check yourself

  1. 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.

  2. 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_id only if it is null and the order is UNASSIGNED, so an order never has two accepted offers.

  3. 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.

  4. 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

Systems docs

Books

Other curricula

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →