System design explainer

Design food delivery

The interview question behind every DoorDash order: food appears at your door, hot. How do you dispatch thousands of couriers across a city so the timing works — courier arrives just as the food is ready, and nobody delivers one sad burrito at a time?

The takeaway, up front

Food delivery is a three-sided dispatch problem — customer, restaurant, courier — and the whole game is batching. One courier, multiple orders, sequenced so pickups happen the moment food is ready and dropoffs chain together. The dispatcher matches orders to couriers to minimize total delivery time, not distance. Get batching right and you roughly double deliveries per courier-hour; get it wrong and you have the world's most expensive taxi service for burritos.

1 · Requirements

What are we actually building?

The analogy: it's Uber with a kitchen timer attached. In ride-hail the "package" is ready immediately; here the package needs 12 minutes in an oven, and the courier must arrive exactly then — not 10 minutes early (food sits, gets cold, courier idles) and not 10 minutes late. The dispatcher is really a conductor keeping three clocks in sync.

Functional

  • Customer browses restaurants, places an order, tracks it live
  • Restaurant receives the ticket, confirms, reports prep time
  • Dispatch assigns a courier — possibly batching 2–3 orders
  • Courier app: route with sequenced pickups and dropoffs
  • Live tracking, accurate delivery ETA, proof of delivery
  • Courier pay calculation per delivery

Non-functional

  • Assignment latency: courier assigned within ~30–60s of order
  • Food-hot SLA: pickup within minutes of ready; delivery under ~45 min
  • Throughput: dinner-rush peaks at 10× the daily average
  • Reliability: an order is never lost between "paid" and "delivered"
  • Fairness: couriers get steady work; no starvation by algorithm

🚫 Common misconception

"Dispatch sends the closest courier to each order." Closest-first is the demo answer, and it's wrong at scale. The closest idle courier is often the worst choice — a slightly farther courier already carrying an order from the same restaurant, heading the same direction, will deliver both orders faster and cheaper. Real dispatch batches orders onto couriers and sequences the route; proximity is just one input to that optimization.

2 · Back-of-the-envelope

Capacity math

AssumptionValue
Orders per day (one large metro)200,000
Order QPS200k ÷ 86,400 ≈ 2.3 avg · dinner peak ~10× ≈ 25 QPS
Active couriers15,000 × 1 ping / 10s = 1,500 QPS location writes
Courier productivity~2.5 deliveries/hour batched (vs ~1.5 solo)
Concurrent couriers at dinner peak~15k orders/hour ÷ 2.5 ≈ 6,000 busy — batching is what makes the fleet math work
Order record~3 KB → 200k × 3 KB ≈ 600 MB/day per metro
Batch window30s → ~750 orders per national batch cycle; partition by city zone

The line to say out loud: "Batching 2–3 orders per courier nearly doubles deliveries per hour — that's not an optimization, it's the business model."

Go deeper: why the batch window exists

Dispatching each order the instant it arrives is greedy and myopic — you can't batch what you haven't seen yet. Holding orders for ~30 seconds lets the optimizer see clusters: two orders from the same restaurant, three dropoffs in one neighborhood. The cost is ~15 seconds of average added wait, invisible against a 12-minute prep time. The window length is the central tuning knob: longer window, better batches, slower first response.

3 · Architecture

The system, end to end

flowchart TB
    CA["Customer app"]
    RA["Restaurant tablet"]
    DA["Courier app"]
    GW["API gateway"]
    OS["Order service
order state machine"] DE["Dispatch engine
assignment + batching"] GEO["Geo + ETA service"] NOTIF["Notifications"] DB[("Orders DB
Postgres")] CA --> GW RA --> GW DA --> GW GW --> OS OS --> DE DE --> GEO OS --> DB DE --> NOTIF OS -. "ticket + prep ETA" .-> RA OS -. "live tracking" .-> CA OS -. "route + batch" .-> DA

The order service owns the state machine (placed → confirmed → preparing → ready → picked up → delivered). The dispatch engine is stateless-ish: it reads the world, writes assignments, and the state machine makes them durable.

4 · Component deep-dives

An order's journey

sequenceDiagram
    autonumber
    participant C as Customer
    participant O as Order service
    participant R as Restaurant
    participant D as Dispatch engine
    participant Cr as Courier
    C->>O: POST /v1/orders (items, dropoff)
    O->>R: new ticket on kitchen display
    R-->>O: confirmed, prep_eta 12 min
    O->>D: ready for dispatch
    D->>D: 30s batch window:
insert into courier routes D->>Cr: offer batch (2 orders, sequenced) Cr-->>D: accept D-->>C: courier assigned, live ETA Cr->>R: arrive as food is ready, pickup Cr->>C: dropoff 1, dropoff 2, delivered O->>O: record times, pay courier

How batching decides

flowchart TB
    A["New order:
restaurant + dropoff + prep time"] --> B["Hold for 30s batch window"] B --> C["Consider couriers with
fewer than 2 active orders"] C --> D["Score each: added route time
of inserting pickup + dropoff"] D --> E{"fits without making
any food cold?"} E -->|"yes — best score"| F["Batch it:
append stops to courier route"] E -->|"no fit"| G["Solo courier
or next window"] F --> H["Sequence stops:
pickups in prep-ready order,
then chained dropoffs"]
Go deeper: the sequencing problem

With 2 orders you have pickups P1, P2 and dropoffs D1, D2, with the constraint that each pickup precedes its dropoff — and a soft constraint that hot food shouldn't ride around for 20 extra minutes. The insertion heuristic (try the new order's stops in every position of the existing route, keep the cheapest valid one) is O(n²) per courier and plenty good. Full vehicle-routing solvers exist, but at 30-second batch cadence the heuristic wins on simplicity and debuggability.

Go deeper: what "prep time" really is

Prep ETA is the noisiest input in the system — kitchens lie, optimistically or otherwise. Production systems learn per-restaurant, per-item, per-hour prep distributions and dispatch against a percentile (e.g. p70), not the quoted number. Dispatching the courier to arrive at quoted-prep + buffer beats arriving exactly on time and waiting. Every completed order feeds back into the model.

5 · API + data model

The contract

POST /v1/orders
{"restaurant_id": "rest_42", "items": ["ramen", "gyoza"],
 "dropoff": {"lat": 37.77, "lng": -122.41}}
→ 202 Accepted {"order_id": "o_9k2m", "prep_eta_min": 12}

POST /v1/orders/o_9k2m/confirm     # restaurant: {"prep_eta_min": 14}
POST /v1/courier/location           # courier heartbeat
GET  /v1/orders/o_9k2m/track        # websocket: courier position + ETA

Data model: Order {id, state, restaurant_id, prep_eta, ready_at, items[]} — the state machine is the source of truth. Courier {id, loc, capacity, active_stops[]} is ephemeral in the geo index. Batch {id, courier_id, order_ids[], stop_sequence[]} is the dispatcher's output, versioned so a re-dispatch never double-assigns.

6 · Trade-offs

What you give up, on purpose

DecisionWhyCost
30s batch windowSees clusters, enables batching~15s average added wait before assignment
Max 2–3 orders per courierFood stays hot, routes stay saneLeaves some theoretical efficiency on the table
Dispatch against p70 prep timeCourier arrives as food is readyOccasional short wait when kitchen is fast
Insertion heuristic, not optimal VRPFast, debuggable, good enoughA few % worse than optimal routes
Courier can decline a batchHumans, not robotsRe-dispatch latency on declines
7 · Failure modes

What breaks, and what saves you

8 · What I'd actually build

Opinionated, concrete, shippable

Dispatch: Python or Go service, 30s batch loop, greedy insertion heuristic, max 2 orders per courier for v1. State: Postgres, one row per order with a strict state machine; Redis GEO for courier positions. Comms: websockets for live tracking, push for state changes. Prep times: start with restaurant-quoted, learn p70 per item within weeks. One team, one quarter — and the batching story is your demo.

9 · Interview tips

How to run the room

  1. Open with "three-sided marketplace." It frames every decision after it.
  2. The money sentence: "The courier should arrive at the restaurant exactly when the food is ready." Everything — prep learning, batching, sequencing — serves that.
  3. Draw the batching flowchart before you're asked. It's the differentiator from the Uber question.
  4. Quantify courier productivity: 2.5/hr batched vs 1.5/hr solo. Numbers make the business case.
  5. Have a cold-food story: what happens when the kitchen is slow? Re-dispatch and prep learning.
  6. End with metrics: on-time rate, food-wait time at restaurant, deliveries per courier-hour.
10 · Interactive widget

Dispatch simulator — watch couriers batch orders

Three restaurants, four couriers. Tap New order and the dispatcher assigns it — preferring a courier already heading that way, batching up to 2 orders per courier. Each order card shows prep progress and live ETAs. Turn on auto orders and watch batching happen on its own. 1 real second = 1 kitchen minute

courier waiting for food ■ restaurants R1–R3 · colored pins = customers
Active orders: 0 Busy couriers: 0 Delivered: 0 Avg delivery: – Batched orders: 0

The dispatcher scores each courier by added route time — not distance — and caps couriers at 2 active orders. That's the insertion heuristic from the article, running live.

v2026.10.03-01