Design a food delivery app: the system design interview walkthrough
## Design a food delivery app: the system design interview walkthrough
The request to **design a food delivery app** sounds like one line — you order food, a driver brings it — and that one line hides the whole interview. Picture it for a single restaurant and you reach for a table of orders; picture it for a whole city, matching every new order to a nearby available courier in seconds and streaming that courier's moving dot to the customer's phone, and it becomes a three-sided real-time problem with a dozen sharp edges. This is one of the most revealing **food delivery system design** prompts — the DoorDash-style question — because it forces every distributed-systems muscle at once: an order state machine, geospatial matching, event-driven services, and a high-write location firehose.
The round turns on one structural insight. "Order food, a driver brings it" hides a system with **three** live participants — the customer, the restaurant, and the courier — and the hard parts are not the menu or the payment. The hard parts are **matching** an order to the right nearby courier and **streaming** that courier's live position back to the customer. Seen clearly, this is a real-time matching plus tracking problem sitting on top of an order state machine: dispatch is a geospatial nearest-available search, and **real-time tracking** is a high-write stream you must deliberately keep off your main database. Get that reframe and the rest of the **system design interview** is deciding how the order moves through its states, how you find the nearest free courier among tens of thousands, and where you put the location firehose so it never threatens order correctness. This walkthrough follows the path a strong candidate draws on the whiteboard — requirements, estimate, API and data model, high-level design, then the deep dives — and at each step it names the follow-up the interviewer uses to tell a memorised answer from an engineer who understands the tradeoff.
## How the food delivery round is actually run
A food delivery round runs about 45 minutes on a shared canvas — you draw the boxes and arrows while you talk, and the interviewer interrupts. It is a favourite because the one-line problem opens onto deep water fast: the moment you draw "customer orders, courier delivers," the question becomes _how do you find the right courier_ and _where does the stream of GPS pings go_ , and every answer after that compounds from those two. The clock is a touch tighter than the hour-long rounds, so you have to spend your minutes on matching and tracking, not on the menu schema.
> The interviewer rarely lets "a courier delivers it" stand. "An order comes in at the dinner rush — how do you find the right courier among tens of thousands moving around the city, and what happens when the first one you offer it to says no?" That question — nearest-available matching under load — is the one a copied answer can never rehearse, and the one this round is really testing.
The rounds here are **modelled on how the loop runs** , not scraped from any candidate's recall. What matters for prep is the shape: a blank canvas, a tighter clock, and a follow-up after every decision you make.
### The five-step framework for any HLD round
Every high-level design answer fits the same spine. Say it out loud at the start so the interviewer knows you have a plan, then walk it:
1. **Clarify requirements** — functional and non-functional, and what you are explicitly _not_ building.
2. **Estimate** — orders per second, active couriers and ping rate, and live-tracking connections, to justify every later choice.
3. **High-level design** — the boxes and arrows: customer, restaurant and courier apps, the order, dispatch and location services, and the event stream between them.
4. **Deep dive** — the two or three decisions that actually decide this system.
5. **Follow-ups** — bottlenecks, failure modes, and what you would change at ten times the scale.
### What the debrief measures
The score is not "right or wrong design." A good HLD debrief reads on four dimensions, and every move below maps to one: **problem solving** (did you reach an order state machine with geospatial dispatch and a location stream kept off the main database), **communication** (did you say why the location firehose must not share the order database in a single sentence), **technical execution** (order states and events, a geohash/quadtree index over courier locations, pub-sub plus websockets for the live dot), and **time management** (did you spend your 45 minutes on orders, matching and tracking, not on the restaurant menu model). Reading a walkthrough teaches the design; it does not teach you to deliver it on the clock while someone pushes back.
## Step 1 — Requirements: what a food delivery platform must do
Spend the first few minutes here. Jumping to boxes before you have scoped the problem is the fastest way to design the wrong system.
Type | Requirement
---|---
**Functional** | A customer browses restaurants and places an order; the restaurant accepts and prepares it; the platform matches the order to a nearby available courier; the courier picks up and delivers; the customer tracks the courier live on a map with an ETA; all three sides get status updates.
**Functional (optional)** | Batching multiple orders onto one courier, scheduled/ahead-of-time orders, promotions and surge pricing — call these out, then scope most of them for later.
**Non-functional** | Low-latency matching (assign a courier in seconds); real-time location updates to the customer; high availability at dinner-rush peaks; order state must be consistent and never lost; location updates can tolerate some loss; scale across many cities.
The sentence that earns points: _"The hard part isn't the menu or checkout — it's a three-sided real-time system, so the core is an order state machine, a geospatial search to match a nearby courier, and a live-location stream I keep off the order database."_ Say that and you have framed the entire design before drawing a box.
## Step 2 — Capacity estimation (back of the envelope)
Estimation here is about proving one thing: there are **two very different write patterns** — low-rate order state and a high-rate location firehose — and the design must keep them apart.
Quantity | Assumption | Result
---|---|---
Order throughput | high but modest — thousands of orders/sec at peak | ordinary rows in a normal transactional database; this is not the scaling problem
Active couriers + pings | hundreds of thousands of couriers, a GPS ping every ~2–4 seconds | ~millions of location writes/sec — a streaming firehose, never the order DB
Geospatial queries | one nearest-courier lookup per new order (plus re-tries) | frequent index lookups over moving courier locations — the matching hot path
Live-tracking connections | one long-lived connection per customer watching an active order | hundreds of thousands of concurrent websockets, fan-out of courier positions
Two conclusions fall out, and you should say both out loud. First, **order state is the easy axis** — the write rate is modest and fits a normal database, so order data stays consistent and durable without heroics. Second, **location is the hard axis** — millions of pings per second is a firehose that would crush a transactional database, so it belongs in an in-memory/streaming layer, and the matching lookups belong in a geospatial index, not in `SELECT ... WHERE distance < x`. You have now pointed the interview at the two real deep dives — dispatch and location — before anyone asked.
## Step 3 — API and data model
The API is deliberately small: a customer places and tracks an order, couriers stream their location, and the platform assigns a courier.
Operation | Shape
---|---
`place_order(customer, restaurant, items)` | creates an order in state `placed`; returns the `order_id`.
`get_order_status(order)` | returns the current state of the order (placed, preparing, picked-up, …).
`update_location(courier, lat, lng)` | a courier's app streams its GPS position every few seconds; goes to the location service, not the order DB.
`assign_courier(order)` | the dispatch service finds and offers the order to a nearby available courier.
`track(order)` | returns the live courier location and current ETA over a long-lived connection.
The data model is thin. The order record is the source of truth for state; location pings live elsewhere and are mostly disposable:
Entity | Notes
---|---
`order` | `order_id`, `state`, `customer_id`, `restaurant_id`, `courier_id` (once assigned), `items`, timestamps — the durable source of truth for state.
`courier` | `courier_id`, `status` (available / on-delivery / offline), latest `location` — the dispatch and location services read this.
`restaurant` | `restaurant_id`, menu, location, prep-time estimate.
`location_ping` | `(courier_id, lat, lng, ts)` — high-volume, low-value; keep only the latest per courier (and maybe a sampled trail), never every ping forever.
## Step 4 — High-level design: orders, dispatch and location
Now the boxes. The three **apps** — customer, restaurant and courier — talk through an **API gateway** to a small set of services, tied together by an **event stream** :
* **Order service:** owns the **order state machine** and the order database — the durable record of every order and its current state.
* **Restaurant service:** owns menus and restaurant-side actions (accept the order, mark it preparing, mark it ready).
* **Dispatch / matching service:** holds a **geospatial index** of courier locations and runs the nearest-available search to assign a courier to an order.
* **Location service:** ingests the courier GPS **ping** firehose into an in-memory / streaming layer and **pub-sub** , keeping it entirely off the order database.
* **Notification service:** turns order-state events into push updates to the right side (restaurant ticket, courier offer, customer "your order is on its way").
* **Event stream:** every order-state transition is an event, so each service reacts without the order service calling them all directly — the restaurant gets the ticket, dispatch is triggered, the customer is notified.
* **Customer live map:** reads the courier's moving position over a **websocket** fed by the location service, and shows the dot and ETA.
Say explicitly that there are **two very different write patterns** here, and that the design keeps them in separate systems: low-rate, must-be-consistent **order state** in the order database, and high-rate, can-be-lossy **location pings** in the streaming layer. Naming that split unprompted — so the firehose never threatens order correctness — is a signal the interviewer is listening for.
## Deep dive: the order lifecycle and the services
This is the first decision that actually decides the round. What _is_ an order, and how do three independent sides stay in step without one giant service calling everyone?
Model the order as a **state machine**. An order moves through a defined set of states, and each transition is triggered by a specific actor; the order record is the **source of truth** for which state it is in. The services are split by who owns which actions, and they coordinate through **events** rather than direct calls, so each side reacts to what it cares about:
Order state | Transition / trigger
---|---
`placed` | customer submits the order; order service creates the record and emits an event.
`restaurant-accepted` | restaurant service: the restaurant accepts the ticket.
`preparing` | restaurant marks the food as being prepared.
`courier-assigned` | dispatch service matches and a courier accepts the offer.
`picked-up` | courier confirms collection at the restaurant.
`delivered` | courier confirms hand-off to the customer — terminal.
`cancelled` | customer, restaurant or system cancels — terminal; compensating actions run.
Each service owns a slice: the **order service** holds the state machine and the durable order record; the **restaurant service** owns accept/prepare/ready; the **courier service** owns a courier's status and the pickup/delivery confirmations; the **notification service** owns turning events into pushes. Because transitions are published to an **event stream** , the restaurant gets its ticket, dispatch is kicked off, and the customer is notified — all without the order service orchestrating every step synchronously. Keeping the order record as the single source of truth for state is what makes the whole thing debuggable: there is always one answer to "what state is this order in?"
> "The restaurant marks an order ready, but the courier's app still shows 'preparing' — how do the two stay in sync?" Both read from the same order state, updated by events: the restaurant's "ready" is a transition the order service records and republishes, and the courier app subscribes to that order's events (or polls its status), so it converges to the new state. The order record — not either app's local copy — is the source of truth, so a stale screen is a refresh away from correct, never a second conflicting truth.
System Design
HLD · Messaging & realtime · 45 min
**Design a food delivery app out loud, with follow-ups**
Practise on Zynter →
## Deep dive: courier matching and dispatch
This is the decision the whole round is really about. The write path for orders is easy; the follow-up is always "an order comes in — how do you find the right courier among tens of thousands moving around the city?"
You cannot scan every courier and compute a distance per order — at city scale that is far too slow. Instead you keep courier locations in a **geospatial index** (a **geohash** , **quadtree** , or S2 cell scheme over the lat/lng of every available courier) so "who is near this restaurant?" is a cheap lookup rather than a full scan. Then you **score** the small candidate set and **offer** the order, handling rejection by moving to the next candidate. It is the same shape as ride-sharing dispatch:
Step | What it optimises
---|---
**Index lookup** | query the geospatial index for available couriers within a radius of the restaurant — turns a city-wide scan into a cheap neighbourhood lookup.
**Candidate set** | take the nearby available couriers as a short list — small enough to score precisely.
**Score** | rank candidates by ETA to the restaurant, rating, current load, and whether the order can be **batched** with one they already carry.
**Offer** | send the order to the top candidate and wait for accept within a timeout.
**Accept / retry** | on accept, move the order to `courier-assigned`; on reject or timeout, offer the next candidate — so one slow courier never stalls the order.
The geospatial index is what makes this real-time: because couriers are constantly moving, their positions update in the index (fed from the same location stream in the next deep dive), and each new order runs a fresh neighbourhood lookup rather than a brute-force distance sort over the whole fleet. Scoring, not just raw distance, is what separates a toy answer from a real one — the nearest courier is not always the best if they are about to finish another drop two streets from your restaurant.
> "An order comes in — how do you find the right courier among tens of thousands moving around the city?" Not by scanning everyone: courier locations live in a geospatial index (geohash or quadtree), so I look up only the couriers near the restaurant, score that short list by ETA, rating, load and batching, and offer to the best. If they reject or time out, I offer the next — the index keeps the search local, and scoring keeps the choice sensible under load.
System Design
HLD · Messaging & realtime · 45 min
**Defend courier matching and dispatch out loud**
Practise on Zynter →
## Deep dive: real-time location tracking at scale
Matching decides who delivers; this decides how the customer watches the dot move — and it is where candidates most often put the firehose in the wrong place. The follow-up is always about volume.
Every active courier's app emits a **GPS ping** every few seconds. At hundreds of thousands of couriers that is **millions of writes per second** — and the first rule is that **you do not write every ping to the main transactional database**. That firehose would overwhelm the order DB and threaten the one thing that must stay consistent: order state. Instead, the pings flow through a dedicated path:
* **Ping → location service:** the courier app sends `update_location(courier, lat, lng)` into a **location service** that holds the latest position per courier in an **in-memory** store (fast key-value, overwrite-in-place) — not a durable row per ping.
* **Location service → pub-sub:** the service publishes position updates to a **pub-sub** layer, so anything interested (the customer watching this order, the dispatch index) subscribes without the location service knowing who is listening.
* **Pub-sub → customer websocket:** the customer's live map holds a **websocket** / long-lived connection and receives the courier's moving position pushed to it, so the dot moves smoothly without the app polling.
* **ETA:** the location service (or a small compute on top of it) turns the courier's position, the route and traffic into a live **ETA** that rides along with the position updates.
* **Store only what's needed:** keep the **latest location per courier** and maybe a **sampled trail** for the active order; do not persist every ping forever — most of the firehose is disposable the moment the next ping arrives.
The point to state plainly: this write-heavy stream is **isolated from order data**. Pings are high-rate and can tolerate loss (miss one and the next arrives in two seconds); orders are low-rate and must never be lost. Putting the firehose in an in-memory streaming layer with pub-sub fan-out — rather than the order database — is exactly what keeps a surge of location updates from ever threatening order correctness.
> "Hundreds of thousands of couriers ping their location every 2 seconds — where does that firehose go, and how does the customer see the dot move?" Not into the order database — it goes to a location service that keeps the latest position per courier in memory and publishes updates to a pub-sub layer; the customer's map holds a websocket and gets the position pushed to it, with a live ETA. I keep only the latest location (and maybe a sampled trail), because a dropped ping is harmless but a dropped order is not — so the stream stays off the transactional store entirely.
## The follow-ups that decide the food delivery round
Scan the probes from each deep dive and they rhyme. Master these five and you are ready for a version of the question that is not drawn exactly like this one.
* **"Why an order state machine with events, not one big service?"** → The order record is the single source of truth for state, and publishing each transition as an event lets the restaurant, courier and notification sides react independently — so three sides stay in sync without synchronous orchestration. (communication)
* **"How do you find the right courier among tens of thousands?"** → A geospatial index (geohash/quadtree) makes "who is near this restaurant?" a cheap neighbourhood lookup, then score the short list by ETA, rating, load and batching and offer, retrying the next on rejection. (problem solving)
* **"Where does the location firehose go, and why not the order DB?"** → Into a location service holding the latest position in memory plus a pub-sub layer pushed to the customer over a websocket; pings are high-rate and lossy, orders are low-rate and must be consistent, so they stay in separate systems. (technical execution)
* **"Restaurant says ready but the courier app shows preparing — how do they sync?"** → Both derive from the order's state, updated by events; the order record is the source of truth, so a stale screen converges on the next event or refresh, never a second conflicting truth. (technical execution)
* **"A courier rejects or times out on an offer — what happens to the order?"** → The dispatch loop offers to the next candidate from the scored set, so one slow courier never stalls an order; the order simply stays in `placed`/pending-assignment until someone accepts. (problem solving)
Each maps to a debrief dimension. Reading this list teaches you the answers; it does not teach you to deliver them while someone interrupts — that only comes from saying them out loud.
## How to prepare: a one-week plan
You do not need fifty systems. You need this one cold and the framework automatic, so the next "design X" is just new nouns in the same five steps.
* **Days 1–2:** Learn the five-step framework until it is reflex. Do the food delivery app end to end on paper — requirements, estimate, API, data model, the orders/dispatch/location boxes.
* **Days 3–4:** Drill the matching deep dive: the geospatial index, neighbourhood lookup, scoring by ETA/rating/load/batching, and the offer-and-retry loop. Say each tradeoff in one sentence.
* **Days 5–6:** Own the order state machine and location tracking — events between services, the ping firehose into an in-memory + pub-sub layer, websockets to the customer, and why the firehose stays off the order database. Answer each of the five follow-ups out loud.
* **Day 7:** A full timed 45-minute round, narrating the whole way, with someone (or something) interrupting. This is the rep that counts.
This post is the deep walkthrough behind one row of the broader Top 25 system design interview questions — once the food delivery app is automatic, the same spine carries the rest of the list.
## Frequently asked questions
### How do I design a food delivery app step by step?
Follow the five-step HLD framework. Clarify requirements (place an order, restaurant accepts and prepares, match a nearby courier, deliver, track live with an ETA); estimate to show the two write patterns — modest order throughput that fits a normal database, and a location-ping firehose of millions of writes per second that must not; define the small API (place_order, get_order_status, update_location, assign_courier, track) with a thin order/courier/restaurant/ping model; draw the design (customer, restaurant and courier apps through a gateway to order, dispatch, location and notification services tied by an event stream, with the customer map over a websocket); then deep-dive the decisions that matter — the order state machine and services, courier matching and dispatch, and real-time location tracking. Finish on the follow-ups about rejection and sync.
### Why model the order as a state machine?
Because an order is not one event — it moves through a defined sequence (placed, restaurant-accepted, preparing, courier-assigned, picked-up, delivered, plus cancelled), and each transition is triggered by a specific actor. Modelling it as a state machine with the order record as the single source of truth makes the whole system debuggable: there is always one answer to "what state is this order in?" Publishing each transition as an event lets the restaurant, courier and notification services react independently, so three sides stay in sync without one service orchestrating every step synchronously.
### How does courier matching find the nearest available driver?
Not by scanning the whole fleet and computing a distance each time — that is far too slow at city scale. Courier locations are kept in a geospatial index (a geohash, quadtree, or S2 scheme over lat/lng), so "who is available near this restaurant?" is a cheap neighbourhood lookup that returns a small candidate set. You then score that short list by ETA to the restaurant, rating, current load, and whether the order can be batched with one the courier already carries, offer it to the best candidate, and on rejection or timeout offer the next. The index keeps the search local; the scoring keeps the choice sensible under load.
### Where should real-time location tracking live — not the main database?
Correct — not the main transactional database. Hundreds of thousands of couriers pinging every few seconds is millions of writes per second, a firehose that would overwhelm the order database and threaten order consistency. Instead the pings go to a location service that holds the latest position per courier in memory and publishes updates to a pub-sub layer; the customer's live map holds a websocket and receives the moving position pushed to it, with a live ETA. You keep only the latest location (and maybe a sampled trail), because a dropped ping is harmless but a dropped order is not — so the high-write stream stays isolated from order data.
### How does the customer see the courier move in real time?
The customer's app opens a long-lived connection — a websocket — when they start tracking an active order. The courier's app streams GPS pings to the location service, which publishes position updates to a pub-sub layer; the customer's websocket subscribes to that courier's updates and receives each new position pushed to it, so the dot moves smoothly on the map without the app repeatedly polling. A live ETA, computed from the courier's position and route, rides along with the position updates. The whole path runs through the streaming layer, never the order database.
### How do I practise a system design interview out loud?
Reading a walkthrough is not the same as defending a design while someone interrupts. On Zynter, the HLD track runs live voice rounds modelled on how real system design loops run — you talk through a shared design on the canvas, and a voice interviewer pushes with the follow-ups above one at a time, then gives you a debrief on problem solving, communication, technical execution, and time management. The score is a practice signal, not a hiring decision.
## Practise it on Zynter
### You can read every state transition. The round is won on matching and live tracking.
Pick the Food Delivery round, draw the orders-dispatch-location design on the canvas, and defend it out loud to a voice interviewer that keeps asking "how do you find the right courier among tens of thousands?" and "where does the location firehose go?" — then read the debrief. Zynter.ai is on-demand mock interviews modelled on real company loops, with 150+ rounds across 20 companies including the HLD system design track (Sep 2026).
Practise the Food Delivery round →
Browse all interview tracks
dev.to