Design a Food Delivery App
Design a three-sided food delivery platform. Customers browse nearby restaurants and place orders; restaurants accept orders on a tablet and mark them ready; couriers on bikes or scooters receive delivery offers, pick up food and bring it to the door while the customer watches them move on a map.
Demand is sharply peaked around lunch and dinner, every courier phone reports its location every few seconds, and a bad match means cold food or a courier waiting 15 minutes at a busy kitchen. An order must never be charged twice or lost between services, and a courier must never get two conflicting assignments for the same order.
In 45 minutes, cover requirements and scale, the order lifecycle, how couriers are matched to orders, and how live tracking reaches the customer.
Sketch on the HLD board, write your answer in the Design doc, and size it with the Estimator. Start the mock interview from the timer for a 45-minute run.
Design Path · Location & Marketplaces · problem 9 of 18
Ask before you design. Try answering each yourself, then compare.
- Which cities and what scale?
Typical answer
A multi-city service: about 15 million daily customers, 3 million orders a day, 300,000 couriers online at the dinner peak.
- Do couriers choose orders or are they assigned?
Typical answer
The platform offers an order to one courier at a time; the courier has ~30 seconds to accept, otherwise it goes to the next.
- Can a courier carry more than one order?
Typical answer
Yes, batching two orders from nearby restaurants is a nice-to-have; design for single orders first.
- How live does tracking need to be?
Typical answer
The courier's dot on the customer's map should move every few seconds; 5–10 seconds of lag is fine.
- Do we process payments ourselves?
Typical answer
No. We authorise through a payment provider when the order is placed and capture on delivery.
- Should restaurant search be personalised?
Typical answer
Basic ranking by distance, rating and delivery time is enough; recommendations are out of scope.
Requirements
Functional
- Customers find restaurants that deliver to their address, browse menus and place an order with payment.
- Restaurants receive orders, accept or reject them, and mark them ready.
- The platform assigns each order to a courier, who can accept or decline the offer.
- Couriers stream their location; customers see live courier position and an ETA.
- All parties get notifications on status changes (accepted, picked up, delivered).
Non-functional
- Order placement p99 under 1 s (excluding payment provider) and 99.99% available at peak.
- Ingest ~75,000 location pings per second at dinner peak.
- Courier offer sent within 10 s of the dispatch decision point; tracking lag under 10 s.
- No lost or duplicated orders; each order has exactly one active courier assignment.
- Peak traffic about 4x the daily average, concentrated in 2-hour windows.
Out of scope
- Courier onboarding, background checks and payouts.
- Promotions, loyalty and recommendations.
- Route-level navigation for couriers (we use a maps/routing provider).
Back-of-the-envelope estimates
- Orders: 3 M/day ≈ 35 per second on average, and because dinner squeezes a third of the day's orders into two hours, about 140–150 per second at peak.
- Browsing: ~30 menu and restaurant reads per order ≈ 90 M/day, ~4,000 reads per second at peak; menus change rarely, so most of this is cacheable.
- Location pings: 300,000 online couriers × one ping every 4 s = 75,000 writes per second, each ~100 bytes. That is ~650 GB/day if every ping is kept.
- Tracking fan-out: ~400,000 orders in flight at peak, each with a customer watching; pushing a position every 5 s is ~80,000 messages per second over WebSockets.
- Order storage: 3 M × 5 KB (items, address, state history) × 365 × 3 years ≈ 16 TB, ~50 TB replicated.
The location stream is the number that shapes the design: it is 500× the order rate, is mostly ephemeral, and needs an in-memory geo index rather than the transactional order database.
API
GET /v1/restaurants?lat=..&lng=..&q=.. -> nearby, open, deliverable
GET /v1/restaurants/{id}/menu -> menu (CDN/edge cached)
POST /v1/orders Idempotency-Key: <uuid> {restaurantId, items[], addressId, paymentMethodId}
-> 201 {orderId, status: PLACED, eta}
POST /v1/restaurant/orders/{id}/accept {prepMinutes}
POST /v1/restaurant/orders/{id}/ready
WS /v1/courier/stream up: {lat, lng, ts, heading} down: offer{orderId, pickup, expiresIn}
POST /v1/courier/offers/{offerId}/accept
WS /v1/orders/{id}/track down: {courierLat, courierLng, eta, status}Couriers and tracking customers use long-lived WebSocket connections, because polling at a 4-second interval from 300,000 phones would mostly carry headers and empty answers.
Data model
Order store (PostgreSQL or a distributed SQL database, sharded by order_id, with city as a locality hint): orders(order_id, customer_id, restaurant_id, courier_id, status, version, total, payment_ref, placed_at, ...), order_items and an append-only order_events(order_id, seq, type, at). Status changes use optimistic concurrency on version, so two actors cannot both move an order forward.
Catalog: restaurants and menus in a relational store, copied into a search index with a geo-shape of each restaurant's delivery zone; menus are also cached at the edge.
Courier live state (Redis): courier:{id} -> {lat, lng, status, ts} plus a geo index of available couriers per city, keyed by H3/geohash cell. It is soft state that can be rebuilt from the next round of pings within seconds.
Location history: pings go to a Kafka topic partitioned by courier id and land in cheap columnar/object storage for ETA model training and disputes.
High-level design
Placing an order. The customer app calls the API gateway. The catalog service answers restaurant search from a geo-aware search index and menus from cache. When the customer pays, the order service checks the idempotency key, authorises the card with the payment provider, writes the order as PLACED and emits an OrderPlaced event onto the order event stream in the same transaction (an outbox table relayed to Kafka). The restaurant tablet receives the order and accepts it with a prep-time estimate.
Dispatch. The dispatch service consumes order events. It does not grab a courier immediately; it schedules matching so the courier arrives about when the food is ready. Every few seconds per city zone it takes the waiting orders, queries the courier geo index for free couriers nearby, scores pairs by expected lateness and travel, and sends an offer over the courier's WebSocket. Acceptance is a conditional update on the order row (only if no courier is assigned yet); a decline or 30-second timeout moves to the next candidate.
Tracking. Courier phones stream pings over WebSocket to the location service, which updates the geo index in Redis and appends to a location stream. The real-time gateway pushes the assigned courier's position and a refreshed ETA to the customer who is watching that order. Status changes (picked up, delivered) flow through the order service and its events, which also trigger notifications and, on delivery, payment capture.
Open the HLD board and press Reference to see the architecture diagram.
Deep dives
Order state machine and exactly-once effects
Model the order as PLACED → ACCEPTED → COURIER_ASSIGNED → PICKED_UP → DELIVERED, with CANCELLED and REJECTED as exits. Each transition is a conditional update: UPDATE orders SET status='PICKED_UP', version=version+1 WHERE id=$id AND status='COURIER_ASSIGNED' AND courier_id=$c AND version=$v. Losing writers get a clear conflict instead of silently overwriting. Side effects (notify, capture payment, start dispatch) are driven by events written to an outbox table in the same transaction and relayed to Kafka, so a crash cannot commit the state without its event. Consumers are idempotent on (order_id, seq) because the relay delivers at least once.
Matching couriers to orders
Greedy nearest-courier at order time wastes couriers: they wait at kitchens that need 20 more minutes. Instead, start matching at ready time minus travel time. Run a matcher per city zone every 2–5 seconds over the waiting orders and free couriers in a radius. Score each pair by expected lateness at pickup, courier travel distance, and fairness, then solve the batch as an assignment problem (Hungarian or a greedy on sorted pairs when batches are large). Batching a few seconds of orders noticeably cuts total wait versus one-at-a-time. Partition matchers by zone so each owns its couriers; near zone borders, allow a courier to be offered only by the zone that holds its lease, avoiding double offers.
Ingesting 75,000 location pings per second
Pings are high-volume, small and disposable: the latest one matters, older ones are history. Terminate WebSockets on a fleet of stateless connection servers (each holds ~50,000 connections), and have them write the latest position into Redis keyed by courier, plus a geo set per city for available couriers. Writes are overwrites, so a lost ping is harmless. Append every ping to a Kafka topic partitioned by courier for history and ETA training, sampled down before long-term storage. Never put pings in the order database; only meaningful events, such as 'arrived at restaurant' detected by geofence, become order events.
Live tracking fan-out
Only the customer of an order needs that courier's position, so fan-out is one-to-one, not broadcast. The tracking gateway subscribes a customer's socket to a channel order:{id}; the location service publishes the assigned courier's position to that channel (Redis pub/sub or a routing table of which gateway node holds which socket). Throttle to one update every 3–5 seconds and smooth on the client with interpolation along the route. If the socket drops, the app reconnects and fetches the latest snapshot; nothing is replayed because old positions are worthless.
Failure modes
- If Redis for courier locations fails over, the geo index is empty for a few seconds; dispatch pauses matching for that city until pings repopulate it.
- If the dispatch service for a zone crashes, a standby takes its zone lease; orders wait a few extra seconds but none are lost because they are still in the order store and event stream.
- If a courier's phone loses signal, its last position goes stale; after 60 s without pings the courier is marked unavailable and pending offers move on.
- If the payment provider is slow, order placement times out at the client; the idempotency key lets the retry return the same order instead of charging twice.
- If a WebSocket server dies, its couriers and customers reconnect to another node through the load balancer and resume from current state.
Core algorithm in code
import math
from collections import defaultdict
class CourierGrid:
"""Buckets couriers into square cells so 'who is near this restaurant' scans a few cells.
In production this is a geohash / H3 index in Redis, updated by every location ping."""
def __init__(self, cell_km=1.0):
self.cell = cell_km
self.cells = defaultdict(set)
self.pos = {}
def _key(self, x, y):
return (int(x // self.cell), int(y // self.cell))
def update(self, cid, x, y):
if cid in self.pos:
self.cells[self._key(*self.pos[cid])].discard(cid)
self.pos[cid] = (x, y)
self.cells[self._key(x, y)].add(cid)
def remove(self, cid):
if cid in self.pos:
self.cells[self._key(*self.pos.pop(cid))].discard(cid)
def nearby(self, x, y, radius):
cx, cy = self._key(x, y)
r = int(math.ceil(radius / self.cell))
out = []
for i in range(cx - r, cx + r + 1):
for j in range(cy - r, cy + r + 1):
for cid in self.cells.get((i, j), ()):
px, py = self.pos[cid]
d = math.hypot(px - x, py - y)
if d <= radius:
out.append((d, cid))
return sorted(out)
def assign_batch(orders, grid, speed_kmh=18.0, radius=4.0, horizon_min=10):
"""Every few seconds, match all waiting orders at once instead of first-come nearest.
Orders whose food is far from ready are deferred to a later round. Cost of a pair =
lateness at pickup (worst), courier idle wait at the kitchen, and a little travel.
Greedy on globally sorted pairs is a cheap stand-in for a full assignment solver."""
pairs, deferred = [], []
for oid, (x, y, ready_in_min) in orders.items():
if ready_in_min > horizon_min:
deferred.append(oid)
continue
for dist, cid in grid.nearby(x, y, radius):
eta = dist / speed_kmh * 60
lateness = max(0.0, eta - ready_in_min)
idle = max(0.0, ready_in_min - eta)
pairs.append((2.0 * lateness + 0.3 * idle + 0.1 * eta, oid, cid, round(eta, 1)))
pairs.sort()
taken_o, taken_c, plan = set(), set(), {}
for cost, oid, cid, eta in pairs:
if oid in taken_o or cid in taken_c:
continue
taken_o.add(oid)
taken_c.add(cid)
plan[oid] = (cid, eta)
for cid in taken_c:
grid.remove(cid) # no longer free
return plan, deferred
grid = CourierGrid(cell_km=1.0)
for cid, x, y in [("c1", 1.0, 1.0), ("c2", 2.5, 0.5), ("c3", 0.2, 3.1), ("c4", 5.0, 5.0), ("c5", 1.4, 1.2)]:
grid.update(cid, x, y)
orders = { # order -> (restaurant x, y, minutes until food is ready)
"o1": (1.2, 1.1, 18), # slow kitchen: too early to tie up a courier
"o2": (1.5, 1.5, 2), # almost ready: needs someone close now
"o3": (0.5, 2.8, 8),
}
print("couriers near o2:", grid.nearby(1.5, 1.5, 2.0))
plan, deferred = assign_batch(orders, grid)
for oid, (cid, eta) in sorted(plan.items()):
print("%s -> %s (reaches restaurant in %.1f min, food ready in %d min)" % (oid, cid, eta, orders[oid][2]))
print("deferred to a later round (food not ready soon):", deferred)
print("still free:", sorted(grid.pos))
Trade-offs
- Batch matching every few seconds gives better global assignments but costs a few seconds of added latency before an offer goes out.
- Keeping courier positions in Redis gives fast geo queries and cheap overwrites but costs durability, which is acceptable because the next ping repairs it.
- WebSockets give low-latency push to couriers and customers but cost sticky, stateful connection servers that need draining on deploys.
- An outbox plus event stream decouples dispatch and notifications from order writes but costs eventual consistency and idempotent consumers everywhere.
- Offering an order to one courier at a time avoids double assignment but costs time when several couriers decline in a row.
What a strong answer shows
- Defines a clear order state machine with guarded transitions and idempotent side effects.
- Separates high-rate ephemeral location data from transactional order data, with numbers to justify it.
- Designs dispatch that considers food-ready time and batches decisions rather than naive nearest-courier.
- Explains how live tracking reaches only the relevant customer over push connections.
- Prevents double assignment and double charging with conditional updates and idempotency keys.
- Addresses peak-hour load and per-city partitioning.
Mistakes to avoid
- Writing every GPS ping to the main order database.
- Dispatching the nearest courier the moment an order is placed, so couriers idle at kitchens.
- Broadcasting all courier positions to all clients instead of per-order channels.
- Letting two dispatchers offer the same courier or order without a lease or conditional update.
- Ignoring the restaurant side: orders that are never accepted or never marked ready need timeouts.
Follow-ups to expect
- How would you batch two orders onto one courier? (Consider pairs with nearby pickups and compatible drop-offs; accept if added delay for the first order stays under a threshold.)
- How do you compute the ETA shown to the customer? (Prep-time estimate plus routing time, corrected by a model trained on location history.)
- How would you handle surge when couriers are scarce? (Widen radius, raise courier incentives, show longer ETAs or pause low-priority restaurants.)
- How do you detect that a courier arrived at the restaurant? (Geofence on the location stream emitting an event.)
- What changes for multi-region deployment? (Cities are independent; home each city in one region.)