Course contents

Design food delivery (DoorDash / Swiggy)

Design a food delivery system like Swiggy, Zomato or DoorDash, built one requirement at a time: geo search with a per-cell cache, idempotent checkout with authorise-then-capture, a guarded order state machine with a transactional outbox, dispatch timed by a prep-time distribution, batch matching of partners, and live tracking over WebSockets.

FreeMust know for interviewsHardDesign problem32 min readMid → StaffReviewed Oct 2026
On this page
  1. 1. Understand the problem
  2. 2. The set up
  3. 3. High-level design
  4. 4. Deep dives
  5. 5. The final design
  6. 6. What interviewers look for by level
  7. 7. Follow-up questions
  8. 8. How to say it in the interview

🍔 What is a food delivery app like Swiggy, Zomato or DoorDash?

It lets a customer order from a nearby restaurant, gets the kitchen cooking, and sends a delivery partner to carry the food to the door while everyone watches the order move. It's a three-sided marketplace: the customer, the restaurant and the partner each have their own app, and the order is the one thing all three share. Picture a restaurant's order ticket that three people are reading and writing at the same time, from three different places.

For this problem, we'll design browsing nearby restaurants, placing and paying for an order, the restaurant accepting it, assigning a delivery partner, and tracking it to the door. We'll leave grocery, scheduled orders, ads and restaurant onboarding for later. Interviewers like this one because it looks like Uber for the first ten minutes and then turns out to be different: the food has to be cooked first, so the clock that matters is the kitchen's, not the partner's. Candidates who copy their Uber answer assign a partner the instant the order lands, and the partner stands at the counter for twelve minutes.

The 30-second answer

"Customers search nearby restaurants through a search service on Elasticsearch with a geo filter and a per-area cache. Placing an order is idempotent: it authorises the payment and writes the order and an outbox row in one Postgres transaction, and a relay publishes the event to Kafka. Every state change is a guarded update, so cancel and accept can't both win. Dispatch consumes the accepted order, asks a prep-time model when the food will be ready, reads free partners near the restaurant from a Redis geo index, and assigns in small batches so the partner arrives as the food does. Status and partner location reach all three apps over WebSockets, with mobile push when the app is closed."

What you’ll learn

  • Build the design one requirement at a time, from browse to live tracking
  • Make cancel-versus-accept safe with guarded updates and a transactional outbox
  • Make checkout exactly once with two idempotency keys: one for the order, one for the payment provider
  • Time dispatch by a prep-time distribution, not at order time, and re-plan every few seconds
  • Beat greedy assignment with batch matching, and keep search fast with a per-cell cache

Interview radar

What they probe: the order state machine, exactly-once ordering and payment, when and how you pick a partner, slow kitchens, and how search stays fast at dinner time. Where candidates slip: dispatching the moment the order is placed, no idempotency on "place order", publishing events with a dual write, one database for menus, orders and locations, and forgetting the restaurant is a participant at all.

011. Understand the problem

Functional requirements

Here's the scope I'd agree on in the first five minutes. The core requirements are:

  1. Customers should be able to browse restaurants that deliver to them, with menus and an estimated delivery time.
  2. Customers should be able to place and pay for an order.
  3. Restaurants should be able to accept or reject an order and mark it ready.
  4. The system should assign a delivery partner, who picks the order up and delivers it.
  5. All three apps should see the order's status in real time, and the customer should see the partner on a map.

Below the line

  • Grocery and multi-store baskets.
  • Scheduled orders and table bookings.
  • Promotions, ads and the ranking model in detail.
  • Ratings, reviews and support chat.
  • Restaurant onboarding and menu management tools.

At this point, ask your interviewer three questions: "Are all partners ours, or do some restaurants run their own fleet? Can one partner carry two orders? Do we take the money before or after the restaurant accepts?" Each answer moves the design. I'll assume our own fleet, one order per partner for the main design, and authorise-then-capture.

Before reading on: what are the non-functional requirements? Think about money, speed of browsing, the promised delivery time and the dinner peak. Put a number on each.

Non-functional requirements

  1. Orders and payments are exactly once. Two taps on "Place order", or a retry on a bad network, must still be one order and one charge. Every charge must match an order.
  2. Browsing is fast. The restaurant list loads in under 300 ms at p99. People browse about 20 times for every order they place.
  3. The ETA is honest. The promised time is the product. We want the partner to arrive at the restaurant within about 2 minutes of the food being ready, in either direction.
  4. It survives the dinner peak. About 5 million orders a day, a dinner peak of 3x the average, 300k restaurants and 500k partners online at peak.

Below the line

  • Multi-region active-active for the order database.
  • Fraud detection and abuse.
  • GDPR deletion flows and data retention.
Notice that the requirements disagree. Browsing wants

availability and speed (a list that's 30 seconds stale is fine). Orders and payments want strong consistency (a stale "PLACED" that's really "CANCELLED" costs real money). Say that out loud and say which parts of the system get which. "I'm going to keep search eventually consistent and cacheable, and make the order path transactional. Does that match what you care about?"

022. The set up

Plan the approach

We'll build the design one functional requirement at a time, adding only the boxes each one needs. Then we'll use the non-functional requirements to drive the deep dives: exactly-once maps to the state machine and payments, honest ETAs map to dispatch timing and partner choice, fast browsing maps to search, and the real-time requirement maps to tracking.

Core entities

Start with the nouns. We'll fill in fields as we need them.

  • Customer: who orders, with saved addresses.
  • Restaurant: location, delivery area, open hours, and a live signal of how busy the kitchen is.
  • Menu item: name, price, in stock or not. Read-heavy, changes a few times a day.
  • Order: customer, restaurant, a snapshot of items and prices at checkout, total, address, assigned partner and a state.
  • Partner: status (OFFLINE, FREE, ASSIGNED, DELIVERING), vehicle, and latest location.
  • Address: the drop point, which turns out to be harder than it looks (see the follow-ups).
Fig. 1

Six entities, and the Order is the one everybody shares

Six entities, and the Order is the one everybody sharesSix boxes. A Customer places an Order at one Restaurant, which offers Menu items. The Order holds a snapshot of the items and prices at checkout and the drop Address. A Partner is assigned to at most one active Order here and reports a Location every few seconds. Notes list the order states and partner states.Customerid, phone, addressesRestaurantlocation, hours, loadMenu itemname, price, in stockOrderstate, items, totalPartnerstatus, vehicleAddresslat, lng, notesplacesoffersreceivesdelivered tocarried byOrder.state: PLACED → ACCEPTED → PREPARING → READY → PICKED_UP → DELIVEREDPartner.status: OFFLINE · FREE · ASSIGNED · DELIVERING
The entity map. Everything hangs off the Order. It copies prices at checkout so a menu edit later can't change what the customer pays, and the two status lines at the bottom are the state machines the rest of the design protects.

API

One endpoint per requirement, plus the partner's location stream.

GET   /restaurants?lat=..&lng=..&q=pizza&cursor=..  -> { restaurants: [...], nextCursor }
GET   /restaurants/{id}/menu                         -> { items: [...], version }

POST  /orders          Idempotency-Key: 7f3c...
      { restaurantId, items: [{ itemId, qty }], addressId, paymentMethodId }
                                                     -> 201 { orderId, state: "PLACED", eta }

POST  /orders/{id}/accept     (restaurant)           -> 200 | 409
POST  /orders/{id}/ready      (restaurant)           -> 200 | 409
POST  /orders/{id}/pickup     (partner)              -> 200 | 409
POST  /orders/{id}/deliver    (partner)              -> 200 | 409
POST  /orders/{id}/cancel     (customer)             -> 200 | 409

POST  /partners/location      { lat, lng, ts }        -> 204
WS    /live                   order status + partner position

Two choices to explain. POST /orders carries an Idempotency-Key the app generates once per checkout, so a retry returns the order it already made. And the state changes are separate verbs, not a generic PATCH { state }, because each one has its own rule about who may call it and from which state; 409 Conflict means "someone else moved the order first".

Never trust the client with identity, prices or time. The customer, restaurant and partner IDs come from the

JWT, so a partner can't mark someone else's order delivered. The cart carries item IDs and quantities only: the Order service re-prices from the menu, so nobody orders a ₹600 biryani for ₹6. Timestamps are set by the server.

Back-of-envelope estimates

All inputs are assumptions to confirm with your interviewer; the arithmetic is ours and rounded. I only do the numbers that change the design.

Quantity Assumption Roughly
Orders 5M/day ÷ 86,400 s, dinner peak 3x ~60/s average, ~200/s peak
Restaurant searches ~20 per order ~4k searches/s at peak
Partner location writes 500k partners, a ping every 4 s ~125k writes/s
Order state changes ~7 per order ~1.4k writes/s at peak
Order storage 5M/day x ~2 KB ~10 GB/day, ~3.6 TB/year

Notice what this tells us. Orders are low volume and high value: one well-tuned Postgres primary handles 200 inserts a second without trying. The heavy traffic is search reads and location writes, so those get their own systems, and neither touches the order database.

033. High-level design

Let's satisfy the functional requirements one at a time. Each picture is the previous one plus a few new boxes, drawn in colour; what we already had turns gray.

1) Customers should be able to browse restaurants that deliver to them

The customer opens the app and sees a list of restaurants that will deliver to where they're standing, each with an ETA. We need:

  • Customer app: the client, talking to us over HTTPS.
  • API gateway: one front door that checks the JWT, applies rate limits and routes each call.
  • Search service: answers "which restaurants deliver here, matching this text, in what order?" and serves menus.
  • Search index: a search engine (Elasticsearch or OpenSearch) with one document per restaurant: location, delivery area, cuisine, menu text, rating, open hours and kitchen load.
Fig. 2

Step 1: the customer browses restaurants that deliver to them

Step 1: the customer browses restaurants that deliver to themThe first board. The customer app calls GET /restaurants with its location through the API gateway (step 1). The gateway routes it to the Search service (step 2). The Search service queries a search index for open restaurants whose delivery area contains the customer, matching the text and sorted by ETA (step 3). Menus are read the same way.CustomerAPI Gatewayauth · routingSearch Svcrestaurants · menusSearch index12geo + text3GET /restaurants?lat=..&lng=..&q=pizza→ restaurants + ETA, next cursor
The first board. Three boxes and a client are enough to browse. Follow badges 1 to 3.
  1. The customer app sends GET /restaurants?lat=..&lng=.. to the API gateway.
  2. The gateway authenticates the request and routes it to the Search service.
  3. The Search service queries the index with a geo filter (restaurants whose delivery area contains this point), the text match if there is one, and a sort by ETA and relevance. Opening a restaurant calls GET /restaurants/{id}/menu, served from the same place.

Why not query the restaurants table in the main database? Because "contains this point, matches 'paneer', sorted by a score" is exactly what search engines are built for, and because 4k searches a second shouldn't share a database with the money.

2) Customers should be able to place and pay for an order

The customer taps "Place order". We add three things:

  • Order service: owns the order and its state machine. It re-prices the cart from the menu and talks to payments.
  • Order DB: a relational database (Postgres). Orders are where we need transactions.
  • Payment provider: Stripe, Razorpay, Adyen or a bank gateway. A third party, so we draw it dashed.
Fig. 3

Step 2: the customer places and pays for an order

Step 2: the customer places and pays for an orderNew parts in colour: the Order service, the Order DB and a payment provider. Step 1: the app sends POST /orders with an idempotency key. Step 2: the gateway routes it to the Order service. Step 3: the Order service re-prices the cart from the menu and authorises the payment with the provider. Step 4: it writes the order as PLACED in the Order DB.CustomerAPI Gatewayauth · routingSearch Svcrestaurants · menusSearch indexOrder Svcorders · stateOrder DBPayment provider12authorise3PLACED4POST /ordersIdempotency-Key: 7f3c...{ restaurantId, items, addressId }→ { orderId, state: PLACED, eta }
Three new boxes. The order path is separate from search from the first step, because one is cacheable and the other is money.
  1. The app sends POST /orders with the item IDs and an Idempotency-Key.
  2. The gateway routes it to the Order service, with the customer ID from the JWT.
  3. The Order service loads current prices, computes the total, and asks the provider to authorise the amount. Authorising holds the money on the card without taking it.
  4. It writes the order with state PLACED and returns the orderId and ETA.

We authorise now and capture later (when the restaurant accepts or when the order is delivered, depending on the business). If the restaurant rejects, we void the hold instead of refunding a charge, which is faster for the customer and cheaper for us.

3) Restaurants should be able to accept or reject an order and mark it ready

The restaurant needs to hear about the order within a second or two, and the rest of the system needs to hear when it's accepted. We add:

  • Restaurant app: usually a tablet at the counter.
  • Order events: a Kafka topic with one event per state change, keyed by order ID so each order's events stay in order.
  • Notification service: consumes order events and decides who to tell and how (push to the app, SMS as a fallback for a restaurant tablet that's offline).
Fig. 4

Step 3: the restaurant hears about the order, accepts it and cooks

Step 3: the restaurant hears about the order, accepts it and cooksNew parts in colour: the restaurant app, an order events stream and a Notification service. Step 1: the order and an outbox row are written in one transaction; a relay publishes it to the order events stream. Step 2: the Notification service consumes it. Step 3: it pushes "new order" to the restaurant app. Step 4: the restaurant taps accept, which reaches the Order service through the gateway. Step 5: the Order service moves the order to ACCEPTED with a guarded update.CustomerAPI Gatewayauth · routingSearch Svcrestaurants · menusSearch indexOrder Svcorders · stateOrder DBPayment providerRestaurant...order eventsNotificationAPNs / FCMoutbox12new order34acceptACCEPTED5
Events enter the picture. The Order service never calls the restaurant directly. It records a fact; whoever cares reacts to it.
  1. When the order is written, an outbox row is written in the same transaction; a relay publishes it to the order events topic.
  2. The Notification service consumes order.placed.
  3. It pushes "new order" to the restaurant app.
  4. The restaurant taps Accept, which reaches the Order service through the gateway as POST /orders/{id}/accept.
  5. The Order service moves the order from PLACED to ACCEPTED with a guarded update, and the same outbox path publishes order.accepted.

Later the restaurant taps Ready the same way. If the restaurant doesn't accept within about 3 minutes, the order is cancelled and the hold is voided. We'll make "guarded update" precise in the first deep dive.

4) The system should assign a delivery partner, who picks up and delivers

Now the logistics. To pick a partner we need to know where partners are and when the food will be ready. We add five things:

  • Partner app: sends its position every few seconds while online.
  • Location service: takes about 125k pings a second and keeps the latest position per partner.
  • Geo index: an in-memory store (Redis) of free partners, indexed by area so "who is near this restaurant?" is a few key lookups.
  • Prep-time model: predicts when the food will actually be ready.
  • Dispatch: consumes accepted orders, decides who and when, and assigns.
Fig. 5

Step 4: dispatch picks a partner, at the right moment

Step 4: dispatch picks a partner, at the right momentNew parts in colour: the partner app, a Location service, a geo index, Dispatch and a prep-time model. Step 1: every partner app sends its position through the gateway to the Location service, which keeps the geo index fresh. Step 2: Dispatch consumes order.accepted. Step 3: it asks the prep-time model when the food will be ready. Step 4: it reads free partners near the restaurant from the geo index. Step 5: it publishes order.assigned. Step 6: the Notification service pushes the pickup task to the partner app.CustomerAPI Gatewayauth · routingSearch Svcrestaurants · menusSearch indexOrder Svcorders · stateOrder DBPayment providerRestaurant...order eventsNotificationAPNs / FCMPartnerDispatchwho and whenLocation Svcpartner pingsGeo indexPrep-time modelwhen is it ready?upsert12ready at?3free near45pickup task6
Dispatch joins the board. Notice that it asks two questions: when will the food be ready (badge 3) and who is close (badge 4). Uber only needs the second one.
  1. Every partner app sends POST /partners/location through the gateway to the Location service, which upserts the geo index.
  2. Dispatch consumes order.accepted.
  3. It asks the prep-time model when this order will be ready.
  4. Close to "ready time minus travel time", it reads free partners near the restaurant from the geo index and picks one.
  5. It publishes order.assigned.
  6. The Notification service pushes the pickup task to the partner app.

The partner then rides to the restaurant, taps Picked up (POST /orders/{id}/pickup), rides to the customer and taps Delivered. Each tap is another guarded state change.

5) All three apps should see the order move in real time

The last requirement is the one people feel. Status changes already flow through order events and Notification. What's missing is the partner's dot moving on the customer's map, and fast updates while the app is open. We add one box:

  • Live gateway: a fleet of servers holding one WebSocket per open app.
Fig. 6

Step 5: all three apps see the order move, live

Step 5: all three apps see the order move, liveOne new part in colour: a Live gateway holding a WebSocket per open app. Step 1: the partner app keeps sending its position to the Location service. Step 2: the Location service publishes each position on the order's channel to the Live gateway. Step 3: the gateway pushes it to the customer's open map. Status changes still flow through order events and the Notification service, which uses mobile push when the app is closed.CustomerAPI Gatewayauth · routingSearch Svcrestaurants · menusSearch indexOrder Svcorders · stateOrder DBPayment providerRestaurant...order eventsNotificationAPNs / FCMPartnerDispatchwho and whenLocation Svcpartner pingsGeo indexPrep-time modelwhen is it ready?Live gatewayWebSocket1order:123 channel2partner on map3
One new box. Pings flow partner to Location service to Live gateway to customer, and only for the people watching that order.
  1. The partner app keeps sending its position to the Location service.
  2. The Location service publishes each position on the active order's channel.
  3. The Live gateway node holding the customer's socket forwards it to the map.

When the app is closed, there's no socket, so Notification falls back to APNs or FCM for status changes. Nobody needs a map update with the app closed.

Record facts, let services react

The Order service writes one row and one event; dispatch, notifications, analytics and payments each consume what they need. Adding a "rate your order" email later is a new consumer, not a change to the order path. The rule that makes it safe is the transactional outbox, which we'll design properly in deep dive 1.

044. Deep dives

The board above works for a demo. Now we stress it with the non-functional requirements. For a senior candidate, I expect two or three of these driven by you; for staff, I expect you to pick the ones that matter for this business (timing and partner choice) without being asked.

1) The customer cancels as the restaurant accepts. What does the order end up as?

Three apps can move one order, often in the same second. If two moves both "win", you get a cancelled order that a kitchen is cooking, or a refunded customer whose food arrives anyway.

Fig. 7

One order, one state machine, three apps watching it

One order, one state machine, three apps watching itOrder states left to right: PLACED, ACCEPTED by the restaurant, PREPARING, READY, PICKED_UP by the partner, DELIVERED. Below the line, which app moves each step: customer, restaurant, partner. A restaurant can reject before acceptance and the order goes to CANCELLED with a refund. Every transition is a conditional update so two apps cannot move the same order at once.PLACEDACCEPTEDPREPARINGREADYPICKED UPDELIVEREDrestaurant movespartner movesCANCELLEDrejected / timeoutrefundEvery step is a guarded writeUPDATE orders SET state = 'READY'WHERE id = ? AND state = 'PREPARING'0 rows updated → someone else got there first
One order, one state machine. Each arrow belongs to one participant, and each is only legal from one state. The deep dive is about enforcing that under concurrency.

Before reading on: the customer taps "Cancel" at 8:00:01.200 and the restaurant taps "Accept" at 8:00:01.250, on two different Order service instances. What stops both from succeeding?

Naive approach: Read the state, decide, write it back

The handler loads the order, checks if order.state == PLACED, and saves the new state. It reads well and passes every single-user test.

Challenges: both handlers read PLACED before either writes, both checks pass, both writes land, and the last one wins silently. At 200 orders a second and a few seconds of overlap per order, this happens daily.

Solid approach: A guarded update: the state check lives in the write

Make the database do the check and the write as one atomic step:

UPDATE orders SET state = 'ACCEPTED', accepted_at = now()
WHERE id = :id AND state = 'PLACED';
-- 1 row: you won.   0 rows: someone moved it first; re-read and return 409.

Cancel is the same shape with SET state = 'CANCELLED'. The row lock serialises the two, the first commits, and the second matches zero rows. No locks held across a network call, no read-then-write gap.

Challenges: the state is right, but the rest of the system still has to hear about it. If the service updates the row and then crashes before publishing order.accepted, dispatch never sends a partner.

Fig. 8

Cancel and accept in the same second: the database picks one

Cancel and accept in the same second: the database picks oneSequence diagram with four participants: the customer app, the Order service, the Order DB and the restaurant app. Step 1: the restaurant taps accept and the service runs an update that only matches if the state is still PLACED. Step 2: the customer taps cancel at the same time and the service runs the same kind of guarded update. Step 3: the database applies the accept first and reports one row changed. Step 4: the cancel finds the state is no longer PLACED and reports zero rows. Step 5: the customer gets 409 with the current state and is offered a cancel request instead.1POST /orders/9/acceptSET ACCEPTED WHERE state='PLACED'2POST /orders/9/cancelSET CANCELLED WHERE state='PLACED'3accept: 1 row4cancel: 0 rows5409: already ACCEPTEDCustomer appOrder SvcOrder DBRestaurantOne row lock, one winner, no read-then-writethe loser re-reads the order and shows the real state
The database picks the winner. Both requests are valid on their own. The guard makes them mutually exclusive, and the loser gets a 409 with the real state.
Staff approach: Guarded update plus a transactional outbox

Write the state change and the event in the same transaction: the UPDATE above plus an INSERT INTO outbox (event_id, order_id, type, payload). A relay reads new outbox rows and publishes them to Kafka. The relay can be a poller (SELECT ... WHERE published = false ORDER BY id LIMIT 500 every 100 ms) or change data capture from the Postgres log with Debezium, which avoids the polling load.

Now there are only two outcomes: both the state and the event exist, or neither does. The relay can publish a row twice (it crashes after sending, before marking it), so every consumer dedupes on event_id. Add a per-order version column, incremented on every transition, so consumers can also drop out-of-order events.

Challenges: a little extra latency (tens of milliseconds for CDC) and one more moving part to monitor. Alert on outbox lag; if it climbs, orders are stuck in limbo.

Fig. 9

The state change and its event commit together, or not at all

The state change and its event commit together, or not at allLeft zone in red: the Order service updates the database, then publishes to Kafka in a second step; a crash between the two leaves an accepted order that dispatch never hears about. Right zone in green: the Order service writes the new state and an outbox row in one transaction. A relay (a poller or change data capture such as Debezium) reads the outbox and publishes to Kafka, where Dispatch, Notification and Analytics each consume it. Consumers dedupe on the event ID because the relay can publish a row twice.Naive: dual writeOrder SvcOrder DB...Kafka1. UPDATE2. publishcrash after 1, before 2:order ACCEPTED, nobody toldStaff: transactional outboxOrder SvcOrder DBorders + outboxRelayCDC / poller...order eventsDispatchNotificationAnalyticsone transactionconsumers dedupeon eventId
Dual write versus outbox. On the left, a crash between steps 1 and 2 loses the event. On the right, one transaction writes both, and the relay only ever publishes what committed.
Many candidates put orders in a NoSQL store "for scale" and then build cancel-versus-accept with read-then-write. At ~200 orders a second, scale isn't the problem; correctness is. Postgres with guarded updates is the right default. When storage grows past one machine, shard by

order_id (or by city), keeping each order and its outbox rows on the same shard so the transaction stays local.

2) How do we make sure an order is placed, and charged, exactly once?

Checkout is the worst place to be flaky. The customer taps "Place order", the spinner hangs on a 3G connection, and they tap again. Or the app retries on its own after a timeout. Meanwhile, our call to the payment provider can time out after the provider has already put a hold on the card.

Before reading on: the app sends

POST /orders, we authorise ₹450 with the provider, and the provider's reply is lost. The app retries. How do you avoid a second order and a second ₹450 hold?

Naive approach: Disable the button and hope

Grey out the button after the first tap, and on the server create an order and charge the card for every POST /orders.

Challenges: the button doesn't stop network retries, a killed app, or a load balancer that retries a timed-out request. Each one becomes a duplicate order and a duplicate charge, and support tickets you'll refund by hand.

Solid approach: An idempotency key on the order

The app generates a UUID once per checkout and sends it as Idempotency-Key. The orders table has a unique index on (customer_id, idempotency_key). The first request inserts; a retry hits the unique constraint, loads the existing order and returns it, with the same 201 body.

Challenges: this dedupes orders, not charges. If the first attempt timed out talking to the provider, did it authorise or not? Calling authorise again might hold the money twice.

Staff approach: Two keys, a pending state, and reconciliation

Split checkout into steps that can each be retried safely:

  1. Insert the order as PENDING_PAYMENT with the client's idempotency key (dedupes the order).
  2. Call the provider's authorise with our order ID as the provider's idempotency key. Every serious provider supports this: a repeat call with the same key returns the original result instead of a new hold.
  3. On success, move to PLACED and write the outbox row in one transaction.

A retry at any point re-runs from the order's current state, and each step returns the same answer twice. Capture later uses its own key (order-9-capture). A nightly reconciliation job compares our orders to the provider's report: every capture has a delivered order, every hold older than a day is captured or voided.

Challenges: orders stuck in PENDING_PAYMENT (the provider is down, the app was closed). A sweeper checks the provider for anything pending over 2 minutes and resolves it one way or the other.

Fig. 10

A retried checkout finds the first order, and the provider charges once

A retried checkout finds the first order, and the provider charges onceSequence diagram with four participants: the customer app, the Order service, the Order DB and the payment provider. Step 1: the app sends POST /orders with idempotency key k1. Step 2: the service inserts the order as PENDING_PAYMENT, unique on k1. Step 3: it asks the provider to authorise, with the order ID as the provider idempotency key. The response is lost and the app times out. Step 4: the app retries with the same key k1. Step 5: the insert hits the unique key, so the service loads the existing order and repeats the authorise with the same provider key, which returns the same authorisation instead of a second hold. Step 6: the order becomes PLACED with its outbox row.1POST /orders key=k12INSERT PENDING_PAYMENT (k1)3authorise idem=order-9auth a1 (reply lost)4POST /orders key=k15k1 exists: load order-9authorise idem=order-9same auth a1, no new hold6PLACED + outbox rowCustomer appOrder SvcOrder DBPayment providerTwo keys: the client key dedupes orders, the order ID dedupes chargestimeout, retry
A retry finds the first order. The client key dedupes the order; the order ID, reused as the provider's key, dedupes the hold. Follow the lost reply at step 3.
Many candidates say "use an idempotency key" and stop at the API. The interviewer's next question is "and the call to the payment provider?". Two calls to two systems need two keys. Name both.

3) When should dispatch send the partner?

This is where food delivery stops looking like Uber. A ride is ready the moment the rider asks. Food isn't ready for 10 to 30 minutes, and we don't know exactly when.

Before reading on: a biryani order is accepted at 8:00 pm. This kitchen usually needs 18 minutes at this hour, and the nearest free partner is 6 minutes away. When should dispatch assign them?

Naive approach: Assign the nearest partner as soon as the order is accepted

It's what the Uber design does, and it feels responsive: the customer sees "partner assigned" immediately.

Challenges: the partner arrives at 8:06 and waits 12 minutes at the counter, unable to take anyone else's order. Across a city at dinner, thousands of partners spend a third of their shift standing in restaurants. Partner time is the most expensive resource in this system, and this wastes it.

Fig. 11

Assign the partner so they arrive when the food does

Assign the partner so they arrive when the food doesA timeline from order placed to delivered. The kitchen preparation bar is predicted by the food preparation time model. Two partner bars: assigned too early, the partner arrives and waits at the counter; assigned on time, travel ends as the food becomes ready. Dispatch starts the search at roughly predicted ready time minus travel time.placedfood readydeliveredkitchenpredicted prep time (FPT)too earlytravelwaiting at counteron timetravelto customerstart search atready time - travel time+ handshake time at pickup
Arrive as it's ready. Assigning early turns travel into waiting. Assigning at ready time minus travel time puts the partner at the counter when the food reaches it.
Solid approach: Delay assignment by the restaurant's average prep time

Keep a rolling average prep time per restaurant and hour (from accepted_at to ready_at). Start looking for a partner at accepted_at + avg_prep - travel_eta.

Challenges: averages hide the spread. The same restaurant takes 12 minutes for one item at 4 pm and 30 minutes for six items at 8:30 pm with 20 tickets on the rail. And the "ready" tap is unreliable: some kitchens tap it when they start packing, some when the partner walks in.

Staff approach: Predict a distribution, pick the quantile, keep re-planning

Train a model on items and quantities, the restaurant's history at this hour and day, live kitchen load (orders in progress right now) and how long partners actually waited recently. Predict a distribution of prep time, not one number, and choose the point on it from the business cost: if a partner waiting 2 minutes costs less than food sitting 2 minutes, aim later (p70); if not, aim earlier.

Then don't trust the plan. Dispatch runs a loop per zone every few seconds: find orders whose start time predicted_ready - travel_eta - handshake is close to now, read candidates from the geo index, score and assign. New signals move the plan: the kitchen taps ready early, kitchen load spikes, the assigned partner stops moving.

Challenges: the model needs good labels, and "ready" taps are noisy. Use partner arrival and pickup timestamps (which are reliable, because the partner is standing there) to correct the labels.

Fig. 12

Predict a range for prep time, then pick the point you can afford to miss

Predict a range for prep time, then pick the point you can afford to missLeft: inputs to the model, namely the items and their count, this restaurant's history at this hour and day, live kitchen load (orders in progress), and how long partners actually waited recently. Middle: one multi-output model. Right: three outputs, the prep time as a distribution with p50 at 16 minutes and p80 at 21 minutes, the expected partner wait, and the handshake time at the counter. Note: training with a quantile loss lets you choose which side of the error you prefer.items + quantityrestaurant @ hourlive kitchen loadrecent partner waitsPrep-time modelone model, 3 outputsprep time (FPT)p50 16 minp80 21 minpartner wait at the counterhandshake: tap to bag in handQuantile loss: a late partner costs less than cold food? Train for p70, not the mean.
The prep-time model. Four kinds of input, one model, three outputs. The spread between p50 and p80 is the whole reason to predict a range.
Fig. 13

The plan is a guess, so dispatch re-checks it every few seconds

The plan is a guess, so dispatch re-checks it every few secondsA loop of four steps run per delivery zone every few seconds: read orders whose start time is near now (predicted ready minus travel minus handshake), read candidate partners from the geo index, score and assign, then watch new signals such as the restaurant tapping ready early, kitchen load rising or a partner going offline, and move the start time. Notes give the start time formula.Due ordersstart ≈ nowCandidatesgeo index, ring 1-2Score + assignguarded writeWatch signalsmove the planevery 5 s per zoneStart the search atpredicted ready - travel ETA - handshakekitchen taps "ready" early? pull it forwardkitchen slammed? push it backAssign is guardedUPDATE partners SET status='ASSIGNED'WHERE id=? AND status='FREE'
The dispatch loop. Every 5 seconds per zone: who is due, who is close, assign, then watch for signals that move the next plan.
Zomato describes the same problem: identical orders take different times depending on what's happening in the kitchen, so they model food preparation time as a distribution and train it with a

quantile loss to choose which side of the error they prefer. They also merged separate models, each around 6 million parameters, into one model that predicts prep time, the partner's wait at the restaurant and the handshake time to collect the order (Zomato).

4) Which partner should get which order?

Once an order is due, we need to pick a partner. The geo index gives us free partners near the restaurant; the question is how to choose among them when many orders are due at once.

Before reading on: two orders become due within 3 seconds of each other in the same area, and there are two free partners. Is "give each order the nearest free partner" the best you can do?

Naive approach: Greedy: nearest free partner, first come first served

Each order, as it becomes due, takes the closest free partner by straight-line distance.

Challenges: straight-line distance ignores roads, rivers and one-way streets. Worse, greedy is short-sighted: order A grabs the partner who was 2 minutes away from A but 3 minutes from B, leaving B with someone 12 minutes away. Total: 14 minutes when 7 was possible.

Solid approach: Score candidates by road ETA and a few business rules

For each due order, read candidates from the geo index (the restaurant's cell plus one or two rings), get road ETAs from a routing service, and score by ETA, distance from the partner's home zone, and acceptance rate. Assign the best scorer with a guarded update on the partner (SET status='ASSIGNED' WHERE id=? AND status='FREE').

Challenges: better per order, still greedy across orders.

Staff approach: Batch matching every few seconds, with order batching on top

Collect due orders and free partners per zone for a short window (5 to 10 seconds). Build a cost matrix of order x partner, where cost is road ETA plus penalties (partner far from home, partner rejected recently). Solve the assignment problem (Hungarian algorithm, or a min-cost flow for large zones) to minimise total cost, then commit each pair with the guarded update.

The same framework handles batching: if two orders come from the same restaurant (or two restaurants 200 m apart) to nearby drops, add a "partner carries both" option to the matrix with its own cost. The solver picks it when the combined trip beats two separate trips.

Challenges: a 5 to 10 second wait before assignment, which is invisible because dispatch is already timing assignments minutes ahead. Zones need limits (a few hundred orders per solve) to keep each solve under a second; split big cities into zones and let edge partners appear in both.

Fig. 14

Greedy picks the nearest partner; batch matching picks the best pairs

Greedy picks the nearest partner; batch matching picks the best pairsTwo panels with the same two orders, A and B, and two partners, P1 and P2. Left, greedy in red: order A arrives first and takes the nearest partner P1 at 2 minutes; order B is left with P2 at 12 minutes, 14 minutes in total. Right, batch matching in green: collect orders for a few seconds, build a cost matrix and solve the assignment; A takes P2 at 4 minutes and B takes P1 at 3 minutes, 7 minutes in total.Naive: greedy, first comeorder Aorder BP1P22 min12 mintotal wait: 14 minStaff: batch every 10 sorder Aorder BP1P24 min3 mintotal wait: 7 min
Greedy versus batch. Same two orders, same two partners. Matching them together halves the total wait.
Many candidates assign a partner and forget the partner can say no, go offline, or get a flat tyre. The assignment needs a timeout (accept within 30 seconds), a fallback (back into the next batch), and a guarded write so a partner who just went

ASSIGNED elsewhere isn't picked twice.

5) How does search stay fast at dinner time?

At 8 pm, 4k searches a second ask "which open restaurants deliver here, sorted by ETA?". Most of them come from the same few hundred neighbourhoods and ask the same question.

Before reading on: which part of that query is expensive, and what can you compute once and share across thousands of customers?

Naive approach: Query the restaurants table with a distance formula

SELECT ... FROM restaurants WHERE haversine(lat, lng, :lat, :lng) < 7 km AND open AND name ILIKE '%pizza%' ORDER BY ...

Challenges: a function on every row can't use an index, so each query scans 300k restaurants, and ILIKE '%...%' is another full scan. At 4k queries a second, the database that also holds orders falls over.

Solid approach: A search index with a geo filter

Put restaurants in Elasticsearch or OpenSearch. Each document holds a geo_shape delivery area (or a point and radius), cuisine, menu text, rating and open hours. A query is: filter by "delivery area contains the customer" first (300k down to a few hundred), then text match, then rank.

Challenges: every query still runs the full pipeline, and ranking needs live data (is the kitchen slammed right now?) that changes every few seconds.

Staff approach: Cache per area, and keep live fields small

Map the customer's location to an H3 cell (about 0.7 km² at resolution 8). Cache the home-screen result per cell and filter set for 30 to 60 seconds; thousands of customers in one cell see the same list, and the cache absorbs about 80% of home-screen traffic. On a miss, run the indexed query.

Keep open_now and kitchen_load as small fields updated in place every few seconds, separate from the menu text, which is re-indexed only when it changes. Serve menus from a cache keyed by restaurantId:version; an edit bumps the version. Prices shown are a hint: the Order service always re-prices at checkout.

Challenges: up to 60 seconds of staleness. A restaurant that just closed may show for a minute; the order path rejects it cleanly, which is acceptable for browsing.

Fig. 15

Most searches never reach the index

Most searches never reach the indexThe customer app sends GET /restaurants with its location. The Search service maps the location to an H3 cell and checks a cache keyed by cell and filters for 30 to 60 seconds. On a hit it returns at once. On a miss it queries the search index: first a geo filter to restaurants whose delivery area contains the customer (millions down to a few hundred), then the text match, then a rank by ETA, rating and kitchen load. Kitchen load and open now are small fields updated in place every few seconds.customerSearch Svclat,lng → H3 cellArea cachecell + filtershit? ~80%missSearch index (Elasticsearch / OpenSearch)geo filter10M → ~400text match"pizza"rankETA · rating · loadPartial updates: open_now and kitchen_load change every few seconds, the menu text changes daily.Why it is cheap- filter by area first- one cached list per cell- prices re-checked at checkout
Most searches stop at the cache. A miss runs the cheap filter first, so text match and ranking only see a few hundred restaurants.
Uber built H3 to index the planet in hexagonal cells at 16 resolutions; each finer cell is about one seventh the area of its parent (

Uber). The same cell ID that keys the search cache can key the geo index for partners, so both questions ("what's near this customer?" and "who's near this restaurant?") use one vocabulary.

6) How do we show the partner moving on the customer's map without melting the servers?

125k location pings arrive every second, but only the customer, the restaurant and support care about any one partner's position, and only while an order is active.

Before reading on: how does one partner's ping reach the one customer who's watching, when you have 50 Live gateway servers and you don't know which one holds that customer's socket?

Naive approach: The customer app polls every 2 seconds

GET /orders/{id}/partner-location on a timer.

Challenges: with 500k active orders at peak, that's 250k requests a second, almost all returning a position that moved a few metres. It drains batteries, and the dot still jumps every 2 seconds.

Solid approach: Mobile push for every update

Send each status change and position through APNs or FCM.

Challenges: mobile push is built for notifications, not streams. Delivery can take seconds, messages can be collapsed or dropped, and providers throttle high-frequency pushes. Fine for "Your order is on the way", wrong for a moving dot.

Staff approach: WebSockets, with pub/sub per order

Open apps hold a WebSocket to the Live gateway. The Location service publishes each ping of a partner with an active order to a channel named after the order (Redis pub/sub, or a Kafka topic keyed by order ID for durability). The gateway node holding the customer's socket subscribes to that channel when the socket opens, and only that node receives the ping.

Throttle map updates to one every 2 to 4 seconds and let the app animate between points. Send status changes as separate messages with a sequence number; a reconnecting app sends its last number and gets anything it missed. When the app is closed, status changes go through APNs or FCM instead.

Challenges: sticky connections make deploys and rebalancing harder. Drain gateway nodes slowly, and let clients reconnect with jittered backoff so a deploy doesn't cause a thundering herd.

Fig. 16

Only the people watching this order get this partner's pings

Only the people watching this order get this partner's pingsThe partner app sends a GPS ping every 4 seconds to the Location service. The Location service updates the geo index for dispatch and publishes the ping on a channel named after the active order, using Redis pub/sub or a Kafka topic keyed by order. Live gateway nodes hold the WebSockets; the node that holds the customer's socket subscribes to that order channel and forwards the ping. A side note: send at most one map update every 2 to 4 seconds, and send status changes as their own messages with a sequence number so a reconnecting app can catch up.partnerLocation SvcGeo indexdispatch...order:9 channelLive gw node 1Live gw node 2customerevery 4 supsertsubscribednot subscribedThrottle map updates to one per 2-4 s per order.Status changes go separately, with a sequence number: on reconnect, replay from it.
Only the watcher gets the ping. The order channel routes a partner's position to the one gateway node holding the customer's socket.
Uber replaced polling with a push platform after finding that polls made up a large share of the calls to its API gateway. It started with Server-Sent Events and sequence numbers, and later moved to gRPC bidirectional streaming so acknowledgements travel on the same stream (

Uber).

055. The final design

Here's everything on one board. Each zone is a tier; the numbered steps follow one order from the first search to the partner's dot on the map.

Fig. 17

Design a food delivery system: the whole platform on one board

Design a food delivery system: the whole platform on one boardLeft to right: customer, restaurant and partner apps, an edge tier, core services, data stores and third parties. Edge: a CDN for menus and images, the API gateway, and a Live gateway holding WebSockets. Core services: Search, the Order service, Notification, Location, an order events stream on Kafka, Payments, a prep-time model and Dispatch. Data stores: a search index, the Order DB and a Redis geo index. Third parties: a payment provider and APNs or FCM. Numbered steps follow one order: browse, search the index, authorise payment, write the order and outbox, publish the event, dispatch consumes it, asks for ready time, finds free partners nearby, assigns with a guarded write, status events reach Notification, which pushes through the Live gateway, and partner GPS keeps the geo index and the customer map fresh.ClientsEdgeCore services (per region)Data storesThird partiesCustomerRestaurantPartnerCDNmenus · photosAPI gatewayauth · rate limitLive gatewayWebSocketsSearch servicegeo + text + rankOrder servicestate machineNotificationwho to tell, howLocation servicepartner pings...Order eventsPaymentsauthorise · capturePrep-time modelFPT · wait · handshakeDispatchbatch match per zoneSearch indexElasticsearchOrder DBorders + outboxGeo indexRedis · H3 cellsPSPcards · UPIAPNs / FCMmobile push1acceptgeo + text2authorise3hold, then capturePLACED + outbox456ready at?7free near8assign9status1011GPS12move cellapp closed? mobile pushLegendrequestevent (async)data read / writeFollow one order1Customer browses, then orders2Search: geo filter, text, rank3Authorise the payment (held, not taken)4Write PLACED and the outbox row5Relay publishes order events6Dispatch consumes the accepted orderThen7Ask when the food will be ready8Free partners in nearby cells9Assign with a guarded write10Status events reach Notification11Pushed down each open app's socket12Partner GPS feeds index and map
The whole platform. Search is cacheable and separate; the order path is transactional; dispatch decides who and when; the Live gateway carries status and location to every open app. Follow badges 1 to 12.

Let's trace one order through it:

  1. The customer browses through the API gateway; menus and photos come from the CDN.
  2. The Search service filters the index by delivery area, matches the text and ranks by ETA (most home screens are answered from the per-cell cache).
  3. The customer orders with an idempotency key. The Order service re-prices the cart and Payments authorises the amount with the provider.
  4. The order is written as PLACED with its outbox row in one transaction.
  5. The relay publishes it to order events. The restaurant hears about it, accepts, and the guarded update publishes order.accepted.
  6. Dispatch consumes the accepted order.
  7. It asks the prep-time model when the food will be ready.
  8. At the right moment, it reads free partners in nearby cells from the geo index and batch-matches them.
  9. It assigns with a guarded write, which publishes order.assigned.
  10. Notification consumes every status event.
  11. It pushes each change down the open sockets on the Live gateway, or through APNs or FCM when the app is closed.
  12. The partner's GPS keeps the geo index fresh and, during the active order, moves the dot on the customer's map.

066. What interviewers look for by level

Mid-level

  • Three apps with clear roles and a single order state machine.
  • Search on a search index with a geo filter, separate from the order database.
  • A geo index for partners and push for status.
  • A working end-to-end flow with sensible APIs.

Senior

  • Guarded state transitions and the transactional outbox, without being prompted.
  • Idempotent checkout, and a second key for the payment provider.
  • Dispatch timed by predicted prep time, not at order time.
  • Numbers that show which parts are heavy (search and locations, not orders).

Staff+

  • Prep time as a distribution with a chosen quantile, and why the business cost picks the quantile.
  • Batch matching and order batching, with the trade-off for the first customer.
  • Failure handling: restaurant timeouts, partners going dark, Kafka outages, outbox lag.
  • How ETA errors and partner wait times feed back into the model.

077. Follow-up questions

What if one partner carries two orders?

Batch orders from the same or neighbouring restaurants that will be ready at similar times and are heading the same way. Score a batch by the extra delay it adds to the first customer, and only batch when that's under a few minutes. In the batch-matching design it's one more option in the cost matrix, so the solver picks it only when it wins.

What if the restaurant doesn't accept?

Put a timer on PLACED, about 2 to 3 minutes. On the first timeout, call the restaurant (an automated IVR call works); on the second, cancel and void the hold. Track acceptance rate per restaurant and lower its ranking, or pause it, if it keeps timing out.

What if two customers in one building write the address differently?

Then you have two drop points for one door, and partners waste minutes hunting. Zomato groups differently written addresses using sentence embeddings and DBSCAN clustering, so "A-4, XYZ Apartments" and "A/4 First Floor, XYZ Apts" land together; it also helps tell public places like stations from homes (Zomato). In the interview, "canonicalise addresses and learn the real drop point from past deliveries" is the line to say.

What if Kafka is down for a minute?

Orders still get placed: the outbox keeps the events in Postgres, and the relay catches up when Kafka returns. Dispatch falls a minute behind, so on recovery it should handle the oldest due orders first, and the prep-time plan for those orders should be re-checked rather than trusted.

What if a kitchen is overwhelmed at 8:30 pm?

Kitchen load is already an input to the prep-time model, so ETAs grow on their own. Go further: when a restaurant's predicted prep time passes a threshold, lower it in search ranking, show a longer ETA, or pause new orders for 10 minutes ("busy mode"). Protecting the promised ETA is worth more than one extra order.

088. How to say it in the interview

When I ask this question, I'm listening for three things: a clean order state machine with safe transitions, the insight that the kitchen sets the clock, and exactly-once ordering and payment.

In the interview

"It's a three-sided marketplace, and the order is the shared object. Search runs on Elasticsearch with a geo filter on the delivery area, cached per H3 cell for the home screen, so it never touches the order database. Checkout takes an idempotency key, re-prices the cart, authorises the payment with the order ID as the provider's key, and writes the order and an outbox row in one Postgres transaction; a relay publishes to Kafka. Every transition is a guarded update on the current state, so cancel and accept can't both win. Dispatch doesn't assign at order time: it predicts prep time as a distribution, picks a quantile from the cost of waiting versus cold food, and starts matching at ready time minus travel and handshake. It batch-matches due orders to free partners from a Redis geo index every few seconds, which beats greedy nearest-partner and lets one partner carry two orders when it helps. Status and the partner's position reach the apps over WebSockets with pub/sub per order, and mobile push when the app is closed."

On the whiteboard

  • Three apps; one order state machine; the order is the shared object
  • Search: Elasticsearch, geo filter on delivery area, cache per H3 cell 30-60 s
  • Checkout: Idempotency-Key, re-price, authorise with order ID as provider key
  • Postgres: UPDATE ... WHERE state = expected; 0 rows → 409; outbox → Kafka
  • Dispatch: start at predicted_ready - travel - handshake; re-plan every 5 s
  • Prep time: distribution + quantile loss (Zomato)
  • Partner choice: batch matching per zone, cost matrix, order batching
  • Live: WebSockets, pub/sub per order, seq numbers; APNs/FCM when closed
  • Scale (our math): ~200 orders/s, ~4k searches/s, ~125k pings/s

Concepts used in this design

Go deeper in Articles

Optional long reads behind this design, for when you want the full story.

Further reading (5)