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.
On this page
🍔 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:
- Customers should be able to browse restaurants that deliver to them, with menus and an estimated delivery time.
- Customers should be able to place and pay for an order.
- Restaurants should be able to accept or reject an order and mark it ready.
- The system should assign a delivery partner, who picks the order up and delivers it.
- 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
- 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.
- Browsing is fast. The restaurant list loads in under 300 ms at p99. People browse about 20 times for every order they place.
- 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.
- 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.
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).
Six entities, and the Order is the one everybody shares
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".
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.
Step 1: the customer browses restaurants that deliver to them
- The customer app sends
GET /restaurants?lat=..&lng=..to the API gateway. - The gateway authenticates the request and routes it to the Search service.
- 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.
Step 2: the customer places and pays for an order
- The app sends
POST /orderswith the item IDs and anIdempotency-Key. - The gateway routes it to the Order service, with the customer ID from the JWT.
- 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.
- It writes the order with state
PLACEDand returns theorderIdand 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).
Step 3: the restaurant hears about the order, accepts it and cooks
- When the order is written, an outbox row is written in the same transaction; a relay publishes it to the order events topic.
- The Notification service consumes
order.placed. - It pushes "new order" to the restaurant app.
- The restaurant taps Accept, which reaches the Order service through the gateway as
POST /orders/{id}/accept. - The Order service moves the order from
PLACEDtoACCEPTEDwith a guarded update, and the same outbox path publishesorder.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.
Step 4: dispatch picks a partner, at the right moment
- Every partner app sends
POST /partners/locationthrough the gateway to the Location service, which upserts the geo index. - Dispatch consumes
order.accepted. - It asks the prep-time model when this order will be ready.
- Close to "ready time minus travel time", it reads free partners near the restaurant from the geo index and picks one.
- It publishes
order.assigned. - 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.
Step 5: all three apps see the order move, live
- The partner app keeps sending its position to the Location service.
- The Location service publishes each position on the active order's channel.
- 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.
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.
One order, one state machine, three apps watching it
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.
Cancel and accept in the same second: the database picks one
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.
The state change and its event commit together, or not at all
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:
- Insert the order as
PENDING_PAYMENTwith the client's idempotency key (dedupes the order). - 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.
- On success, move to
PLACEDand 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.
A retried checkout finds the first order, and the provider charges once
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.
Assign the partner so they arrive when the food does
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.
Predict a range for prep time, then pick the point you can afford to miss
The plan is a guess, so dispatch re-checks it every few seconds
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.
Greedy picks the nearest partner; batch matching picks the best pairs
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.
Most searches never reach the index
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.
Only the people watching this order get this partner's pings
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.
Design a food delivery system: the whole platform on one board
Let's trace one order through it:
- The customer browses through the API gateway; menus and photos come from the CDN.
- 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).
- The customer orders with an idempotency key. The Order service re-prices the cart and Payments authorises the amount with the provider.
- The order is written as
PLACEDwith its outbox row in one transaction. - The relay publishes it to order events. The restaurant hears about it, accepts, and the guarded update publishes
order.accepted. - Dispatch consumes the accepted order.
- It asks the prep-time model when the food will be ready.
- At the right moment, it reads free partners in nearby cells from the geo index and batch-matches them.
- It assigns with a guarded write, which publishes
order.assigned. - Notification consumes every status event.
- It pushes each change down the open sockets on the Live gateway, or through APNs or FCM when the app is closed.
- 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
- Rate limitingSoon
- Load balancing
Go deeper in Articles
Optional long reads behind this design, for when you want the full story.
Further reading (5)
- Predicting food preparation time optimally (Zomato): FPT as a distribution, quantile loss, and one multi-output model for prep time, partner wait and handshake time.
- How we use embeddings to identify and cluster unique addresses (Zomato): SBERT embeddings and DBSCAN to group differently written addresses, and telling public from private places.
- Uber's real-time push platform (Uber): Push instead of polling for live order and location updates.
- H3: Uber's hexagonal hierarchical spatial index (Uber): Cells for indexing partners and caching search per area.
- Swiggy Bytes (Swiggy engineering blog): Swiggy's engineering posts for further reading on dispatch and logistics.