Blueprint
Geo & marketplaces advanced

Design DoorDash / Uber Eats

Three-sided marketplace with a 30-minute deadline — the interesting problems are matching drivers to orders, ETAs that don't lie, and a menu that changes twice a day.

asked at DoorDash · Uber · Swiggy Sometimes asked ~40 min walkthrough
01

Scope the requirements

Three actors — customer, restaurant, driver — with independent lifecycles. The design work is the seams between them, especially when things go wrong (driver cancels, restaurant runs out mid-preparation).

Functional — what it must do

  • Customer browses restaurants nearby, orders, tracks in real time.
  • Restaurant receives order, confirms/rejects, marks ready.
  • System dispatches a driver, updates ETA, pushes order state to all three.
  • Payment on placement; refund/tip changes at delivery.
  • Explicitly cut: subscriptions, group orders, alcohol ID — offer them back.

Non-functional — the numbers that shape the design

  • Match latency: driver assigned within 20 s of restaurant confirming.
  • 1M active orders in flight globally; peak 200k/s location writes from drivers.
  • ETA accuracy: p95 error under 5 minutes.
  • Restaurant menu update propagates in under 1 minute.
  • 99.99% for the order-placement path; realtime tracking may degrade.
Say this out loud

Three actors, three lifecycles, one order state machine. I'll draw the state machine first and hang services off it.

02

Back-of-envelope estimates

The scary number is location writes from drivers; the actual load on the matching brain is much smaller — because we only match when the restaurant says a batch is ready.

QuantityEstimateHow you got there
Concurrent drivers online~2Mpeak lunch/dinner windows; heartbeat every 4 s → 500k location writes/s.
Orders/day~30M~350/s average; peak dinner window ~2k/s in a metro.
Match calls/s~500/s peakone per restaurant-ready event, not per driver ping. The matching brain is small.
Menu items~500M rows500k restaurants × ~1k items with variants. Read-heavy, written by restaurant staff.
Location storage~1 TB/daydownsampled to per-minute breadcrumbs; hot minute in memory, day archived to S3.
Push notifications~10k/sstate changes × 3 actors per order. Batched through the notification system.
Say this out loud

Drivers ping cheap. What's actually expensive is the matching decision — 500 per second globally, but every one of them is a small geospatial optimisation.

03

Sketch the API

Three separate API surfaces per actor. Location writes are their own high-throughput channel that bypasses the order services entirely.

GET/api/v1/nearby/restaurants?lat=&lng=→ ranked list with ETA estimate. Read from geo index + a small ML rank.
POST/api/v1/ordersbody: { restaurantId, items, address }. Runs the payment auth + creates order in PENDING.
POST/api/v1/orders/{id}/readyRestaurant marks batch ready → triggers driver match.
POST/api/v1/drivers/locationbody: { lat, lng, ts }. Every 4 s per driver. Bulk-ingested into geo index.
WS/track/{orderId}server pushes driver position + ETA updates to the customer app.
  • Location writes are a separate service: high write throughput, no join needs. They land in a Redis geospatial index and a Cassandra breadcrumb log — never in the same database as orders.
  • The order state machine is the source of truth; every actor's view is derived from orders via subscriptions.
04

Data model

Four tables with clear owners: orders (transactional, per-metro sharded), restaurants + menus (mostly-read), drivers (small stable set + a hot location index), payments (external + local record).

orders
id PK · customer_id · restaurant_id · driver_id? · status · items[] · created_at
Postgres, sharded by metro. Every state change is a row update + event emission.
menus
(restaurant_id, item_id) PK · name · price · available
read-heavy; cached in Redis, source of truth in Postgres. Menu updates are batched.
driver_state
driver_id PK · status (offline/online/on_trip) · current_order_id?
small in-memory + Postgres. Not the same table as location.
driver_locations
driver_id → (lat, lng, ts)
Redis GEOADD index for near-me queries; recent-day breadcrumbs in Cassandra.
The database call

Orders in Postgres because the state machine wants ACID + secondary indexes. Menu items shard-by-restaurant in Cassandra would work but the total is small — one Postgres cluster per metro is easier. Driver locations are the only large-write dataset — Redis for the hot query, Cassandra for history.

05

High-level design

Six services, one event bus that everyone subscribes to. The order state machine is the spine; the matching service reads it and writes drivers back onto it.

actors edge service data async Customer Restaurant Driver API gateway Order svc state machine Menu svc Matcher dispatch brain Location svc Postgres orders, menus Redis geo Cassandra breadcrumbs Event bus Kafka Push notif ETA svc near-me assign geoadd state chg ready
the order service owns state. everyone else reads events off the bus and reacts.
  1. Customer orders → order service inserts PENDING, kicks off payment auth, emits order.created. Restaurant receives via its subscription.
  2. Restaurant confirms → order.accepted. Prep begins. Menu service is not involved after this point.
  3. Restaurant marks batch ready → order.ready. Matcher wakes up, queries Redis GEO for online drivers within a radius, ranks them by predicted time-to-restaurant + current queue depth, hedges to top 2-3.
  4. Chosen driver's app receives the offer via push + socket. On accept, order.assigned → order service updates driver_id, all three actors get the update.
  5. Driver's location updates flow into Redis every 4 s; the ETA service subscribes to breadcrumbs and pushes recalculated ETA to the customer's tracking socket.
06

Deep dives — where the interview is won

Phase 01

Matching: geospatial index + hedged offers

The matcher wakes up on order.ready. First step: geo query. Redis GEOSEARCH returns online drivers within R km, sorted by distance. That's a coarse filter — the real ranking considers current speed, whether the driver is finishing another order, historical acceptance rate, and predicted travel time from the road network. All of this comes from Redis + a small model call; the total budget is <200 ms.

The subtlety: don't send the offer to just the top driver. A single-offer strategy has ~10-20% no-show rate (driver ignores push, phone lost signal). Hedging — send to the top 2-3 drivers simultaneously with a first-accept-wins race — cuts assignment time in half at the cost of some driver frustration. Modern designs cap hedging via a rolling reputation system so drivers who accept a lot keep getting first pick.

matcher index drivers Matcher on order.ready Redis GEO Rank model Driver 1 Driver 2 Driver 3 near-by rank offer offer offer
one geo query, one rank pass, three parallel offers. first accept wins; the losers get a cancel push instantly.
Trade-offHedging costs driver goodwill and pushes assignment complexity into a race — you need first-write-wins semantics on driver_id (UPDATE ... WHERE driver_id IS NULL) or two drivers will show up to the same restaurant.
Phase 02

Dynamic ETAs from breadcrumbs and prep-time model

A first-order ETA is time_to_restaurant + prep_time + time_to_customer. Each piece has to be modelled: time_to_restaurant comes from the road-network graph plus current traffic, updated by driver breadcrumbs; prep_time is per-restaurant, per-item-count, per-time-of-day, learned offline; time_to_customer is another graph query. On assignment, we compute an initial ETA and every ~30 s recompute it as the driver moves.

The ETA that hurts most is the wrong-and-optimistic one — customer expected 25 min, got 45. Design bias: pad ETAs slightly in the initial commitment, then beat them. The whole thing feeds into the tracking WebSocket: each recomputation is a small payload pushed to the customer app so the map animation stays honest.

inputs ETA svc consumers Breadcrumbs Prep model Road graph ETA compute every 30 s Customer WS Order svc push commit
three inputs feed one estimator; two consumers get the fresh number.
Trade-offRecomputing every 30 s for 1M in-flight orders is 33k ETA calls/s — you cache aggressively per driver+destination and skip recomputes when the driver hasn't moved much.
Phase 03

The order state machine as the single source of truth

Every service reads and writes different columns of the orders table. If two of them fight — matcher assigns driver A while restaurant service marks the order cancelled — inconsistency wrecks the day. The pattern: the order service is the only writer. Every other service publishes a request (request.assign_driver) and the order service applies it under a per-order lock in a state-machine transition function that validates preconditions.

State transitions emit events on the bus after the write commits (transactional outbox pattern — insert the event row in the same transaction as the state change, then a shipper pushes to Kafka). This guarantees exactly-once state changes with at-least-once event delivery. Downstream services are idempotent on order_id + status.

requesters order svc outputs Matcher Restaurant Driver State machine per-order lock orders + outbox Event bus ready commit assign picked up
requests fan in, one writer commits, events fan out. no other service writes orders.
Trade-offThe order service becomes a bottleneck under regional load — mitigate by sharding orders by metro (independent state machines per city), which matches the real world since a customer in Boston never orders from Denver.
07

Follow-ups you should expect

What happens if a driver accepts then goes offline?
Order stays in ASSIGNED until an app heartbeat is missed for ~60 s. State machine transitions to REASSIGN_PENDING and the matcher wakes up with the same order.ready-ish event. Customer sees a small message; restaurant is unaware because they're still cooking.
How does the restaurant's menu update propagate in <1 min?
Menu writes go to Postgres and simultaneously to a Redis cache with 60-second TTL. Customer app cache is short (client-side 60 s). If a customer picks an item that's since become unavailable, order service catches it at placement and returns a structured error listing what changed.
Batching multiple orders for one driver — how does the design change?
The matcher becomes a small optimiser instead of a nearest-neighbour lookup — given N ready orders and M nearby drivers, minimise total time over the assignment. Google OR-tools or a heuristic (nearest insertion) runs per metro every ~30 s. State machine transitions include multi-order pickups but the storage model doesn't change.
How do you handle payment on a partial order (item ran out during prep)?
Payment is authorised on placement, captured on delivery. If items are removed mid-prep, restaurant marks lines as MISSING in the state machine, order service adjusts total, capture happens against the adjusted amount. The saga: authorise → later capture is the standard payment pattern for this reason.
Real-time tracking scale: 1M concurrent customers watching the map
Location updates are aggregated by ETA service to per-order deltas (position + ETA), pushed via WebSocket. Each customer only receives updates for their specific order — no fan-out. Server load is per-active-order, not per-viewer-of-driver, so ~1M sockets each getting ~20 msgs/s is manageable on ~1k gateway boxes.

Where candidates lose the room

  • Storing driver locations in the same database as orders — 500k writes/s on the transactional store is a nonstarter.
  • Naive matching that sends to the single closest driver — no-shows tank match rate; hedging or bidding is standard.
  • Making the ETA a static number set at placement — customers expect it to move; static ETAs make angry customers.
  • Letting multiple services write to the orders table — you'll have races between cancel and assign that lose orders.
  • Treating menus as static — restaurants change availability constantly, and stale menus cause the worst kind of failed order.

Concepts this leans on

Found a bug or want more?

Tell me what's broken, missing, or wrong. Every message lands in my inbox.