Design Food-Delivery Dispatch
Design Food-Delivery Dispatch (DoorDash / Swiggy-style)
Here's the answer that sounds obviously correct and is wrong: "when an order comes in, find the nearest idle courier and assign it." Every word of that is a trap. It assigns the instant the order is placed, so the courier sprints to the restaurant and then stands on the sidewalk for eleven minutes while the kitchen is still frying the food — you've burned your most expensive resource (courier-minutes) waiting on a stove. It assigns one courier to one order, so two orders from the same restaurant going to the same apartment block ride in two separate cars when one trip would do — you've doubled your delivery cost for no reason. And "assign the instant it's placed" from many app servers at once means two orders can grab the same nearest courier in the same millisecond — the dispatch race, a courier now holding two jobs it never agreed to. Food delivery is not a lookup problem like "find the nearest driver"; it is a three-sided matching + optimization problem over orders, couriers, and restaurants, and the single insight that separates a passing answer from a failing one is: you do not dispatch greedily on order-placed; you dispatch on food-ready, in batches, under an atomic assignment. This lab derives that from the naive version breaking in your hands.
1. The Trap — greedy nearest-idle-courier-on-order-placed
Write the obvious dispatcher and watch three separate things break. The naive loop, running on every app server:
// NAIVE dispatch — runs the moment an order is placed
onOrderPlaced(order):
courier = geoIndex.nearestIdleCourier(order.restaurant.location)
assign(courier, order) // courier drives to restaurant NOW
Break #1 — the courier idles at the restaurant (ignores food-ready time). An order is placed at 7:00:00. The kitchen quotes 18 minutes of prep. The nearest idle courier is 5 minutes away. Greedy dispatch sends her now: she arrives 7:05, and then stands there until 7:18 — 13 minutes of paid courier time evaporated, doing nothing, while a second order two blocks over that she could have delivered goes unassigned because she's "busy." Multiply by a fleet of 300K couriers at dinner rush and you've structurally destroyed maybe 20-30% of your delivery capacity by dispatching too early.
Break #2 — no batching (one courier, one order, always). Two orders come from Sushi Palace within 90 seconds of each other, both headed to the same downtown high-rise. The optimal move is one courier, one pickup, two drop-offs — one trip. Greedy assigns two couriers and runs two trips. On dense dinner traffic, batching two orders onto one trip is often a 30-40% efficiency gain; greedy leaves 100% of it on the table because it can only reason about one order at a time.
Break #3 — the dispatch race (one courier, two orders). Two orders are placed 3 ms apart. App server A and app server B each independently call nearestIdleCourier(), both see courier #778 as idle (her "busy" flag hasn't been written yet), and both call assign(#778, ...). Now courier #778 holds two jobs she never agreed to, from two restaurants in opposite directions. This is the exact double-booking race — a lost-update on the courier's assignment state — and it is invisible in a single-threaded local test. It only shows up under the concurrency the naive design pretends doesn't exist.
Three failures, one root cause: greedy-on-order-placed treats an optimization problem as a lookup, and treats a contended write as if it were uncontended. The rest of the lab fixes both.
2. Scope it like a senior (ask before you draw a box)
Every one of these answers changes the design. Pin them down first:
- Assignment timing — assign-on-order or delayed/batched? Do we commit a courier the instant the order is placed, or hold it in a short window and dispatch closer to food-ready so we can batch and avoid idling? (This is the decision — it determines whether Break #1 and #2 even exist.)
- Objective — per-order ETA or fleet efficiency? Are we minimizing each customer's wait, or maximizing deliveries-per-courier-hour across the whole fleet? These conflict: the ETA-optimal move (dedicate a courier to one order) is often the fleet-wasteful one (no batching). Where's the dial set, and does it move during a shortage?
- Supply/demand & surge: at dinner rush demand outstrips online couriers. Do we surge-price to pull supply in, queue orders, quote longer ETAs, or all three? Is there a hard SLA we must not breach, or is a longer honest ETA acceptable?
- Courier location updates: how often do couriers ping GPS (every 3-5s is typical), and do we need exact real-time positions or is "good enough within a few seconds" fine for matching? (This sizes the single biggest write firehose in the system.)
- Scale: orders/day and the dinner-rush peak factor? Active (online) couriers at peak? Are we global-but-city-partitioned (a courier in NYC is never matched to a restaurant in SF), which lets us shard by city?
- Non-goals: we are NOT building payments, restaurant menu management, or the routing/turn-by-turn engine — assume a maps service gives us travel-time estimates. We ARE building order lifecycle + the dispatch engine.
Assume for this lab: 10M orders/day; dinner+lunch rush gives a 5× peak over daily average; 300K couriers online at peak; couriers ping location every 4s; city-partitioned (dispatch is always local to a metro); the default objective is fleet efficiency with a per-order ETA guardrail (never let one order's ETA blow past an SLA to save fleet cost); delayed/batched assignment is on the table (that's what we'll derive); surge handled by ETA-quoting + pricing, out of the deep scope here.
3. Reason to the design (derive it, don't recite it)
Attempt 1 — greedy nearest-idle on order-placed. We just broke it three ways (§1). Its fatal assumption is that the courier should start moving the moment the order exists. But the order isn't ready the moment it exists — there's an 8-25 minute kitchen delay in between. Any design that ignores food-ready time is dispatching blind.
Attempt 2 — model the order as a lifecycle, not an event. An order isn't a point in time, it's a state machine: Placed → Preparing → Ready → PickedUp → Delivered. The moment you draw that, the fix to Break #1 is obvious: you don't need the courier at the restaurant when the order is Placed; you need her there right as it hits Ready. So dispatch has to consume a food-ready ETA (predicted from restaurant + item history + current kitchen load) and work backwards: dispatch a courier who is roughly travel_time minutes away, timed so she arrives as the food does. The state machine turns "dispatch on an event" into "dispatch against a predicted timeline."
Attempt 3 — dispatch in short batched windows, not per-order. Once you're timing against food-ready anyway, you're no longer forced to decide each order in isolation the instant it arrives. Collect the orders in a geo-cell over a short window (a dispatch "tick," e.g. every ~2 seconds) and solve them together: now you can see that order A and order B are the same restaurant → same building and put them on one courier's trip (fixes Break #2). Batching is only possible because you stopped dispatching greedily on arrival. The window is a real trade-off — too long and orders sit; too short and you're back to near-greedy with no batching opportunity (see §6).
Attempt 4 — make the assignment atomic. Batched or not, the final act is "give courier #778 this trip," and multiple dispatch workers (or app servers) can target the same courier concurrently (Break #3). The courier's assignment must be a compare-and-set: claim #778 only if she is still idle; if someone else claimed her a millisecond earlier, the CAS fails and we re-score with her removed from the candidate set. This is the double-booking race fix — a courier's status is a contended resource guarded by an atomic transition, optionally an explicit distributed lock when the assignment spans multiple stores.
Attempt 5 — separate the firehose from the decision. 300K couriers pinging every 4s is 75K writes/s of location data — but the dispatch decision runs only ~580 times/s at peak. Conflating them is a mistake: the location stream is a high-volume, lossy-tolerant write into a geohash/quadtree geo-index (last-write-wins, no durability drama if one ping is dropped), while the dispatch decision is a low-volume, must-be-correct optimization that reads a snapshot of that index. Keep them as two different paths with two different consistency budgets. That separation is the whole architecture.
Put together: an order lifecycle state machine emits "ready-soon" orders → a dispatch engine reads a snapshot of the courier geo-index + the food-ready ETA predictor, batches within a short window, scores candidates on ETA + fleet-efficiency, and commits via an atomic (CAS/lock) assignment. Delayed, batched, atomic — the opposite of greedy-on-placed.
4. Build it — the design worksheet
This is the artifact you produce on the whiteboard. Numbers are traced, not asserted — recompute them before an interview.
1. Requirements
- Functional: place an order and track its lifecycle; predict food-ready time; ingest courier location pings; dispatch couriers to orders honoring food-ready timing; batch compatible orders onto one trip; let a courier accept/decline an offer; never double-assign a courier.
- Non-functional: dispatch decision latency low (seconds, not minutes — a courier can't wait); the location-ingest path must absorb the full 75K/s firehose without backpressuring dispatch; assignment must be correct under concurrency (no double-booking) even at the cost of a little latency; graceful degradation under supply shortage (quote longer ETAs, don't crash).
2. Back-of-envelope estimation (BOTE)
- Order rate: 10M orders/day ÷ 86,400s ≈ 116 orders/s average. Food ordering is spiky — lunch + dinner rush. With a 5× peak factor → ~580 orders/s at dinner peak.
- Courier location firehose: 300K couriers online at peak, each pinging every 4s → 300,000 ÷ 4 = 75,000 location updates/s. Compare to 580 orders/s: the location stream is ~130× larger than the order stream (75,000 ÷ 580 ≈ 129). This is the load-bearing number of the whole system — the firehose is GPS pings, not orders. It's why the geo-index write path and the dispatch read path must be separated (§3, Attempt 5).
- Dispatch decisions/s: one decision per order at peak ≈ 580/s. Each decision scores the candidate couriers within radius of the restaurant — say ~20 nearby couriers per order → 580 × 20 = ~11,600 courier-scoring ops/s. That's trivial CPU (it's arithmetic on in-memory data), which is the point: the optimization is cheap; the coordination is the hard part.
- Geo-index size: 300K couriers × ~250 bytes each (id, lat/lng, status, current-batch, geohash cell) = ~75 MB. The entire live courier fleet fits in RAM on a single node with room to spare — even 10× the couriers is 750 MB. The geo-index is small; the write rate into it is what's large.
- Location-ingest bandwidth: 75,000 pings/s × ~100 bytes/ping ≈ 7.5 MB/s ingest. Comfortable for a single ingest tier per region; last-write-wins, so no durability cost.
- Order storage: 10M/day × ~1.5 KB/order (items, 2 addresses, price, courier id, full state-transition log) = ~15 GB/day → × 365 ≈ ~5.5 TB/year. Ordinary sharded storage, no exotic engine.
3. API sketch
POST /orders placeOrder
body: { userId, restaurantId, items[], dropoffLoc }
-> 201 { orderId, state:"Placed", predictedReadyAt }
POST /couriers/{id}/location updateCourierLocation (FIREHOSE: 75K/s)
body: { lat, lng, ts }
-> 202 Accepted // fire-and-forget, last-write-wins, no ACK contract
POST /dispatch/assign assignCourier (internal, dispatch tick)
body: { orderIds[], candidateCourierIds[] }
-> { assignments:[{orderId, courierId}] } // atomic CAS per courier
POST /assignments/{id}/accept courier accepts/declines an offer
-> 200 { state:"Accepted" } | 409 OFFER_EXPIRED
GET /orders/{orderId} lifecycle state + live courier location
4. Data model & partitioning
- Everything shards by city / metro region. Dispatch is inherently local — you never match an NYC courier to an SF restaurant — so city is the natural partition key. It co-locates the orders, couriers, and geo-index that any single dispatch decision needs, so a decision never crosses a shard. A stadium event or a dinner rush is a hot city, addressed by splitting that city's geo-index into finer geohash cells, not by resharding the world.
orders(sharded by city):orderId (PK), userId, restaurantId, items, dropoffLoc, state, predictedReadyAt, assignedCourierId, stateLog[]. Thestatetransitions are the lifecycle machine; every transition is appended tostateLogfor audit and ETA training.couriers/ geo-index (in-memory, partitioned by geohash cell within a city):courierId → {lat, lng, status(idle|offered|on_trip), currentBatch}. Backed by a Redis-geo / quadtree structure; the 75K/s pings are last-write-wins updates here, never touching the durable orders store.- Assignment as an atomic transition: a courier's
statusis the contended field. Claiming is a conditional write —UPDATE courier SET status='offered', batch=X WHERE id=? AND status='idle'— a CAS that fails if someone claimed her first. For a multi-order batch spanning stores, wrap it in a short-lived distributed lock on the courier id. - Dispatch input queue: "ready-soon" orders flow from the Order Service to the Dispatch Engine over a message queue, decoupling order intake spikes from the steady dispatch tick.
5. High-level diagram
The order path (blue) advances the lifecycle state machine. The courier/geo path (green) absorbs the 75K/s ping firehose into the in-memory geo-index. The Dispatch Engine (amber) is the optimizer: on each ~2s tick per geo-cell it reads a snapshot of ready-soon orders + food-ready ETAs + nearby couriers, batches, scores on ETA + fleet-efficiency, and commits each courier via an atomic CAS — the dashed amber arrows are the assignment writing back to advance the order state and offer the trip to the courier.
6. Rubric — what a strong answer hits
- States BOTE out loud and identifies the location firehose (75K/s) as ~130× the order rate — the true load-bearing number — before drawing boxes.
- Models the order as a lifecycle state machine, and derives delayed/food-ready-timed dispatch from it (not greedy-on-placed).
- Names batching as a deliberate efficiency lever with a stated window trade-off, not an afterthought.
- Separates the high-volume lossy geo-index write path from the low-volume must-be-correct dispatch decision — two consistency budgets.
- Makes assignment atomic (CAS / distributed lock) and can explain the double-booking race it prevents.
- Partitions by city/metro and explains why a decision never crosses a shard, and how a stadium event is a hot city addressed by finer geohash cells.
- Model-answer reading: the closest guide treatments are Dispatch & Assignment: the double-booking race (the atomic-assignment core), Designing Uber backend and Designing Uber — Geospatial Matching, Traced (the geo-matching engine), with building blocks Geohashing and Quadtrees, distributed locking, and Message Queues vs Service Bus.
5. Break it — three traced failures of the naive design
1. Greedy dispatch → courier idles at the restaurant, traced. Order placed 7:00:00, kitchen prep 18 min → ready 7:18. Nearest idle courier is 5 min out. Greedy sends her at 7:00:00; she arrives 7:05:00 and waits until 7:18:00 = 13 minutes of paid idle time. The correct dispatch, timed against the food-ready ETA, sends her at ~7:13 so she arrives right as the food does — those 13 minutes are now spent completing a different delivery. At 300K couriers in a dinner rush, systematically dispatching ~13 min early is a double-digit-percent haircut on effective fleet capacity — the difference between "we have enough couriers tonight" and "everything is 40 minutes late." The fix is Attempt 2/3: dispatch against food-ready time, not order-placed time.
2. The double-assignment race, traced. Orders X and Y arrive 3 ms apart. Two dispatch workers run concurrently:
// NAIVE assign — read then write, no atomicity
w1: c = nearest(X); // reads courier 778 as idle
w2: c = nearest(Y); // ALSO reads 778 as idle (w1 hasn't written yet)
w1: assign(778, X); // 778 now on trip X
w2: assign(778, Y); // lost update: overwrites, 778 now "on" X AND Y
Courier 778 is physically one person on one scooter, now holding two trips from restaurants in opposite directions; one customer's food is guaranteed cold or dropped. This is a classic lost-update / double-booking race. The fix is Attempt 4: the claim is a CAS — UPDATE ... SET status='offered' WHERE id=778 AND status='idle'. Exactly one worker's conditional update succeeds; the loser sees 0 rows affected, drops 778 from its candidate set, and re-scores. Remove that AND status='idle' guard and this race reappears instantly — that removal is the failure lesson.
3. Dispatch too early vs too late — the window is a real cost both ways. Dispatch too early (Break #1) burns courier idle time. Dispatch too late — hold the order to squeeze out more batching — and the food sits under a heat lamp getting soggy while no courier is en route, blowing the customer ETA. There is no free lunch: the dispatch window is a tuned parameter (a couple of seconds of batching collection, dispatch timed to arrive near food-ready). Get it wrong in either direction and you lose — which is precisely why "just assign nearest, now" (no window at all) isn't the safe default; it's one specific, bad, corner of this trade-off.
6. Optimise — with trade-offs vs named alternatives
| Decision | Option A | Option B | Pick when |
|---|---|---|---|
| Assignment strategy | Greedy nearest-idle courier | Delayed batch optimization (collect a window, solve together) | Delayed-batch almost always at scale — it enables food-ready timing and batching, the two biggest efficiency levers. Greedy only when the fleet is so sparse there's nothing to batch and latency-to-assign is the sole concern (e.g. a brand-new market with 3 couriers). |
| Dispatch trigger | Assign-on-order (commit at order-placed) | Assign-on-ready (commit timed to food-ready ETA) | Assign-on-ready to eliminate courier idle-at-restaurant (Break #1). Assign-on-order only when prep time is near-zero (e.g. pre-made/grab-and-go) so there's no ready-time gap to exploit — then the extra machinery buys nothing. |
| Objective function | Per-order ETA (minimize each customer's wait) | Global fleet efficiency (max deliveries/courier-hour) with an ETA guardrail | Fleet-efficiency-with-guardrail is the production default: it batches and packs the fleet while a per-order SLA cap prevents any single order from being sacrificed. Pure per-order ETA only when supply is abundant (efficiency doesn't bind) or for a premium "priority" tier the customer paid for. |
| Assignment concurrency control | Optimistic CAS (conditional update, retry on conflict) | Pessimistic distributed lock on the courier id | Optimistic CAS by default — conflicts are rare (two workers targeting the same courier in the same tick is uncommon) so retries are cheap and there's no lock to hold/expire. Reach for an explicit lock when one assignment must atomically mutate several records across stores (courier + both orders in a batch) where a single CAS can't cover all of them. |
| Batch size | Single-order dispatch (1 courier : 1 order) | Batched dispatch (2–3 orders : 1 trip) | Batch when order density is high and drop-offs cluster (dense urban dinner rush) — 30–40% fewer trips. Stay single-order when density is low (rural/late-night) so batching would force long detours that blow ETAs, or for time-critical items where a second stop is unacceptable. |
| Location ingest durability | Durable write per ping (to the orders store) | In-memory last-write-wins geo-index | In-memory LWW always for the 75K/s firehose — a location ping is disposable (the next one is 4s away), so durability is wasted cost and would couple the firehose to the orders store. Durable only for the sparse events that matter (order state transitions), never for raw GPS. |
7. Defend under drilling
Q1 — "How do you avoid a courier idling at the restaurant?"
Dispatch is timed against a predicted food-ready ETA, not order-placed time. The Order Service is a lifecycle state machine and the prep-time predictor estimates when the food hits Ready; the dispatch engine works backwards by the courier's travel time and offers the trip so she arrives right as the food does, not 13 minutes early. Concretely: order placed 7:00 with an 18-min prep, a 5-min-away courier is dispatched around 7:13, not 7:00. Those saved minutes go to another delivery — that's the fleet-efficiency win.
Q2 — "How do you prevent double-assigning one courier?"
The courier's status is a contended field and the claim is an atomic compare-and-set: UPDATE courier SET status='offered' WHERE id=? AND status='idle'. Exactly one concurrent worker's conditional write succeeds; the loser gets 0 rows affected, drops that courier, and re-scores against the remaining candidates. For a multi-order batch that must mutate several records atomically, I wrap the claim in a short-lived distributed lock on the courier id (with a lease/TTL so a crashed worker can't hold it forever). This is the double-booking race fix — I never do a read-then-blind-write.
Q3 — "Why not just assign the nearest idle courier the instant the order comes in? It's simpler."
It's simpler and it's three bugs (§1). It ignores food-ready time (courier idles at the restaurant), it can't batch (one courier per order even when two share a destination), and read-then-write with no atomicity double-books couriers under concurrency. Greedy is the right answer only in a market so sparse there's nothing to batch and no concurrency to race — which is not the system we're scoped for.
Q4 — "The batching window adds latency. Isn't holding orders bad for the customer?"
The window is a tuned trade-off, and both extremes lose. Too long: food sits under a heat lamp, ETA blows. Too short: no batching, back to near-greedy waste. We collect for a couple of seconds and, crucially, time dispatch to food-ready anyway — so the window usually overlaps with the kitchen still cooking, costing the customer ~nothing while buying batching efficiency. And a per-order ETA guardrail caps the worst case: if holding would breach the SLA, that order is dispatched immediately, unbatched.
Q5 — "Courier location is 75K writes/s. Won't that overwhelm the system?"
Only if you conflate it with the decision path. Location pings go into an in-memory, last-write-wins geo-index (geohash/quadtree) — ~75 MB for 300K couriers, ~7.5 MB/s, no durability, a dropped ping is harmless because another arrives in 4s. The dispatch decision is a separate, low-volume path (~580/s) that reads a snapshot of that index. High-volume-lossy write and low-volume-correct read have different consistency budgets and must not share a code path or a datastore.
Q6 — the 100× escalation: "A stadium event just let out / it's peak dinner rush — 100× the orders in one small area. What breaks first?"
The hot spot is a single geo-cell, not the whole system, because we partition by city and index by geohash. First response: subdivide that cell into finer geohash cells so dispatch work and the courier index for the area spread across more workers/shards instead of one hot partition. Second: demand now structurally exceeds nearby supply, so this becomes a supply problem, not a dispatch bug — we surge-price to pull couriers toward the venue, quote honestly longer ETAs (backpressure via the order queue rather than dropping orders), and lean harder into batching (dense identical-destination orders are the ideal batch case). The dispatch engine itself doesn't fall over because scoring is cheap in-memory arithmetic (~11,600 ops/s even at normal peak, and it scales horizontally per cell); the binding constraint is real-world courier supply, which we manage economically, not with more servers.
Q7 — "How does a courier declining an offer not deadlock the order?"
An offer is a time-boxed state, offered, with a short expiry. If the courier accepts, the trip is committed and her status goes on_trip. If she declines or the offer times out (409 OFFER_EXPIRED), her status flips back to idle and the order returns to the dispatch pool for the next tick, which re-scores it against the now-current candidate set (minus the decliner). Because assignment is CAS-guarded, a late accept after expiry simply fails the conditional update — no deadlock, no double-commit.
8. You can now defend
- You can explain why greedy nearest-idle-on-order-placed is not a lookup but a broken optimization — and name its three concrete failures (idle-at-restaurant, no-batching, double-booking) from first principles.
- You can derive delayed, food-ready-timed, batched dispatch from an order lifecycle state machine, instead of reciting it.
- You can size the system and identify the location firehose (~130× the order rate) as the load-bearing number, and separate its high-volume-lossy write path from the low-volume-correct dispatch decision.
- You can make assignment atomic (CAS / distributed lock), explain the double-booking race it prevents, and defend the batching-window and per-order-ETA-vs-fleet-efficiency trade-offs against a drilling interviewer — including a 100× stadium-rush escalation.
Re-authored/Deepened for this guide. Model-answer reading: Dispatch & Assignment: the double-booking race, Designing Uber backend, and Designing Uber — Geospatial Matching, Traced (System Design Problems); building blocks Geohashing and Quadtrees, distributed locking, and Message Queues vs Service Bus.
🤖 Don't fully get this? Learn it with Claude
Stuck on Design Food-Delivery Dispatch? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.
Build the mental picture, not memorization.
I just read a lesson on **Design Food-Delivery Dispatch** (Hands-On Builds) and want to truly understand it. Explain Design Food-Delivery Dispatch from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Socratic — adapts to where you're stuck.
Teach me **Design Food-Delivery Dispatch** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Active recall exposes what you missed.
Quiz me on **Design Food-Delivery Dispatch** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Intuition + hook + flashcards for long-term memory.
Help me remember **Design Food-Delivery Dispatch** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.