If you remember five things
The whole answer in one glance. Everything below is the defense of these five calls.
- Three stores, three workloads. A geo index for search, a sharded relational store for orders and money, and an in-memory store for location. The access patterns fight each other, so you never let them share a database.
- Location is a firehose, kept off the Order DB. 50k GPS pings per second, tiny and non-durable, live in their own in-memory pipe. This one call protects the thing you promised never to drop.
- Order is an event-driven state machine. Explicit transitions, every one idempotent, timeouts modeled as real events, so a flaky tablet or a retried phone never corrupts state or double charges.
- Dispatch is a per-city single-owner actor. It offers rather than assigns and scores on distance, batching, and fairness. This is the signature hard part, and it is why peak dinner does not melt into lock contention.
- Authorize early, capture on accept, void otherwise. Money correctness is non-negotiable, and this one rule is the difference between a clean refund and a front-page bug.
1Clarifying the requirements#
Pin down what you are on the hook for before drawing a box. "Food delivery" is huge, so narrow it fast. Here is how I would open:
Candidate: Are we building all three sides, the customer app, the restaurant side, and the courier app, or just one?
Interviewer: Assume all three. The system coordinates between them.
Candidate: Is delivery done by our own couriers, a gig fleet like Uber Eats, or does the restaurant deliver?
Interviewer: Our own gig fleet. Couriers are independent, they go online and offline, and we assign them orders.
Candidate: Do I own payments and the menu, or can I treat those as existing services?
Interviewer: Treat payment as an external service with a clean API. You own the menu, the order, and the dispatch.
Candidate: What is the scale? Rough city count and daily orders?
Interviewer: Global. Think tens of millions of orders a day across hundreds of cities. Design for peak dinner rush.
Candidate: Last one. Is same-restaurant batching in scope, where one courier carries two orders going the same way, or do I assume one courier per order?
Interviewer: Batching is in scope. The fleet is expensive, so we care about courier efficiency.
What those answers just told me:
- The scale answer plus the batching answer point at the same spot: dispatch at peak is the whole game.
- "Our own gig fleet" means supply is scarce and can log off, so I offer orders, never silently assign them.
- "Payment is external, you own the order" means the order state machine and money correctness are mine to defend.
Functional requirements
- Search restaurants nearby, filtered by cuisine, open or closed, and ETA.
- View a menu and place an order with payment.
- Restaurant receives the order, accepts it, and marks food ready.
- System finds a nearby courier and assigns the order.
- Customer tracks the order live on a map until it arrives.
Non-functional requirements
- Low latency on search and tracking. Search under 200 ms, location updates feel real time.
- High availability. A city outage during dinner is a revenue fire.
- Correctness on the order and on money. An order cannot be silently dropped or double charged.
- Elastic to peak. Dinner traffic is roughly 4x the daily average and it is spiky.
This opening is scored before you draw a single box. I am listening for whether you name the consistency split yourself: search can be stale, the order and the money cannot. Reach for one database and one consistency model and I have quietly downgraded you. Say "this is three systems, not one" early, then go spend your time on dispatch.
2Back-of-the-envelope estimation#
Get the scale in your head, because it decides the architecture. One number drives everything, and I will flag it when I reach it.
You are not here to compute storage to three decimals. You are here to surface one load-bearing number: location pings are a firehose that dwarfs order writes and carries no durable business data. That is what justifies a separate in-memory pipe for location and keeps it off the Order DB. Find it fast, then move on.
Orders.
- 30M orders per day. Spread flat that is 30M / 86,400, about 347 per second.
- Food is not uniform. The bulk lands in roughly 4 hours of lunch and dinner.
- Estimate peak at about 8x the flat average: call it ~2,800 order writes/second.
- That is a modest write rate. A sharded relational store does it in its sleep, so orders are not the scale pain.
Reads.
- For every order placed, a customer browses, searches, opens menus, and refreshes tracking a lot.
- Say 50 read actions per order: 347 x 50 = ~17k reads/second average.
- At peak, roughly ~140k reads/second.
- Search and menu are the read-heavy surfaces, so they want caching and a purpose-built index, not the orders DB.
Location pings (the number that bites).
- An active courier sends GPS every 4 seconds while on a trip. Say 200k couriers online at peak.
- 200k / 4 = 50k location writes/second, every one tiny and relentless.
- That is roughly 18x the order write rate, and none of it is durable business data.
- Let this firehose near the orders store and the orders store dies, and orders are the one thing I promised never to drop.
Storage.
- An order row with items and status history is maybe 3 KB. 30M/day x 3 KB = ~90 GB/day, ~33 TB/year. Fine for sharded SQL with cold archival.
- Location if we kept every ping: 50k/s x 100 bytes = 5 MB/s, ~430 GB/day. We do not keep all of it. Current position lives in memory, and we keep a downsampled trail for disputes.
3Core APIs#
Keep these small and split by actor. The one choice I defend hard: order creation is idempotent with a client key, because mobile networks retry and a retried checkout that double charges is the bug that makes the news.
GET /v1/restaurants?lat=..&lng=..&radius=3km&cuisine=thai&open=true
GET /v1/restaurants/{restaurantId}/menu
POST /v1/orders
Idempotency-Key: 3f9c-...
body: { restaurantId, items[], deliveryAddress, paymentToken }
GET /v1/orders/{orderId}
GET /v1/orders/{orderId}/track (returns courier location + ETA)
POST /v1/restaurant/orders/{orderId}/accept
POST /v1/restaurant/orders/{orderId}/ready
POST /v1/courier/status body: { online: true, lat, lng }
POST /v1/courier/location body: { lat, lng, ts } (high frequency)
POST /v1/courier/orders/{orderId}/accept
POST /v1/courier/orders/{orderId}/picked_up
POST /v1/courier/orders/{orderId}/delivered
Two choices worth defending:
POST /v1/ordersreturns202 Accepted, not200. Placing an order kicks off a workflow (charge, notify restaurant, later dispatch). I do not block the HTTP call on all of that. The client then watches over a push channel orGET /orders/{id}. The202is a deliberate signal: order placement is the start of a long workflow, not a single request-response.POST /v1/courier/locationis its own endpoint. That is the firehose: high frequency, fire-and-forget, sharing no code path, store, or rate limit with order writes. Keeping it physically separate at the API layer is the first line of defense for the orders store.
4High-level design#
The box diagram is table stakes. What earns points is the Order Event Bus seam in the middle: the Order Service does the minimum synchronously and everything slow or flaky reacts to events. Draw the boxes fast, then defend that seam and the three-store split.
The shape splits into read-optimized services (search, menu), a stateful order path, a location firehose, and a dispatch engine tying couriers to orders:
- Clients talk through an API gateway that handles auth and routing.
- Search and menu serve reads from a geo index and a cache.
- The Order Service writes durably, then emits an event.
- Dispatch, payment, and notifications all react to order events.
Write path.
- You POST an order. The Order Service validates, writes the row as
CREATED, and emits an event. - Charging the card, pinging the restaurant, and dispatching a courier all react to events.
- At ~2,800 writes/second the synchronous part is cheap; the slow, flaky parts move off the critical path.
Three stores, not one, because the access patterns fight each other.
- Order DB: sharded relational (Postgres or Spanner-style), source of truth for orders and money. Strong consistency.
- Geo Index + Menu Cache: Redis plus an Elasticsearch-style index for "what is near me and open." Fast, eventually consistent.
- Live Location Store: in-memory Redis holding each courier's current position and the current trip's recent trail. Ephemeral.
The one slide to remember.
- Three stores map to the three numbers from estimation: ~140k reads/second to the geo index and cache, ~2,800 writes/second to the Order DB, 50k pings/second to the in-memory location store.
- Three workloads, three stores, no bleed.
5Data model#
The order is the spine. Model status transitions explicitly and keep an append-only history so disputes and analytics have a paper trail.
orders
| Field | Type | Notes |
|---|---|---|
| order_id | uuid (PK) | shard key is city_id prefix for locality |
| customer_id | uuid | |
| restaurant_id | uuid | |
| courier_id | uuid null | null until dispatch assigns one |
| status | enum | CREATED, PAID, ACCEPTED, READY, PICKED_UP, DELIVERED, CANCELLED |
| total_cents | int | charged amount |
| delivery_lat/lng | float | drop-off |
| idempotency_key | string | unique per customer, dedupes retries |
| created_at | timestamp |
order_items
| Field | Type | Notes |
|---|---|---|
| order_id | uuid (FK) | |
| menu_item_id | uuid | |
| qty | int | |
| price_cents | int | price snapshot at order time, not a live join |
order_status_history
| Field | Type | Notes |
|---|---|---|
| order_id | uuid (FK) | |
| from_status | enum | |
| to_status | enum | |
| actor | enum | system, restaurant, courier, customer |
| at | timestamp | append-only, never updated |
restaurants
| Field | Type | Notes |
|---|---|---|
| restaurant_id | uuid (PK) | |
| geohash | string | indexed, used for proximity bucketing |
| is_open | bool | derived from hours plus manual pause toggle |
| prep_time_p50 | int | learned prep minutes, feeds ETA |
Two choices worth defending:
order_items.price_centssnapshots the price. Menus change and promos expire. The order must remember what the customer agreed to pay, not re-derive it from today's menu, because the customer will screenshot the price they were shown.- The shard key is a
city_idprefix. It keeps a city's orders co-located, which makes per-city queries cheap and, more importantly, makes a city the natural blast-radius boundary I lean on later for failure isolation.
6Deep dives#
6.1 Finding restaurants near you (the geo index)#
A naive range query falls over. "Restaurants within 3 km, open, Thai, sorted by ETA" at 140k reads/second is a geo query, and WHERE lat BETWEEN .. AND lng BETWEEN .. on a row store scans a band of the table per request. No index trick saves that at this volume. I use geohashing.
- A geohash encodes lat/lng into a short string where shared prefixes mean physical closeness.
- A 6-character geohash is a cell roughly 1.2 km across. I bucket every restaurant into its cell and keep an index from cell to restaurant list.
- To search, compute the customer's cell plus its 8 neighbors (so I do not miss a restaurant just across a border) and union the lists.
Then rank a small candidate set.
- Filter the candidates by open and cuisine.
- Rank by ETA:
travel_time_to_restaurant + prep_time_p50 + travel_time_to_customer. - Ranking runs over a few hundred candidates, so it is fast.
This half tolerates staleness, on purpose.
- If a restaurant went offline 20 seconds ago and still shows up once, nobody dies: the order fails acceptance and refunds.
- So I cache aggressively in Redis and rebuild the index from the source of truth asynchronously.
- This is the eventually-consistent half of the system. I am comfortable being a little stale here precisely because the strong-consistency half (order and money) lives somewhere else entirely.
Geohash versus S2/H3 (the follow-up they love).
- Geohash cells are rectangular, distort near the poles, and the 8-neighbor trick is clumsy at boundaries.
- S2 or H3 give more uniform cells and cleaner neighbor math.
- For a food app operating in cities, not at the poles, plain geohashing is good enough and dead simple to reason about. I would ship it and note the upgrade path rather than over-engineer on day one.
6.2 The order lifecycle as a state machine#
This is where correctness lives. An order is a long-running workflow spanning minutes and multiple parties, any of whom can fail or go silent. I model it as an explicit state machine driven by events, with every transition idempotent.
Idempotency on every transition.
POST /orderscan arrive twice because a phone retried. The idempotency key makes the second write a no-op that returns the same order id.- Same logic downstream: "move to PAID" checks current status first, so a duplicated payment event does not re-charge.
- I treat idempotency as a property of every transition, not a one-off at the front door, because the event bus can and will deliver a message more than once.
Timeouts as first-class transitions.
- If a restaurant does not accept within, say, 90 seconds, a timer fires a
RESTAURANT_TIMEOUTevent and we auto-cancel and refund. - I schedule these with a timer service (delayed messages on the bus), not a thread sleeping in a server. Servers die; durable timers do not.
- Modeling the timeout as an event means the auto-cancel goes through the same idempotent state machine as everything else.
The payment edge case (write this on the whiteboard).
- Authorize at order time, but capture only once the restaurant accepts.
- If acceptance never comes, void the authorization.
- This avoids charging someone for food that was never cooked, and it is the single most common support ticket if you get it wrong. "Authorize early, capture on accept, void otherwise" is a one-line rule I defend hard.
Reveal the answer
Why an event bus and not synchronous calls everywhere.
- The restaurant tablet is offline for 10 seconds, the payment provider has a 3-second p99, and the courier has not even been asked yet.
- Chaining those synchronously means the customer stares at a spinner and the whole call is as slow as the slowest hop.
- Events decouple it and give natural retry points. The cost I accept is eventual consistency inside the order workflow, which the tracking screen already reflects.
6.3 Dispatch: matching couriers to orders at peak#
This is the hard, interesting part and where I spend the most time, because it is the signature difficulty of the question. When an order hits ACCEPTED, Dispatch picks a courier. The goals fight each other: fast assignment, short courier travel, and a balanced fleet so nobody sits idle while another area is starved.
The input and the scoring.
- Input is "where is every available courier right now," from the live location store, plus the geo index.
- I query couriers within a radius of the restaurant, score them, and offer the order.
- Scoring is not just nearest. I weigh: distance to restaurant (sooner pickup), whether this batches with an order they already carry going the same way, how long they have been idle (fairness), and predicted free time given their current trip.
I offer, I do not hard-assign.
- Offer the top courier and give them a short window (10 to 15 seconds) to accept. If they decline or time out, offer the next.
- This is a small auction per order.
- A courier who gets silently handed an order they did not want either ignores it or logs off, and a courier who logs off is lost supply during the exact minutes I need them most.
The scary scenario: peak dinner.
- Thousands of orders in one city all want couriers in the same few minutes, and free couriers are scarce.
- Two couriers must never be offered the same order as a binding assignment, and one courier must never be committed to two orders at once.
- I serialize per city: a city's dispatch runs as a single logical actor (sharded by city) that holds the matching state, so decisions are made in one place without distributed locks fighting each other. Within a city I still parallelize across neighborhoods.
Why a single actor per city, not a lock per courier.
- At peak the contention is on supply, not on any one courier.
- A distributed lock per courier means every matcher in the city fights over the same scarce free couriers, and I spend dinner rush in lock contention.
- Routing all of a city's matching through one in-memory actor turns distributed locking into local scheduling: faster and far easier to reason about.
- The cost: the actor is a hot shard and a failure domain. I keep its state in a replicated log so I can fail it over and replay, and I keep it cheap by holding only live matching state, not order history.
When supply runs out.
- If no courier accepts after N tries, the order goes to a retry pool and widens its radius.
- If supply is truly gone, surge price or tell the customer there is a delay.
- Honesty beats a stuck order: a customer told "20 minutes longer, here is a credit" is annoyed; a customer watching a spinner that never resolves is gone for good.
6.4 The location firehose and live tracking#
This deep dive pays off the number I chased in estimation. 50k location writes per second cannot go near the Order DB.
Writes are a single overwrite.
- Pings land in the Location Service, which writes the courier's current position to an in-memory store keyed by courier id.
- That is one overwrite per ping, last-write-wins on one key, so there is no contention and nothing to coordinate.
Tracking is push, not poll.
- I do not let the client hammer
GET /trackevery second. I hold a WebSocket per active order. - When the assigned courier pings a new location, the Location Service pushes it to subscribers of that courier's current order, and ETA is recomputed server-side from the fresh position.
- If push drops, the client falls back to polling every 5 seconds.
- The Order DB only learns about location at milestones: picked up, delivered.
Two scaling axes, sized separately.
- 200k couriers at peak means 200k open WebSockets on the customer side plus the courier connections.
- That is a connection-count problem, not a throughput problem.
- Scale the push tier horizontally on connection count, keep it stateless-per-connection, and let the live position live in the shared in-memory store so any push node can serve any subscriber.
- The firehose (writes) and the fan-out (connections) are different axes, so I size them independently.
7Bottlenecks and trade-offs#
The hot restaurant.
- A celebrity posts about a tiny ramen shop and one
restaurant_idgets a thundering herd: everyone reads its menu, everyone orders. - The menu read is solved by caching; that row is just hot in Redis.
- The order write is harder: inventory and kitchen capacity is a single contended counter.
- Fix: move that restaurant's capacity check behind a short-lived in-memory counter with a queue, accept orders up to stated capacity, and show "busy, longer wait" past that instead of letting the DB row become a lock convoy. Same shape as any viral hot key: pull the contended counter out of the durable store into a fast single-owner counter.
City-level failure isolation.
- I sharded orders and dispatch by city on purpose. A bad deploy or a corruption bug should blast-radius to one city, not the planet.
- The trade-off: cross-city features (a courier near a border, global analytics) get more awkward, and I accept that.
- Containing a dinner-rush outage to Hyderabad instead of all of India is worth the awkwardness.
The consistency split.
- I happily serve stale search results, but I will not tolerate a stale order status or a double charge.
- The cost is two mental models in one system, and engineers sometimes reach for the wrong one.
- I defend it by physically separating the stores so the strong-consistency path cannot accidentally depend on the eventually-consistent one.
- This is the single idea the whole design hangs on, so I would rather pay the "two mental models" tax than pretend one store serves both.
Dispatch starvation and fairness.
- Pure nearest-courier matching quietly screws over couriers who sit one block too far every time.
- Adding idle-time to the score costs some efficiency (sometimes a slightly farther courier gets the job) but keeps the fleet willing to work, which is a real business constraint.
- A fleet that logs off because the algorithm never feeds them is a worse outage than a 90-second delay.
The event bus as a single point of pain.
- Everything flows through the order event bus, so if it backs up, orders stall mid-flight.
- I defend it with partitioning by order id (one city's spike does not block another), a dead-letter queue for poison events, and idempotent consumers so I can replay safely.
- I also keep the create-and-charge path able to degrade: if dispatch is down, I still take the order and queue it rather than reject at checkout. An order sitting in
ACCEPTEDwaiting for a courier is recoverable; a customer bounced at checkout is revenue I never get back.
ETA accuracy.
- The "32 min" we show is a promise, and a wrong one erodes trust fast.
- Prep time varies by dish and kitchen load, traffic is unpredictable, and handoff adds slack.
- I treat ETA as a learned model fed by
prep_time_p50per restaurant plus live travel estimates, and I show a range, not false precision. - Getting it exactly right is its own project, so I scope it as "a service we call, backed by a model" and move on rather than burning my remaining time.
Name the three-system split out loud, then design each half for what it actually needs.
8Summary#
The decisions I would defend if challenged:
- Three stores for three access patterns, mapped to three real numbers: ~140k reads/second to a geohash index and cache, ~2,800 order writes/second to a sharded relational store, and 50k pings/second to an in-memory location store. Do not let them bleed into each other.
- Order as an explicit, event-driven state machine with idempotent transitions and timeouts as real events, so a flaky restaurant tablet or a retried phone never corrupts state or double charges.
- Dispatch as a per-city sharded matcher that offers rather than assigns, scoring on distance, batching, and fairness, with radius-widening fallback when supply is thin. The single-owner-per-city actor is the trick that tames peak dinner.
- Authorize early, capture on acceptance, void otherwise. Money correctness is non-negotiable.
- Push for live tracking, cache for search, and keep 50k location pings per second completely off the orders database.
- Shard by city for blast-radius control, trading some cross-city elegance for the ability to fail one city instead of the world during dinner rush.
"A phone retries the checkout on a flaky network. Does the customer get charged twice?"
"It is peak dinner. Thousands of orders want the same scarce couriers. How do you avoid double-assigning one courier?"
"50k GPS pings a second. Where does that go, and why not the Order DB?"
"A celebrity makes one tiny restaurant go viral. What breaks?"
"The order event bus backs up during a spike. Do orders get lost?"
What you can now explain in the room
- Why food delivery is three systems (search, durable workflow, real-time market) and why one store cannot serve all three.
- How to derive the three load numbers: ~140k reads/second, ~2,800 order writes/second, and the 50k pings/second firehose.
- Why location lives in an in-memory pipe and never touches the Order DB.
- How the order state machine stays correct under retries and silent parties, with idempotent transitions and event-driven timeouts.
- Why dispatch is a per-city single-owner actor that offers rather than assigns, and how that tames peak dinner.
- Why you authorize early and capture on acceptance, and how sharding by city contains a dinner-rush outage.
For role-specific angles on this problem, see our company guides on marketplace and logistics system design interviews.
Get a prep plan built around your Design a Food Delivery System interview
Tell us the role and your interview date. We will send back a dated, day by day plan. Free.