System design explainer
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?
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.
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.
"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.
| Assumption | Value |
|---|---|
| Orders per day (one large metro) | 200,000 |
| Order QPS | 200k ÷ 86,400 ≈ 2.3 avg · dinner peak ~10× ≈ 25 QPS |
| Active couriers | 15,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 window | 30s → ~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."
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.
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.
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
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"]
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.
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.
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.
| Decision | Why | Cost |
|---|---|---|
| 30s batch window | Sees clusters, enables batching | ~15s average added wait before assignment |
| Max 2–3 orders per courier | Food stays hot, routes stay sane | Leaves some theoretical efficiency on the table |
| Dispatch against p70 prep time | Courier arrives as food is ready | Occasional short wait when kitchen is fast |
| Insertion heuristic, not optimal VRP | Fast, debuggable, good enough | A few % worse than optimal routes |
| Courier can decline a batch | Humans, not robots | Re-dispatch latency on declines |
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.
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