03. Food Delivery Service — Interview Q&A¶
~16 min read · Part 4 of 4 (Overview → HLD → LLD → Q&A)
These are the questions an interviewer actually asks once the diagram is on the board. Each answer is the strong version, followed by the wrong answer that quietly sinks candidates.
Q1. Why not just assign each order to the nearest available courier the moment it comes in? Greedy nearest-courier is fast per order but allocates the fleet badly under load. It commits a courier the instant one order appears, so it can never see that a second nearby order arriving two seconds later would have paired cheaply onto the same trip — it has already sent the courier away. It also grabs the closest driver even when that driver is the only one who could serve a cluster forming just past them. The better model is a windowed batch optimizer: collect a short window of dispatchable orders, consider single and batched trips together, and solve a min-cost assignment over the whole set. This is why single dispatch needs ~20,000 couriers for a 50,000-order hour while batching needs ~14,000. Greedy is the correct fallback when the optimizer is down, not the primary design. Common wrong answer to avoid: "Nearest free driver, always — it minimizes pickup time." It minimizes one order's pickup time while wasting fleet capacity, so during a rush the whole city runs short even though each individual match looked optimal.
Q2. Walk me through surviving the 7pm rush — 50,000 orders in one hour in one city. That is ~14 orders/second sustained, ~33,000 orders live at once, and ~14,000 couriers on the road with batching. The dispatch tick runs every 15 seconds and is partitioned by H3 cell, so each cell solves a small ~30-order-by-~40-courier assignment in milliseconds and the cells run in parallel — the citywide combinatorial explosion never happens because a northern courier is never a candidate for a southern drop. Batching bundles ~1.4 orders per trip, which is what stretches 14,000 couriers across work that would otherwise need 20,000, and each batch is capped by an +8-minute food-hot budget so no meal is sacrificed to the savings. When a cell still runs short, orders age in the pool and escalate to supply mitigations rather than being dropped. Common wrong answer to avoid: "Autoscale the order service to handle 14 writes/second." The write rate was never the problem; 14 inserts/second is trivial. The rush is a matching-and-supply problem, and scaling the stateless order tier does nothing for it.
Q3. Batch or single dispatch — how do you decide? It is a per-pairing decision, not a global mode. Bundling a second order onto a trip saves a courier but delays that order's food, so you batch only when the incremental delay stays under a food-hot budget (say +8 minutes) and the detour is small; otherwise you dispatch it alone. The optimizer prunes to feasible batches first, then lets the min-cost solve prefer a batch when its combined food-in-transit cost beats two single trips — which during a rush it usually does, because couriers are the scarce resource. Tightening the budget from +8 to +4 minutes protects meal quality but shrinks the batchable fraction and pushes the fleet requirement from ~14,000 back toward ~17,000. That dial is a real operational lever, not a fixed constant. Common wrong answer to avoid: "Always batch to save the most couriers." Unbounded batching stacks orders onto trips until the third or fourth drop arrives cold — you save couriers and lose customers.
Q4. How do you keep ETAs honest for 33,000 orders without melting a routing engine? Recognize the load first: refreshing every live order's ETA every 5 seconds is ~6,600 recomputes/second, and a fresh routing call is ~50 ms — you cannot make 6,600 of those per second. So precompute the expensive part: a cell-to-cell travel-time matrix refreshed once per minute from the routing engine under live traffic, and reduce each per-order ETA to two matrix lookups plus the kitchen's prep estimate. That is a ~99% cut in routing-engine load and turns the per-order path into a memory read. Smooth the pushed number with a moving average so it drifts rather than flaps. The precision you trade away is cell granularity, which is invisible next to the traffic and prep uncertainty that dominate the estimate anyway. Common wrong answer to avoid: "Call the maps API for a fresh ETA on every tracking update." At 6,600 calls/second that is both unaffordable and too slow; the whole point is to precompute cell-to-cell times and look them up.
Q5. What happens when demand exceeds the couriers physically on the road? No matching algorithm conjures a driver from nothing, so the system degrades on promise and price, not on correctness. It lengthens promised ETAs honestly so customers set real expectations, raises courier incentives to pull more supply onto the road, and throttles new-order promises in the worst-hit cells — the graph the operator watches is the supply–demand ratio per cell, which shows where to intervene before customers see broken ETAs. The dispatch loop keeps running unchanged; what moves under pressure is the ETA and the incentive, not the pipeline. Orders already placed age in the pool and are prioritized, never silently dropped. Common wrong answer to avoid: "Queue the orders and process them when couriers free up." Silently queuing without moving the ETA means the customer is promised 30 minutes and gets 70 with no warning — the refund-and-churn outcome. Honest degradation beats a broken promise.
Q6. How do you prevent one courier from being assigned two conflicting orders?
Assignment is committed with a compare-and-set claim on the courier's live state in the geospatial index (AVAILABLE → EN_ROUTE) as the fast first guard, plus a versioned conditional update on each order row (WHERE state = CONFIRMED AND version = v) as the durable second guard. Two dispatch ticks racing for the same boundary courier both attempt the CAS; exactly one wins, the loser drops that assignment and the courier is matched elsewhere. This is the one place the design refuses eventual consistency — a double-assignment strands an order, so it must be a single atomic claim, not a "usually fine" reconcile-later.
Common wrong answer to avoid: "Read the courier's status, check it's free, then assign." Check-then-act is a race: two ticks both read 'free' and both assign. The store's atomic CAS/conditional-write has to be the arbiter, not application logic.
Q7. Why keep courier positions in an in-memory geospatial index instead of the orders database? Because the two data have opposite profiles. Courier position changes every ~4 seconds, is worthless a minute later, and is queried by radius ("who is within 3 km of this restaurant") — that is 3,500 overwrites/second answering geospatial lookups, which an in-memory index (Redis GEO, H3 cells) does in sub-milliseconds and which needs no durability, since the index self-heals from the next round of pings. The orders table is transactional, queried by key, and must never lose a row. Putting the ping firehose in the durable store would swamp it with worthless writes and couple tracking latency to database health. Dispatch and tracking read the same index, so the customer's dot and the dispatcher's view never disagree. Common wrong answer to avoid: "Store every GPS ping in the orders database with a timestamp." You would be doing 3,500 durable writes/second of data that is stale in seconds, and radius queries against a relational store are far slower than a purpose-built geospatial index.
Q8. When is an order actually "dispatchable"? Why not assign a courier the moment the restaurant accepts?
Dispatchability is a derived predicate, not a state: an order is dispatchable when it is CONFIRMED and its predicted ready_at is within a courier's travel horizon — close enough that a courier dispatched now arrives as the food comes up. If you assign the instant the restaurant accepts, the courier reaches a kitchen with 15 minutes of cooking left and idles at the counter, burning fleet capacity you cannot spare during a rush. If you assign too late, the food sits cooling on the pass waiting for a driver. Timing dispatch to the predicted ready moment is what keeps both couriers and food from waiting, which is why the prep-time estimate feeds directly into dispatch.
Common wrong answer to avoid: "Assign a courier as soon as the order is confirmed." That strands couriers idling at counters through the whole prep time — during the rush that is exactly the capacity you needed for another delivery.
Q9. The dispatch optimizer crashes at 7:15pm. What happens to orders in flight and orders arriving?
Orders already ASSIGNED or PICKED_UP are unaffected — they live in the order state machine and their couriers keep delivering. The danger is the incoming stream: without the optimizer, dispatchable orders pile up unassigned and every one gets colder by the second. The mitigation is an explicit fallback to greedy single dispatch — drop batching and the global solve, and assign each order to its nearest free courier immediately. It serves worse (no batching savings, so it leans on the full fleet and runs shorter) but it keeps orders moving, and batching resumes when the optimizer recovers. The failure is a graceful quality degrade, not a stall, and the signal that trips it is rising unassigned-order age.
Common wrong answer to avoid: "Orders queue until the optimizer restarts." A multi-minute stall at peak means thousands of cold meals and mass refunds; the fallback has to keep dispatching, just less cleverly.
Q10. A customer complains their courier's dot is frozen. How does the system behave when GPS pings are dropped? Under overload the location ingestion tier applies backpressure by downsampling — telling courier apps to ping every 8 seconds instead of 4, halving the rate rather than letting a queue back up and go minutes stale. On the client side, tracking shows the last known position and keeps the ETA drifting from it, so the customer sees a slightly stale-but-moving dot rather than a frozen-then-teleporting one; stale-but-smooth reads as "working," a jump reads as "broken." If the tracking websocket tier itself saturates, specific clients degrade to polling every ~10 seconds. A courier silent past a threshold is dropped from the dispatch candidate set so no new order is matched to a driver who may have lost signal. Common wrong answer to avoid: "Increase the ping frequency so the map is smoother." That adds load exactly when the system is already shedding it; under pressure you downsample, not upsample.
Q11. How do you size storage and connections for this?
Order data is modest: ~2 KB/row, a city doing 50k/hour peak lands ~300k orders/day, so ~600 MB/day per city — a few hundred GB/year on a sharded relational store, partitioned by city because every query is local to one metro. The high-volume stream is GPS pings (~3,500/s), but those are ephemeral in the geospatial index with a few-minutes trail and only downsampled into cold storage for ML training, never kept hot. The resource that actually grows is concurrent websocket connections — ~33,000 open at peak in one city — so the tracking tier is sized by connection count, not bytes; egress is only ~1.3 MB/s. Add cities and each is an independent shard across order store, geo index, and tracking tier.
Common wrong answer to avoid: "We need a big data store for all the location data." The durable data is small; location pings are transient and downsampled. Sizing the system around ping storage misreads where the load is.
Q12. How is idempotency handled so a retried order doesn't charge and cook twice?
The client sends an idempotency_key with POST /orders, and the store enforces UNIQUE (customer_id, idempotency_key). A retry after a network timeout carries the same key, so the second insert collapses to the first order rather than creating a duplicate — no second dinner, no second charge. This matters because order placement is coupled to payment; a duplicate here is real money and real food. The same discipline of "let the store enforce it atomically" reappears at dispatch, where the versioned conditional transition prevents an order from being assigned twice.
Common wrong answer to avoid: "Check if an identical recent order exists, then insert." Check-then-insert races under retries just like the courier double-assignment; the unique constraint has to be the guard, not an application-level lookup.
Deeper follow-ups¶
- How would you handle a large multi-restaurant order (a courier picking up from two kitchens) inside the batch feasibility check?
- How would you tune the food-hot budget per cuisine — a batched salad tolerates more delay than batched fries?
- How would you incorporate courier acceptance behavior (some decline long trips) into the optimizer's cost, so it stops offering trips that get declined and re-queued?
- How would you handle H3 cell-boundary couriers being candidates for two cells' ticks at once without double-counting supply?
- If you expanded to multiple cities and needed a global view for surge/incentives, what would you centralize versus keep per-city?
- How would you A/B test a new dispatch algorithm in production without giving one cohort systematically worse ETAs?
How this round is scored¶
Interviewers use food delivery to see whether you recognize that the difficulty is matching under a moving deadline, not throughput. The strong signal is separating the transactional order rail from the real-time geospatial/dispatch rail early, and framing 50,000 orders/hour as a supply-allocation problem rather than a write-scaling one. Seniority shows up in the tradeoff discussions — batch versus single, ETA accuracy versus compute, honest degradation versus silent queuing — where you name both sides and put numbers on the resolution (30% fleet savings, +8-minute food-hot budget, 99% routing-load cut). The failure-mode thinking separates people who have run these systems: an optimizer crash that falls back to greedy, a ping firehose that downsamples under backpressure, a courier claim that must be an atomic CAS because a double-assignment strands a meal. Doing the back-of-envelope math out loud — 14/s in, 33,000 live, 14,000 couriers, 3,500 pings/s, 6,600 ETA recomputes/s — and using it to justify the cell-matrix and the per-cell optimizer rather than as decoration is what pushes an answer from "correct" to "senior."