Design a Local Delivery Service
DoorDash Food Delivery System Design
Overview
A local delivery service moves prepared food (or groceries, or retail goods) from a merchant to a customer using a fleet of independent couriers — the model behind DoorDash, Uber Eats, and Grubhub. A customer opens the app, orders from a nearby restaurant, and the system must get a courier to the restaurant, wait while the kitchen cooks, then carry the food to the customer — all while everyone watches a live map and an ETA.
The trap in this problem is that it looks like ride-sharing, so people design it like ride-sharing. It is not. Two things make it fundamentally different.
First, it is a three-sided marketplace, not two-sided. A ride matches a rider to a driver. A delivery must line up three parties whose clocks don't agree: the customer (who ordered), the restaurant (which cooks on its own schedule), and the courier (who drives). The restaurant sitting in the middle — with a variable, non-zero prep time — changes everything about when and how you assign a courier.
Second, because food cooks on a clock, the courier can carry more than one order at a time. Two orders from restaurants a block apart, both going to the same neighborhood, can be batched onto one courier — cutting delivery cost roughly in half. Ride-sharing has no equivalent (a rider wants a car now).
It is labeled "Medium" because each piece is individually tractable, but the interesting judgment is in the timing: assigning a courier the instant an order is placed is the classic wrong answer. If you dispatch immediately, the courier arrives, and then stands in the restaurant for fifteen minutes waiting for food that isn't ready — burning courier time you pay for and blocking them from other deliveries. The whole design pivots on assigning a courier to arrive as the food is coming up, not the moment the order lands.
In this walkthrough you'll scope it, size it, model the data, design the API, draw the order/dispatch/tracking paths, then go deep on three things: dispatch timed against prep time, real-time tracking + ETA, and the order lifecycle across three parties.
Functional requirements
- Place an order. A customer orders items from a specific restaurant to a delivery address.
- Restaurant confirmation. The restaurant accepts the order and (implicitly or explicitly) starts a prep clock; it can also reject.
- Assign a courier. The system picks a courier and times the assignment so the courier reaches the restaurant around when the food is ready — and may batch nearby orders onto one courier.
- Pickup and delivery. The courier collects the order and delivers it to the customer.
- Live tracking + ETA. The customer sees the order's stage, the courier's position on a map once assigned, and an ETA that reflects prep time plus travel.
Out of scope (say so): menu/catalog management (we assume restaurants and menus exist), payments and the courier-payout ledger (a charge step exists; we don't design the money), and ratings/reviews. Naming these keeps the interview on the core: three-sided matching, prep-time-aware dispatch, tracking, and the order state machine.
Non-functional requirements
- Dispatch timed to prep, not to order time. The system must be able to assign a courier to arrive as the food is ready. The scheduling decision — when to dispatch — is a first-class requirement, not an optimization.
- Low-latency live tracking. Once a courier is assigned, the customer's map and ETA should update within a few seconds of each courier location ping.
- Write-heavy location ingest. All online couriers ping their position every few seconds; the location path must absorb this firehose cheaply (see deep dive 2).
- Consistent, auditable order state. An order moves through many stages across three parties. Its lifecycle must be consistent — no order both
deliveredandcancelled, no double-charge — and transitions must be idempotent so a retried event or duplicate tap doesn't corrupt it. - High availability. A customer who can't place an order, or a courier who can't get assigned, is lost revenue on both sides of the market.
Tip
Split the requirements by consistency need up front. Location pings are approximate, lossy, and best-effort. The order state and the courier assignment must be exact. Saying this early frames the whole design — the loose path and the strict path get different machinery.
Estimations
State assumptions; the goal is to justify the prep-aware dispatch, the write-optimized location path, and the geospatial index — not to be exact.
Rounded assumptions
- Orders at peak (dinner rush): ~5K orders/sec platform-wide.
- Active (online) couriers at peak: ~200K.
- Each online courier (idle or active) pings location every ~4 seconds.
- Average prep time: ~12 minutes, but highly variable (8–25 min) — the number the whole dispatch turns on.
Derived load
- Location pings/sec ≈ 200K ÷ 4 = ~50K pings/sec — the dominant write load, same shape as ride-sharing's firehose. All online couriers ping regardless of status: idle couriers so dispatch can find them near a restaurant, active couriers for live customer tracking.
- Dispatch decisions are on the order of orders/sec (~5K/sec) plus re-evaluations, far below the ping load.
- Each dispatch runs a geospatial candidate search (a read) near the restaurant — thousands/sec.
How the numbers affect the design
| Signal from the numbers | Design decision |
|---|---|
| ~50K location writes/sec, ever-changing | Keep current courier location in memory, last-write-wins; don't durably persist every ping. |
| Dispatch (~5K/sec) ≪ pings (~50K/sec) | The dispatch/assignment path can afford a stronger consistency check; the ping path cannot. |
| Prep time ~12 min and variable | Dispatch is a scheduling problem — hold or delay assignment so the courier arrives at food-ready, not order time. |
| Nearby orders + nonzero prep | Batch compatible orders onto one courier — a courier utilization lever ride-sharing lacks. |
| "Couriers near the restaurant" fast | Use a geospatial index, not a table scan (shared with the nearby/ride-sharing pattern). |
Storage
- A courier's current location is tiny: courier id, lat/lng, timestamp, small metadata ≈ ~40 bytes. For 200K couriers that's ~8 MB of hot state — it fits in memory. The point is the update rate, not the size.
- Orders are durable and grow forever: order id, customer, restaurant, courier, status, line items, timestamps, address ≈ a few hundred bytes to low KB each. Millions of orders/day is large but append-mostly — a good fit for a partitioned durable store. This is where money and accountability live.
Caution
Do not durably store every location ping. At ~50K writes/sec of data you immediately overwrite, you'd be paying for disk durability you don't need. Only the courier's current position matters for tracking and dispatch; the delivery's route, if you want it, is sampled separately.
Core entities / data model
| Entity | Key fields |
|---|---|
Customer |
customer_id (PK), name, delivery addresses, payment ref |
Restaurant |
restaurant_id (PK), location (lat,lng), is_open, avg_prep_minutes, current load |
Courier |
courier_id (PK), availability (OFFLINE/AVAILABLE/ASSIGNED/ON_DELIVERY), last_location, geo_cell, last_seen, current batch |
Order |
order_id (PK), customer_id, restaurant_id, courier_id?, status, line items, address, placed_at, confirmed_at, ready_at?, picked_up_at?, delivered_at? |
Assignment |
order_id, courier_id, offered_at, state (OFFERED/ACCEPTED/DECLINED), batch_id? — a timeout is treated as a decline; there is no separate EXPIRED state |
| Location store | courier_id → (lat, lng, updated_at) — the hot, in-memory, last-write-wins map |
Two stores with very different jobs, exactly as in ride-sharing:
- The location store is the hot write path: an in-memory key-value map (plus a geospatial index over it) keyed by
courier_id, overwritten ~50K times/sec, last-write-wins, disposable. Lose it on restart and it refills within seconds as couriers re-ping. It holds coordinates and freshness only — never the authoritative assignment state. - The durable stores hold the authoritative
Order,Courier.availability, andAssignmentrows in a strongly consistent, partitioned relational store (Postgres/MySQL, partitioned by region or by order/courier id). This is the record of truth that must not lose writes.
The Order.status field is the strict state a state machine governs (deep dive 3). The Restaurant.avg_prep_minutes and live load feed the prep-time estimate that dispatch schedules against (deep dive 1). Courier.availability is controlled only by the assignment path and lifecycle transitions — a location ping must never rewrite it, or a stale ping could make an assigned courier look free again. Courier availability mirrors the order state machine: AVAILABLE → ASSIGNED when the courier accepts an offer, ASSIGNED → ON_DELIVERY at pickup (mirroring the order's picked_up transition), and ON_DELIVERY → AVAILABLE at delivery — the same pattern as ride-sharing's ON_TRIP.
API design
Four groups: the customer placing and tracking an order, the restaurant confirming/marking-ready, the courier receiving offers and streaming location, and the realtime channel that pushes courier position back to the customer. Body: is the request payload, Returns: the response.
Place an order
POST /api/orders
Body: { "customer_id": "...", "restaurant_id": "...", "items": [...], "address": {"lat": ..., "lng": ...} }
201 Created
Returns: { "order_id": "...", "status": "placed" }
Creates the order in placed and routes it to the restaurant for confirmation. Dispatch happens later and asynchronously — this call does not block on finding a courier.
Restaurant confirms and marks ready
POST /api/orders/{id}/confirm
Body: { "estimated_prep_minutes": 14 }
200 OK
Returns: { "order_id": "...", "status": "confirmed", "ready_estimate": "..." }
POST /api/orders/{id}/ready
200 OK
Returns: { "order_id": "...", "status": "ready_for_pickup" }
Confirm moves placed → confirmed and starts the prep clock (the estimate feeds dispatch timing). ready signals the food is up; if the courier isn't there yet, that's a mistimed dispatch (deep dive 1). A restaurant can also reject at confirm time, moving the order to cancelled.
Courier offer response (assignment)
POST /api/assignments/{id}/respond
Body: { "courier_id": "...", "accept": true }
200 OK
Returns: { "ok": true }
The system offers an order (or a batch) to a courier; the courier accepts or declines. Decline or timeout releases the courier and re-offers to the next candidate (deep dive 3).
Courier location update (high frequency)
POST /api/couriers/{id}/location
Body: { "lat": ..., "lng": ... }
200 OK
Returns: { "ok": true }
Sent every ~4 seconds while online — the firehose. Idle couriers ping so dispatch can locate them near restaurants; active couriers ping for live customer tracking. Tiny, frequent, best-effort. The server overwrites current location (last-write-wins) and updates the geospatial index. It does not let the client set availability. A dropped ping is harmless; the next arrives in seconds. In practice this rides a persistent connection (WebSocket/gRPC stream) to avoid per-ping overhead.
Track an order + realtime push
GET /api/orders/{order_id}
200 OK
Returns: { "order_id": "...", "status": "en_route_to_customer",
"courier": {"lat": ..., "lng": ...}, "eta_seconds": 480 }
Channel: WebSocket /ws/orders/{order_id}
Server -> client messages:
{ "type": "status", "status": "courier_assigned" }
{ "type": "location", "lat": ..., "lng": ..., "eta_seconds": 420 }
{ "type": "delivered" }
The GET is a pull fallback for reconnects; the WebSocket pushes stage changes, the courier's moving dot, and the live ETA. This is what makes the tracking map feel live.
High-level architecture
Three flows with different shapes: an order path (place → confirm → the durable lifecycle), a dispatch path (choose and time a courier, possibly batched), and a tracking path (location firehose → live map + ETA). We'll walk each, then combine them.
1. Order path
The customer's order lands, the restaurant confirms, and the durable lifecycle begins.
flowchart LR
Customer[Customer app] -->|"place order"| OrderSvc[Order Service]
OrderSvc -->|"persist status placed"| OrderDB[(Order Store - durable)]
OrderSvc -->|"route for confirmation"| Restaurant[Restaurant app]
Restaurant -->|"confirm plus prep estimate"| OrderSvc
OrderSvc -->|"confirmed - enqueue for dispatch"| Dispatch[Dispatch Service]
- The Order Service creates the order in
placedand writes it to the durable Order Store — the record that must never be lost. - It routes the order to the restaurant, which confirms (or rejects) and supplies a prep estimate; this moves the order to
confirmedand starts the prep clock. - On
confirmed, the order is handed to the Dispatch Service — but not necessarily dispatched yet (deep dive 1 decides when).
2. Dispatch path
Dispatch finds nearby couriers, times the assignment against prep, considers batching, and atomically assigns.
flowchart LR
Dispatch[Dispatch Service] -->|"couriers near restaurant"| Geo[(Geospatial Index - in-memory)]
Geo -->|"candidate couriers"| Dispatch
Dispatch -->|"score by ETA vs food-ready plus batch fit"| Dispatch
Dispatch -->|"atomically assign one - CAS"| CourierDB[(Courier store - durable)]
Dispatch -->|"offer order or batch"| Courier[Courier app]
Courier -->|"accept or decline"| Dispatch
Dispatch -->|"on accept - update order courier_assigned"| OrderSvc[Order Service]
- When an order is near food-ready, Dispatch queries the geospatial index for couriers near the restaurant (not near the customer).
- It scores candidates by how well their arrival lines up with the food-ready time, and checks whether the order can join an existing courier's batch.
- It atomically flips the chosen courier
AVAILABLE → ASSIGNEDin the durable Courier store (a conditional update, so two orders can't grab one courier), then offers the order/batch. - On accept, the Order Service advances the order to
courier_assigned; on decline or timeout (treated identically — no separate EXPIRED state), the courier is released and the next candidate is offered (deep dive 3). For batched orders, all orders in the batch share the samecourier_assignedorder state — theAssignmentrecord carries abatch_idto identify them as a group without needing a new state.
3. Tracking path
Once a courier is on the job, their pings drive the customer's live map and ETA.
flowchart LR
CourierApp[Courier app] -->|"ping every ~4s"| Ingest[Location Ingestion]
Ingest -->|"overwrite - last-write-wins"| Loc[(Location Store - in-memory)]
Ingest -->|"update bucket"| Geo[(Geospatial Index)]
Ingest -->|"assigned courier pings"| ETA[ETA Service]
ETA -->|"prep remaining plus travel"| Push[Realtime Push Service]
OrderSvc[Order Service] -->|"status changes"| Push
Push -->|"WebSocket - loc plus ETA plus status"| CustomerApp[Customer app]
- Courier apps stream position; the Location Ingestion Service overwrites current location in memory (last-write-wins) and keeps the geospatial index current.
- For couriers on active deliveries, pings also feed the ETA Service, which combines remaining prep time and travel legs into a fresh ETA (deep dive 2).
- The Realtime Push Service streams the courier's moving position, ETA, and order-status changes to the customer over a WebSocket.
4. Combined architecture
flowchart LR
Customer[Customer app] -->|"place order"| OrderSvc[Order Service]
OrderSvc -->|"lifecycle"| OrderDB[(Order Store - durable)]
OrderSvc <-->|"confirm plus ready"| Restaurant[Restaurant app]
Restaurant -->|"marks ready"| OrderSvc
OrderSvc -->|"confirmed"| Dispatch[Dispatch Service]
Dispatch -->|"near restaurant"| Geo[(Geospatial Index - in-memory)]
Dispatch -->|"atomic assign - CAS"| CourierDB[(Courier store - durable)]
Dispatch <-->|"offer plus accept"| Courier[Courier app]
Courier -->|"location pings"| Ingest[Location Ingestion]
Ingest -->|"last-write-wins"| Loc[(Location Store - in-memory)]
Ingest --> Geo
Ingest -->|"assigned pings"| ETA[ETA Service]
Restaurant -->|"prep estimate plus ready signal"| ETA
OrderSvc -->|"status changes"| Push[Realtime Push Service]
ETA --> Push
Push -->|"WebSocket"| Customer
The shape to notice: the in-memory left side (location store + geospatial index) absorbs the firehose and answers "couriers near the restaurant" cheaply, while the durable right side (Order and Courier stores) holds the rare, important transitions. The Dispatch Service is the bridge — it reads the cheap side and writes the strict side — and unlike ride-sharing it schedules when to act, against the prep clock.
Deep dives
1. Courier assignment / dispatch — timed against prep time, with batching
This is the heart of the problem and where the design departs hardest from ride-sharing. Finding candidate couriers is the same nearby-search you'd use for drivers: bucket couriers into a geospatial index (a geohash grid, or hierarchical cells like S2/H3), query the restaurant's cell plus neighbors, and get couriers within radius R. That mechanism is identical to the ride-sharing proximity search — reuse it, don't re-derive it here. Two things are genuinely different for delivery.
Difference 1 — you dispatch near the restaurant, timed to food-ready. In ride-sharing you match near the rider and want the driver now. In delivery you search near the restaurant, and you don't want the courier now — you want them arriving as the food is ready. Getting the timing wrong is the canonical mistake:
Greedy nearest-courier at order time (avoid)
The instant the order is confirmed, assign the closest available courier and send them to the restaurant.
The consequence: the courier arrives in 4 minutes; the food needs 14. The courier now stands in the restaurant for 10 minutes doing nothing — time you pay for, and time they can't spend on another delivery. Worse, you committed your best-positioned courier early, so when a second order comes up nearby you have no one close. Courier idle time balloons and effective fleet capacity collapses during the exact dinner rush when you need it most.
- Pro: dead simple; minimal delivery latency if prep were zero.
- Con: prep is not zero. You burn courier-minutes waiting, reduce fleet throughput, and can't batch.
- Verdict: avoid. This is treating a three-sided, prep-in-the-middle problem like two-sided ride-sharing.
Prep-aware scheduled dispatch (recommended)
Estimate a food-ready time = confirmed_at + estimated_prep_minutes (refined by the restaurant's live load and history). Estimate each candidate courier's travel time to the restaurant. Assign so the courier's arrival lands slightly before food-ready (a small buffer covers estimate error), not the instant the order is placed. Concretely, hold the order in a dispatch queue and only offer it once food_ready_time - courier_travel_time is near now — a courier 8 minutes away is offered a 14-minute-prep order after roughly 6 minutes of prep have elapsed.
- Pro: courier arrives as the food comes up — minimal idle time, maximal fleet throughput, and holding the order open long enough leaves room to batch.
- Con: depends on a decent prep estimate; a badly wrong estimate makes the courier early (idle) or late (food sits, gets cold). You mitigate with per-restaurant, load-aware prep models and a re-evaluation loop.
- Verdict: recommended. When to assign is the real decision in delivery dispatch — schedule it against prep, don't fire on order.
Difference 2 — batching (order stacking). Because food cooks on a clock, one courier can serve several orders in a single trip. If two restaurants are a block apart and both orders go to the same neighborhood, batching them onto one courier roughly halves delivery cost and doubles that courier's earnings-per-hour. Dispatch therefore isn't "one order → one courier"; it's an assignment problem where an order can join an existing route.
flowchart TD
O1[Order A ready ~soon at Rest 1] --> B{Batchable together?}
O2[Order B ready ~soon at Rest 2 nearby] --> B
B -->|"pickups close, dropoffs close, timing overlaps"| Batch[Assign both to one courier - single route]
B -->|"too far apart or timing conflicts"| Solo[Dispatch separately]
Batch --> Route[Route - pickup R1 then R2 then drop A then drop B]
Batching is worth it only when pickups are close, dropoffs are close, and the prep-ready times overlap enough that one courier can collect both without one order going cold. The candidate search feeds this: query couriers near both restaurants, and prefer a courier already assigned a compatible nearby order. Cap batch size (typically 2–3) so delivery latency for the first customer doesn't blow out.
Warning
Two classic dispatch failures: (1) assigning the nearest courier immediately, ignoring prep, so couriers idle in restaurants; and (2) ignoring batching entirely, so every order takes a whole courier and cost-per-delivery stays high. A strong answer schedules against prep time and stacks compatible orders.
Candidate selection still needs the boundary/density care from the nearby pattern (search neighbor cells; cap and rank dense downtown results by ETA; widen the radius if no courier is found). And the assignment itself — flipping a courier to ASSIGNED — must be atomic so two orders can't claim one courier; that consistency mechanism is deep dive 3.
2. Real-time tracking + ETA
Once a courier is assigned, the customer watches a live map and an ETA. Two sub-problems: absorbing the location firehose, and computing an ETA that reflects the prep-plus-travel reality.
The location firehose. ~50K pings/sec, each tiny, each making the last obsolete. The design follows one observation: you only ever need a courier's current position, never their history (a delivery's route, if wanted, is sampled separately). So:
- Keep current location in memory, last-write-wins. Hold each courier's position in an in-memory key-value store (e.g. Redis) keyed by
courier_id; a new ping overwrites the old value — no append, no log. Because the data is disposable (a restart refills from the next round of pings), you skip per-write disk durability, which is exactly what lets the path sustain 50K writes/sec. - Don't durably persist every ping. Writing each ping to a database means ~50K durable writes/sec of data you immediately discard — the classic mistake. If you need the delivery's path for receipts or support, sample the assigned courier's position every few seconds into the order record — a few writes per delivery, not per ping.
- Shard by region. Partition the location store and index by city/metro so one busy market stays on its own shards. A restaurant near a shard boundary queries the owning shard and its neighbors.
- Drop stale couriers. A candidate whose last ping is older than N seconds is treated as offline and excluded; a TTL on the in-memory key gives this for free.
The ETA is a sum of legs. Unlike ride-sharing's single "driver → rider" ETA, the delivery ETA is a sum of stages that includes the kitchen — remaining_prep + courier_travel_to_restaurant + handoff + travel_to_customer — and which terms apply depends on the order's current stage:
- Before pickup:
ETA = remaining_prep + courier_travel_to_restaurant + handoff + travel_to_customer. Remaining prep dominates early and shrinks as the kitchen works; you re-estimate it from the restaurant's confirm time and live signals. - After pickup: prep drops out entirely —
ETA = remaining_travel_to_customer, driven purely by the courier's live pings and the road-network route. - Batched deliveries: for a customer whose order is second in a batch, the ETA must include the courier's earlier stop (pickup and drop of order A) — the customer's ETA is a function of the whole route, not just their own leg.
The ETA Service consumes the assigned courier's pings, recomputes the current leg's remaining time, and hands updates to the Push Service, which streams them to the customer over the WebSocket. Recompute on each ping (or every few seconds) rather than once at assignment — traffic, a slow kitchen, or a batch reorder all move the number.
Caution
Don't compute the ETA once at assignment and freeze it, and don't forget the kitchen. Early on, remaining prep time is the biggest term; ignoring it makes the pre-pickup ETA wildly optimistic. Re-estimate every few seconds from live pings and prep signals.
3. Order lifecycle / state machine across three parties
An order isn't a mutable row you patch as things happen — it's a state machine whose transitions are driven by events from three independent parties (customer, restaurant, courier). Modeling it explicitly is what keeps it consistent and auditable.
A single mutable order row, patched ad hoc (avoid)
Keep one orders row and let each service UPDATE whatever field is relevant — restaurant sets confirmed=true, dispatch sets courier_id, courier sets delivered=true — with no defined transitions.
The consequence: illegal states become reachable. A delivered order gets a late cancelled write and ends up both. A duplicate "picked up" event fires the "courier picked up" notification twice. Two services race on the same row and one silently overwrites the other. There's no audit trail of how the order got to its current state, so support and disputes are guesswork.
- Pro: trivial to start.
- Con: no invariants, no idempotency, no audit — corrupt states in production.
- Verdict: avoid. Three parties writing one row with no rules is a data-integrity time bomb.
Explicit state machine with validated, idempotent transitions (recommended)
Define the allowed states and the legal transitions between them; every event is an attempt to move along a defined edge, rejected if illegal.
stateDiagram-v2
[*] --> placed
placed --> confirmed: restaurant accepts
placed --> cancelled: restaurant rejects
confirmed --> ready_for_pickup: restaurant marks food ready
confirmed --> courier_assigned: dispatch assigns plus courier accepts
ready_for_pickup --> courier_assigned: dispatch assigns plus courier accepts
courier_assigned --> picked_up: courier collects food
picked_up --> en_route_to_customer: courier departs restaurant
en_route_to_customer --> delivered: courier drops off
confirmed --> cancelled: no courier or customer cancels
ready_for_pickup --> cancelled: customer cancels
courier_assigned --> confirmed: courier cancels - re-dispatch
delivered --> [*]
cancelled --> [*]
Note the two-lane path from confirmed: the ready_for_pickup state is reached when the restaurant explicitly signals food is up (the POST /orders/{id}/ready call, which sets ready_at). The food-ready estimate (confirmed_at + estimated_prep_minutes) drives when dispatch schedules the courier offer — it is a timing input, not a state. The actual ready event is what the courier waits on at the restaurant; a correctly timed dispatch means the courier arrives as this event fires. If dispatch fires first and the courier is already assigned, the order can move confirmed → courier_assigned → picked_up and the ready_for_pickup state is skipped (food was ready before the courier arrived). Either path is valid; the machine accepts both. Note also that a courier can arrive at the restaurant and wait while the kitchen finishes — the state machine deliberately omits a courier_at_restaurant (arrived/waiting) sub-state as a simplification; a production system would add it to distinguish "traveling to the restaurant" from "waiting at the restaurant," which enables more accurate ETA computation and a cleaner trigger for the picked_up transition.
Each transition is a guarded, idempotent write in the durable Order store: an event carries the order id, the target state, and a stable event id, and the update applies only if the order is in the expected source state. Replaying the same event (a retry, a duplicate tap) is a no-op — the order is already past that edge — which is how three parties' at-least-once events stay safe.
- Pro: illegal states are unreachable; duplicate/retried events are harmless; the transition log is a natural audit trail; each party's events are validated against the current state.
- Con: more upfront modeling; transitions must be defined and enforced in one place (the Order Service) rather than scattered.
- Verdict: recommended. A state machine is the correct backbone for a multi-party, long-lived order.
Consistency and where it must be strong. The Order.status transition and the Courier.availability flip during assignment are the strict operations — do them as atomic conditional updates in the durable store. The courier_assigned transition, in particular, should tie together "order → assigned" and "courier → ASSIGNED" so you can't end with an assigned order and a free courier (or vice versa); a conditional update guarded on both current states, or a small transaction, handles it. Location and ETA, by contrast, are eventually consistent and best-effort — a stale dot on the map costs nothing.
Handling the three failure modes the multi-party flow must absorb:
- Restaurant rejects (at confirm, or closes):
placed → cancelled, refund the customer, never dispatch. - Courier cancels after accepting: release the courier (
ASSIGNED → AVAILABLE), move the ordercourier_assigned → confirmed, and re-run dispatch for the remaining prep window. - No courier available: keep re-trying dispatch with a widening radius; if still none by food-ready, surface a delay to the customer and, past a threshold, offer cancel/refund. This is why the order can sit in
confirmedwhile dispatch retries, rather than failing instantly. - Customer cancels after
ready_for_pickup: the food is already made, so cancellation at this stage is not free — the customer is charged a cancellation fee (or a reduced order amount to compensate the restaurant), and the order moves tocancelled. This matches theready_for_pickup → cancelledstate-machine edge.
Note
The state machine and the atomic assignment reinforce each other: the assignment's compare-and-swap on Courier.availability (deep dive 1 / ride-sharing deep dive 3) is what makes the courier_assigned transition safe under concurrent dispatch, and the state machine is what makes a courier-cancel cleanly reversible.
Tradeoffs & bottlenecks
- Assign-at-order vs assign-when-food-nearly-ready. Assigning early minimizes the risk of no courier at pickup but wastes courier time idling in restaurants and kills batching. Scheduling against prep maximizes fleet throughput but leans on a good prep estimate — a wrong estimate makes couriers early (idle) or late (cold food). Prep-aware scheduling with a small safety buffer is the right default.
- Single-order vs batched dispatch. Solo dispatch gives each customer the fastest possible delivery; batching cuts cost-per-delivery and raises courier earnings but adds latency for the first customer in the batch. Cap batch size and only stack when pickups, dropoffs, and timing genuinely overlap.
- Strong vs eventual consistency for order state. The order status and courier assignment need strong, atomic transitions (money and double-booking are at stake); location and ETA can be eventual and lossy. Pinpointing where strong consistency is required — rather than making everything strong — is the core judgment.
- In-memory location vs durability. Last-write-wins in memory is what makes 50K pings/sec affordable, at the cost of losing in-flight positions on restart. That's fine because the durable Order store — the part that matters — is off this path.
- Prep estimate quality. The whole dispatch pivots on
estimated_prep_minutes. A static per-restaurant average is a fine start; a load-aware or ML estimate is the upgrade the interviewer may push toward. - Hot market shards. Sharding by region localizes load, but a single hot city (or a stadium ordering at halftime) makes one shard hot; sub-shard that region and cap/rank dense-cell candidate results.
Extensions if asked
Add only the extension that changes the design discussion:
- Surge / courier incentives. During undersupply, compute a per-region pay boost from the live ratio of open orders to available couriers — a real-time analytics pipeline over the same location data, kept off the dispatch hot path.
- ETA and prep ML model. Replace static prep averages and straight-line travel with learned models: predict prep time from restaurant, items, and current kitchen load, and use traffic-aware road-network travel times. Both feed dispatch timing and the customer ETA.
- Multi-restaurant orders. One customer order spanning two restaurants becomes a routed pickup problem (collect from both, then deliver) — essentially forced batching within a single order, with its own timing constraints.
- Courier earnings and payouts. After
delivered, compute pay (base + distance + tips + incentives) and settle it — a separate, idempotent ledger subsystem, deliberately out of scope here. - Grocery / retail delivery. Longer pick lists, in-store shopping time replacing kitchen prep, and item substitutions — the same dispatch/tracking spine with a different, longer "prep" stage and a shopper-approval step.
What interviewers look for & common mistakes
What interviewers usually reward:
- Recognizing the three-sided, prep-in-the-middle structure — and not designing it as two-sided ride-sharing. This is the single biggest signal.
- Prep-aware dispatch timing — assigning a courier to arrive as the food is ready, not at order time, with a clear account of why greedy-nearest-now is wrong.
- Batching — stacking compatible nearby orders onto one courier, with sensible constraints (close pickups/dropoffs, overlapping timing, capped size).
- A real order state machine — explicit states, guarded idempotent transitions across three parties, and clean handling of restaurant reject / courier cancel / no-courier.
- A write-optimized in-memory location path (last-write-wins, sharded, not durably persisted per ping) and a layered ETA that includes remaining prep before pickup.
- Atomic assignment (CAS on courier availability) plus an accept timeout and re-offer loop so two orders can't claim one courier and declines don't leak couriers.
- Reusing, not re-deriving, the geospatial nearby-search — naming geohash / S2 / H3 and cross-linking the pattern rather than rebuilding it.
Before you finish, do a quick mistake check:
- Did you treat this as three-sided (customer, restaurant, courier), not two-sided?
- Did you time dispatch against prep time instead of assigning the nearest courier immediately?
- Did you include batching, with constraints and a size cap?
- Did you model the order as an explicit state machine with idempotent, guarded transitions?
- Did you handle restaurant rejection, courier cancellation, and no-courier-available?
- Did you keep current location in memory (last-write-wins), sharded, and avoid persisting every ping?
- Did you build the ETA as remaining prep + pickup travel + delivery travel, and re-estimate it live?
- Did you make the assignment atomic and add an accept-timeout re-offer loop?
Practice this live
Run this exact question with our AI voice interviewer and get feedback.
Start the interview →