You open the app at 8:05 pm on a rainy Friday in Bengaluru, and within half a second it shows you a few hundred restaurants that can reach your flat, each with a time: “32 min”, “45 min”. You order biryani, pay with UPI, and a few minutes later a delivery partner on a scooter heads for the restaurant — timed to arrive just as the food is packed. Food delivery looks like ride-hailing with a detour, but it is a three-sided marketplace with an extra clock: the kitchen. Because the food is not ready when you order, the hard questions are when to send a partner, not just whom; what time to promise before anyone has cooked anything; and which restaurants to show at all when half the city opens the app during an IPL innings break and the rain has sent partners under flyovers. In this problem you design the core of Swiggy, Zomato or DoorDash: discovery, ordering and payment, dispatch, and live tracking.
Understand the problem
Say the shape early: three parties, each with its own clock. The customer wants food fast and wants the time on the screen to be true. The restaurant cooks at its own pace, behind its own queue. The delivery partner wants to spend minutes riding with food, not waiting at a counter. A ride starts the moment the driver arrives; a food order has a second critical path — cooking — running in parallel with the partner’s, and the two must meet at the counter at the same moment. That one fact separates this design from Design Uber, and most of the deep dives follow from it.
Functional requirements
- Customers should be able to see restaurants that can deliver to them right now, each with an estimated delivery time.
- Customers should be able to order from a menu and pay, and the restaurant should be able to accept the order, prepare it and mark it ready.
- The system should assign a delivery partner to pick the order up and deliver it.
- Customers should be able to track the order live, with an updated ETA, until it reaches their door.
Non-functional requirements
- Scale. About 3 million orders a day nationally, with sharp lunch and dinner peaks, and up to 300,000 delivery partners online at the dinner peak, each sending GPS every 4 seconds. Plan for rain or a big cricket match doubling demand within 15 minutes.
- Fast discovery. The restaurant feed must render in about half a second end to end — Swiggy’s engineers note that customers leave if the list is slower — so the server budget is about 300 ms at p99.
- Promises you keep. The delivery time shown at checkout should be met for about 4 orders in 5 and rarely missed by more than 10 minutes. Most orders arrive in 30–45 minutes; nothing that cannot arrive within 60 is shown.
- Correct money and assignments. Never charge a customer without an order, never create two orders from one tap, and never give a partner two deliveries they cannot both make. These need strong consistency.
- Fresh enough data. Partner positions and serviceability may be eventually consistent: up to a minute old for the feed, 5–10 seconds for tracking.
- Availability. 99.95% for browsing and ordering — about 22 minutes a month. Orders already placed must survive any single server or zone failure.
In the interview
Ask: “Do we deliver every order with our own partners, or do some restaurants deliver themselves?”, “What is the longest delivery we will promise?”, “May one partner carry two orders?” and “Prepaid only, or cash on delivery too?” The first decides whether dispatch runs for every order; the third decides whether dispatch is a matching problem or a routing problem.
Out of scope
- Menu management, onboarding and ratings. Ordinary CRUD services.
- How the feed ranks restaurants, and ads. That is a recommendation problem; we produce the serviceable set and its ETAs.
- Partner payouts and incentive accounting. We emit events; a ledger pays out.
- Payment internals. We integrate with a payment system through intents and webhooks.
- Maps and routing. We consume travel-time predictions; how they are built is Design Google Maps.
- Grocery and 10-minute quick commerce. A follow-up question.
The set-up
Plan the approach
Walk the four requirements with the simplest design that works, then go deep where food delivery differs from ride-hailing: which restaurants to show, what time to promise, when to dispatch, when to batch, how an order survives failures with money involved, and what to do when it rains at 8 pm. State the split up front: “a read-heavy discovery path served from precomputed per-cell lists, a low-volume strongly consistent order path driven by a workflow, a periodic dispatch optimiser per zone, and a lossy high-volume location stream.”
Core entities
- Customer and Address — latitude and longitude, the H3 cell it falls in, and notes such as “Tower B, gate 2”.
- Restaurant — location, cell, hours, status (
open,busy,paused), live kitchen load and a cap on open orders. - Menu item — price, availability and typical prep time.
- Order — customer, restaurant, items, price breakdown, promised time, state and a version number.
- Payment — order ID, provider reference, amount, status and idempotency key.
- Delivery partner — vehicle, status (
offline,idle,to_restaurant,at_restaurant,to_customer), current deliveries and a version. - Delivery — the partner’s job for one or more orders: route, pickup and drop times, version.
- Zone — a set of H3 cells owned by one dispatch worker, with its live stress level, radius cap and fees.
Order: PAYMENT_PENDING → PLACED → ACCEPTED → READY → PICKED_UP → DELIVERED
(PAYMENT_PENDING, PLACED and ACCEPTED can end in CANCELLED)
Delivery: UNASSIGNED → OFFERED → ASSIGNED → AT_RESTAURANT → PICKED_UP → DROPPED
Keep order and delivery separate. The order is the contract between customer, restaurant and platform; the delivery is the partner’s job. They change at different times — an order is accepted long before anyone is assigned — and a delivery can be reassigned without touching the order.
API
The customer app loads the feed for its location:
GET /v1/feed?lat=12.9352&lng=77.6245&cursor=p2
200 OK
{
"restaurants": [
{ "id": "r_118", "name": "Biryani Corner", "eta_min": [33, 38], "fee_inr": 35, "status": "open" },
{ "id": "r_402", "name": "Dosa Point", "eta_min": [40, 45], "fee_inr": 25, "status": "busy" }
],
"rain_mode": false,
"next_cursor": "p3"
}
At checkout the app gets a quote — final price, fees and promised time, valid for five minutes — then places the order with an idempotency key, so a double tap on a flaky network still creates one order:
POST /v1/checkout/quote
{ "cart_id": "c_81", "address_id": "a_12" }
200 OK
{ "quote_id": "q_55", "total_inr": 420, "fees": { "delivery": 35, "rain": 0 }, "promise_by": "20:43", "expires_in_s": 300 }
POST /v1/orders
Idempotency-Key: 7b1e0c52-…
{ "quote_id": "q_55", "payment_method": "upi" }
201 Created
{ "order_id": "o_7d2", "state": "PAYMENT_PENDING", "payment": { "intent": "upi://pay?…" } }
Restaurants and partners use small, idempotent action endpoints:
POST /v1/restaurant/orders/o_7d2:accept body: prep_minutes = 15
POST /v1/restaurant/orders/o_7d2:reject body: reason = item_unavailable
POST /v1/restaurant/orders/o_7d2:ready
POST /v1/offers/of_19:accept partner; idempotent on offer ID
POST /v1/deliveries/d_44:picked-up
POST /v1/deliveries/d_44:delivered body: otp = 4821
Live data travels over persistent streams: partner location batches in, tracking updates out.
{ "type": "loc", "partner_id": "p_93", "seq": 4410, "points": [ { "lat": 12.9361, "lng": 77.6251, "speed_mps": 5.2, "ts": 1792586412 } ] }
{ "type": "track", "order_id": "o_7d2", "state": "PICKED_UP", "lat": 12.9381, "lng": 77.6270, "eta": "20:41", "seq": 57 }
Back-of-envelope estimates
(For the method, see back-of-envelope estimation.)
Orders. 3M orders a day ÷ 86,400 s ≈ 35 orders/s on average — but people eat at mealtimes. If the 8–9 pm hour carries 12% of the day, that is 3M × 0.12 = 360,000 orders in an hour: 360,000 ÷ 3,600 = 100 orders/s. A rainy IPL final doubles it to about 200 orders/s. As a sanity check, DoorDash reported 685 million orders in the last quarter of 2024 — 685M ÷ 92 days ≈ 7.4M a day across all its markets — so a few million a day for one country is realistic.
Feed loads. Most app opens do not end in an order: people browse, refresh, change filters, give up. Assume 25 feed loads per order: 100 × 25 = 2,500 feed requests/s at the dinner peak and 5,000/s in a spike. Swiggy describes showing 300–500 restaurants to each customer while considering thousands more.
Candidates per location. Take Bengaluru and assume 30,000 restaurants across 1,000 km², about 30 per km². A 7 km delivery radius covers π × 7² ≈ 154 km², so ≈ 4,600 candidate restaurants per customer location. If every feed load computed a fresh ETA for each, a spike would need up to 5,000 × 4,600 ≈ 23M ETA evaluations/s.
Precomputing instead. Cover the city with H3 resolution-8 cells, which average 0.737 km²: 1,000 ÷ 0.737 ≈ 1,360 cells. Precompute, per cell, which restaurants can serve it and their ETA: 1,360 × 4,600 ≈ 6.3M pairs per city. Refreshing all of them every 60 s is 6.3M ÷ 60 ≈ 105,000 evaluations/s — a few cores of vectorised arithmetic, and independent of how many people open the app. Storage is 4,600 entries × 16 bytes ≈ 74 KB per cell, about 100 MB for the city.
Partner GPS. 300,000 partners ÷ 4 s = 75,000 location updates/s; at about 100 bytes each, 7.5 MB/s. If on average 100,000 partners are online across the day: 100,000 × 21,600 updates each (86,400 ÷ 4) ≈ 2.2 billion points, about 216 GB a day of history.
Orders in flight. By Little’s law, 100 orders/s × 40 minutes from order to door (2,400 s) = 240,000 open orders at the dinner peak, each with a workflow and timers. If a partner is busy for 30 of those minutes, 100 × 1,800 = 180,000 partners are on a job — 60% of the 300,000 online, which leaves the slack dispatch needs.
Tracking pushes. If half of the open orders have the tracking screen open, updated every 5 s: 120,000 ÷ 5 = 24,000 pushes/s. Notice the direction: 75,000 positions arrive each second and only 24,000 leave. Each position has at most one or two viewers — the opposite of Nearby Friends-style fan-out.
Order writes and storage. About 12 state changes per order (payment, acceptance, ready, assignment, pickup, delivery) × 200 orders/s = 2,400 transactional writes/s at the worst moment — comfortable for a sharded relational database. Orders of about 5 KB × 3M = 15 GB a day, about 5.5 TB a year. Menus: 300,000 restaurants × 120 items × 1 KB ≈ 36 GB, which fits in a cache cluster.
Tip
The order rate here is tiny — about 100 a second — and interviewers notice when you say so. The load is in reads nobody pays for (app opens at 8 pm) and writes nobody sees (GPS). Size those first; the order database is the easy part.
High-level design
Build one requirement at a time; by the end the design works for one city at modest load.
Customers should be able to see restaurants that can deliver to them right now
The first instinct is to treat this like nearby places search: find restaurants within 7 km with a geospatial index and list them. That answers “what is near me?”, but the real question is “who can get hot food to me in under an hour, right now?” — which depends on the kitchen’s queue, whether any partner is free near the restaurant, and the traffic in between. Swiggy’s engineers call this the delivery system’s first job: deciding which restaurants are serviceable, and with what time, before you have ordered anything.
So the feed service does not compute; it looks up. A builder job per city precomputes, for every H3 cell, the restaurants that can deliver there now and an ETA band for each.
- The app sends the customer’s coordinates (or saved address) to the API gateway.
- The feed service maps them to an H3 resolution-8 cell — a pure function, no database.
- It reads that cell’s precomputed list: restaurant IDs, ETA bands and flags such as “busy”.
- It fills in names, ratings and photos from a catalog cache, applies filters, ranks the list for this customer and returns a page.
- Every minute, the city’s builder job rewrites the lists from live signals: kitchen load, partner supply near each restaurant, and current travel times.
You gain a feed whose cost does not depend on the evening. You pay with freshness — a list can be up to a minute old — so checkout re-checks the exact address and cart before money moves.
Customers should be able to order and pay, and restaurants should be able to accept and prepare
Placing an order is a small distributed transaction across the app, a payment provider and a restaurant tablet, so keep the moving parts few and make every step safe to retry.
- The app sends
POST /orderswith the quote and an idempotency key. The order service stores the order asPAYMENT_PENDING, creates a payment intent keyed by the order ID, and returns it. - The customer approves the payment in their UPI app (or enters card details).
- The provider calls a webhook. The order service verifies it and moves the order to
PLACED— idempotent on the provider’s reference, because webhooks arrive more than once. - That state change and an outbox row are written in one database transaction.
- A relay publishes the outbox row to the
order-eventstopic in Kafka — the transactional outbox pattern, so “order placed” can never be committed without the event, or the other way round. - The restaurant app, subscribed through the restaurant service, rings with the new order.
- Staff tap “Accept” and enter a prep time; later they tap “Ready”. Each tap is a conditional state change on the order.
Failures — the webhook that never comes, the restaurant that never answers — are the subject of a deep dive.
The system should assign a delivery partner
Dispatch consumes order events and owns the partner side. Start with the simplest rule that works: when an order is accepted, take the nearest idle partner by road ETA, push an offer to their app, and give them 30 seconds to accept; if they decline, offer the next one. The deep dives replace this rule with something much better.
Accepting an offer is a conditional write: the delivery moves from OFFERED to ASSIGNED only if it is still offered to this partner, and the partner’s row takes the job only if their version is unchanged and they have room. This is the same guard as in Design Uber’s double-assignment deep dive (and the general contention and double booking pattern), so it is not repeated here. What is different is everything that happens before the offer — the subject of two deep dives.
Customers should be able to track the order live
Partner apps stream GPS points to a location ingest service, which validates them and appends them to a partner-locations topic keyed by partner ID, so each partner’s points stay in order. Consumers keep the latest position per partner in Redis with a 60-second TTL (a partner who stops reporting simply disappears), re-predict the ETA when the partner moves, and archive points for model training and disputes.
When the customer opens the tracking screen, the app holds one WebSocket or SSE connection to a tracking gateway, which looks up the order’s partner, reads position and ETA, and pushes them every 5 seconds. When the app is closed, milestones — accepted, picked up, arriving — go out as push notifications instead. There is no polling and no broadcast: each position has one or two subscribers, so this is far simpler than the fan-out in real-time updates for chat or live comments.
The real cost sits on the partner’s phone. Android’s location guidance names three battery costs — accuracy, frequency and latency — so the partner app asks for high accuracy only while a delivery is active and the app is in the foreground, sends every 4 seconds while moving with food and every 15–30 seconds while idle, and on a weak network lets the OS batch points and uploads them together. Data is not the issue: 10 hours × 3,600 ÷ 4 = 9,000 updates of 100 bytes is under 1 MB a day.
That completes a working design: customers see serviceable restaurants, order and pay, get a partner and watch them come. It would also break promises every evening. The deep dives fix that.
Deep dives
How do we show only restaurants that can deliver to you right now?
Bad: a fixed radius, queried per request. Give every restaurant a 7 km radius and run a proximity search on each app open. It is fast and simple, but it ignores time. Two kilometres across Silk Board junction at 8 pm can take longer than five on an empty road; a kitchen with 25 open orders is not really serviceable; and a restaurant with no free partner nearby has a long wait before anyone even starts riding. You pay in broken promises and cancellations.
Good: compute a real ETA per candidate on each request. Accurate for the exact address — but, from the estimates, up to 23M ETA evaluations a second in a spike, and the cost grows with app opens, which spike exactly when the system is most stressed.
Great: precompute per-cell lists, and let a controller shrink them. Every 60 seconds the city’s builder combines four inputs into a list per H3 cell:
- Travel times between cells for two-wheelers, from recent partner trips plus live speeds, refreshed every few minutes. Swiggy explains why it does not simply use Google Maps for this: too slow for the number of pairs, too expensive, inaccurate for two-wheelers, and blind to the time from the society gate to your door.
- Prep estimates for each restaurant given its current queue.
- Supply near each restaurant: how long until a partner could be there.
- Caps per zone from the stress controller (last deep dive), such as “maximum 30 minutes in rain”.
The output for a cell is every restaurant whose predicted delivery time fits the cap, with an ETA band and flags. The builder writes a new version of the city’s lists and flips a pointer, so the feed never reads a half-written set; the feed pods keep the roughly 100 MB per city in memory or in a nearby Redis replica.
Which cell size? H3 resolution 7 cells average 5.16 km² with edges of about 1.4 km — too coarse, because the ETA from one side of a cell to the other differs by several minutes. Resolution 9 cells average 0.105 km², so the city needs seven times as many cells and seven times the precompute. Resolution 8, at 0.737 km² and edges of about 0.53 km, is the usual middle. Operations teams still draw zones as polygons — around a lake, along a highway with no crossing — and the system compiles each polygon into a set of cells, so counting orders and partners per area becomes a uniform lookup (more in geospatial indexes).
| Fixed radius per request | Real ETA per request | Precomputed per cell | |
|---|---|---|---|
| Cost per app open | One geo query | Thousands of ETA calls | One key lookup |
| Reacts to kitchen load and supply | No | Yes, exactly | Yes, within a minute |
| Accuracy for one address | Poor | Best | Cell-level; refined at checkout |
| Cost grows with | App opens | App opens × candidates | Cells × restaurants |
| Good fit | A prototype | Checkout only | The feed |
The cell is an approximation, so the precise check moves to checkout: the quote endpoint recomputes the ETA for the exact address and cart, and if it differs from the feed by more than a few minutes, the app shows the new time before the customer pays.
How do we predict a delivery time we can actually keep?
Swiggy’s engineers write the core of the problem as one equation:
delivery time = max(assignment delay + first mile, prep time) + last mile
The partner’s journey to the restaurant and the kitchen’s cooking run in parallel, so only the longer one counts. Add the pieces at either end — parking and collecting at the restaurant, which DoorDash models separately, and the walk from the society gate to your door, which Swiggy calls out — and the biryani order from the introduction looks like this:
Each leg is its own model:
- Prep time depends on the items (a pizza takes longer than a scoop of ice cream), the quantity, and how busy the kitchen is right now — so its features include the restaurant’s open orders and how long its recent orders took.
- Assignment delay and first mile depend on supply near the restaurant; dispatch knows which partners are free or about to be.
- Last mile comes from historical two-wheeler trips between the two cells, corrected with live speeds — 4 km at about 20 km/h is the 12 minutes above.
- Gate to door is learned per building — gated societies, visitor desks, slow lifts. Swiggy notes that Google Maps doesn’t predict this stretch, and that an extra 5–10 minutes seriously hurts a food order.
Your prep-time labels are censored
You rarely observe when food was actually ready. If the partner waited at the counter, pickup time is roughly ready time. If the partner arrived after the food was ready, you only know it was ready before pickup. DoorDash found that dropping those censored records biased its model, and fixed it by first adjusting censored labels using a prior estimate, then training ordinary gradient-boosted trees. The restaurant’s “ready” button is no better: staff often tap it when the partner walks in.
Three ETAs, not one. The feed ETA knows neither the cart nor the exact address, so it uses the restaurant’s typical prep time and the cell centre. The checkout ETA knows both. The in-order ETA is re-predicted at every event — accepted, ready, assigned, picked up — and as the partner moves. Keep the promise stable (the time quoted at checkout) and let the live ETA move; when the live ETA crosses the promise, tell the customer early rather than at the promised minute.
Choosing the number to show. The mean of a well-trained model is late half the time. A fixed buffer is too generous for a predictable café and too tight for a chaotic biryani kitchen at 9 pm. Predict a distribution instead, and show a quantile picked from the asymmetric cost of being wrong. If one minute late costs about four times one minute early (refunds, complaints, cancellations), the best promise is the 4 ÷ (4 + 1) = 80th percentile — the classic newsvendor rule.
With a spread of 6 minutes, the 80th percentile is 33 + 0.84 × 6 ≈ 38 minutes, so the app shows “33–38 min” on the feed and promises 20:43 at checkout. Then check calibration continuously: for every zone and hour, about 80% of orders promised at P80 should arrive on time. If only 70% do, the model is overconfident there, and the promise widens automatically.
How do we decide when to dispatch a partner, not just whom?
In ride-hailing the rider is waiting, so you dispatch now. Here the food is not ready, so every dispatch decision has a time dimension. DoorDash puts it plainly: dispatch too early and the partner waits at the store; too late and the food sits and goes cold. Swiggy’s example is the one to quote: food ready in 15 minutes, partner 5 minutes away — assign immediately and the partner idles for 10.
Bad: greedy, the moment the order arrives. Give each new order the nearest free partner. It is simple and instant. You pay twice: partners idle at counters — 10 minutes on thousands of orders an evening is a large slice of the fleet — and an early commitment takes a partner that an order arriving a minute later needed more.
Good: delay, then match in batches. Swiggy’s naive fix is to wait for roughly prep time minus the average first-mile time before assigning. Better still, stop deciding one order at a time. Every 30–60 seconds, each zone’s dispatch worker builds a cost matrix of open orders against candidate partners and solves it as one assignment problem:
- Candidates include idle partners and partners about to finish a drop nearby. Swiggy calls this next-order assignment: a partner who will be free in 10 minutes and is 5 minutes from a kitchen whose food needs 20 is a perfect match, and considering them enlarges the pool.
- Cost of pairing an order with a partner is a weighted sum of predicted lateness against the promise, partner idle time at the counter, and empty riding.
- A “not yet” option for every order: its cost is low while the order’s ideal dispatch moment is still minutes away, and rises steeply as it approaches. When “not yet” wins, the order simply waits for the next round.
The Hungarian algorithm solves an n × n assignment in O(n³). If Bengaluru carries 15% of the national peak — 15 orders a second — split over 30 zones, each zone sees about 30 new orders a minute; if orders wait about 4 minutes on average before dispatch, a round holds roughly 120 orders against perhaps 400 candidate partners. Padding to 400 × 400 gives 400³ = 64 million basic steps — tens of milliseconds. Only orders whose moment has come are actually offered; the rest of the plan is tentative and is recomputed next round. Swiggy describes the payoff of this “earmark, don’t dispatch” idea: if a better order for partner A arrives a minute later, the plan can swap partners before anyone has started riding.
Great: optimise routes on predicted inputs. DoorDash’s dispatch system, DeepRed, adds two things. First, an ML layer predicts every input instead of assuming it: when the order will be ready, travel times including parking and walking, and how likely each partner is to accept. Acceptance matters more than it looks: with a 30-second offer window and a 0.7 chance of acceptance, you expect 1 ÷ 0.7 ≈ 1.4 offers per order, so about 0.4 × 30 ≈ 13 seconds of extra delay on average — and much more on a rainy night when riders decline. Second, the matching is a mixed-integer program rather than a pure assignment, so a candidate can be a route carrying several orders. DoorDash reported that a commercial MIP solver was up to 10 times faster than the Hungarian algorithm on its matching problem, ran it per region several times a minute, and noted that the speed lets it solve larger regions, which reduces edge effects at region boundaries. The scoring function also penalises variance: each extra stop compounds the error of every prediction, so a route that is good on average but risky loses to a safer one.
| Greedy now | Batched assignment | Optimiser on predictions | |
|---|---|---|---|
| Decides | Whom, immediately | Whom and whether now, each round | Whom, when and which orders together |
| Partner idle at counter | High | Low | Lowest |
| Handles batching | No | Bolted on | Natively, as routes |
| Added latency | None | Up to one round (30–60 s) | Up to one round |
| You pay | Wasted fleet time | A solver and tuned cost weights | A solver, ML models that drift, switchback experiments to prove changes |
In the interview
Say the time dimension out loud: “For each order, dispatch decides not only which partner but when. Often the best answer is ‘nobody yet’: I would rather hold a partner free for two minutes than have them stand at a counter for ten.”
When should one partner carry two orders?
Batching (or stacking) gives one partner two or more orders on one trip. Swiggy points out that batching every order would double the capacity of the whole fleet — which is why it is tempting — and DoorDash notes it works best when the orders come from the same store or nearby stores, because the second pickup costs little and prep time gives the partner slack to collect both.
With a 6-minute first mile per partner, the solo plan costs (8 + 2) + (10 + 2) + 2 × 6 = 34 partner-minutes; the stacked plan costs 8 + 2 + 4 + 2 + 6 = 22 — a saving of 12, or 35%. Customer B gets her food at 16 minutes instead of 12.
When is it worth it? Two orders are batchable when they are placed within a few minutes of each other, from the same or adjacent restaurants, to customers close to each other — and every order in the batch still meets its promise, in the delivery sequence chosen. Order matters: A then B may keep both promises while B then A breaks A’s. Searching all combinations and sequences is a vehicle routing problem, which Swiggy notes is NP-hard, so real systems prune hard: same restaurant or restaurants within a few hundred metres, drops within 1–2 km, ready times within 5 minutes, at most two or three orders, and a cap on how long any food sits in the bag.
The promise problem. If the promise is built on the solo time, almost no order is batchable. Padding every promise to make room loses orders. Swiggy’s proposal is to detect which orders could be batched and change the promise only for those. Tune the window by time and place: batch more when the stress ratio is high and partners are scarce (DoorDash notes that batching can then make deliveries faster as well as more efficient), and less when partners are idle and speed is free.
How do we keep orders, payments and partners consistent when things fail?
Three things must never disagree: what the customer paid, what state the order is in, and which partner holds which delivery. The tool for each is the same — a state machine whose every transition is a conditional write — plus a workflow that owns the timers.
Payment. Bad: charge first, then create the order — a crash between the two leaves a customer charged for nothing. Good: create the order as PAYMENT_PENDING with the client’s idempotency key, then a payment intent keyed by the order ID; the webhook moves the order to PLACED idempotently, and a reconciliation job polls the provider for any intent whose webhook has not arrived within a couple of minutes. Money that lands after the 10-minute timer has cancelled the order is refunded automatically. Cards can authorise now and capture later; an ordinary UPI payment debits immediately, so for UPI every cancellation becomes a refund. Either way the compensation must itself be idempotent — use the order ID as the refund’s key.
State changes. Every transition is guarded, so duplicate taps, retried webhooks and racing timers resolve to exactly one winner:
UPDATE orders
SET state = 'ACCEPTED', prep_minutes = 15, version = version + 1
WHERE id = 'o_7d2' AND state = 'PLACED' AND version = 3;
-- 0 rows updated: something else moved the order first (a cancel, or a duplicate tap)
Timers. Who cancels an order the restaurant never accepts? Bad: a timer in a server’s memory, lost on the next deploy. Good: a sweeper that polls for overdue orders, which works but smears one order’s logic across handlers and cron jobs. Great: one durable workflow per order, a saga whose steps each have a compensation. The engine stores the timers, so a crashed worker changes nothing; Design Uber shows how replay works. DoorDash describes delivery creation that flows through Kafka and Postgres with Cadence handling retries, a separate cancellation step for orders that keep failing, and — the lesson worth repeating — care to make every non-idempotent step safe to retry.
def order_workflow(order_id):
if not wait_for("paid", timeout=minutes(10)):
return act.cancel_if(order_id, state="PAYMENT_PENDING") # nothing was charged
act.send_to_restaurant(order_id)
if not wait_for("accepted", timeout=seconds(90)):
act.call_restaurant(order_id) # automated phone nudge
if not wait_for("accepted", timeout=seconds(90)):
if act.cancel_if(order_id, state="PLACED"): # false if accepted at 179.9 s
act.refund(order_id) # refund key = order ID
return act.notify(order_id, "cancelled_refunded")
act.request_dispatch(order_id) # dispatch decides when
wait_for("delivered", alert_after=promise_plus(minutes(15)))
act.settle(order_id) # restaurant and partner earnings
If the restaurant taps “Accept” at 179.9 seconds, the conditional cancel finds the order already ACCEPTED, returns false, and the workflow carries on — no refund, no angry kitchen. Events leave through the outbox, and consumers such as the restaurant app and dispatch deduplicate by event ID (the inbox pattern).
Partners. Batching changes the double-assignment guard slightly: a partner may hold two deliveries, so the conditional write checks active_deliveries < capacity and the partner’s version, not just “is free”. Reassignment — a punctured tyre before pickup — bumps the delivery’s version, so a late “picked up” from the old partner fails instead of overwriting the new one.
How do we survive the 8 pm rain-and-IPL peak?
Heavy rain at dinner time hits every side at once. Demand jumps because nobody wants to go out. Supply drops because riders shelter under flyovers, and DoorDash notes that in rain, partners on motorbikes decline more offers. Every trip gets slower, and kitchens jam. Do nothing, and promises break, cancellations climb and partners carry food for an hour.
Measure stress per zone. Divide the orders that will need a partner in the next 15 minutes by the partners who will be free in that time. In Koramangala at 8:30 pm on a wet IPL night that might be 540 ÷ 300 = 1.8. Then pull levers in order of cost, each per zone:
- Honest ETAs. The models already lengthen promises as prep and travel slow down. Free, and it nudges some customers to wait.
- Batch more and use next-order assignment. Same partners, more orders per trip.
- Fees and boosts. A rain or surge fee damps marginal demand; a per-order boost brings more partners online and keeps them riding. You pay in conversion and in fairness questions, so cap and display both clearly.
- Shrink the radius. The controller tells the serviceability builder to cap the zone at 5 km, then 3.5 km. If the average job falls from 30 to 20 partner-minutes, the same fleet completes 30 ÷ 20 = 1.5 times as many orders.
- Throttle kitchens. A kitchen that finishes about one order a minute with 25 open orders will not have a new one ready for about 25 minutes, whatever its menu says. Cap open orders per restaurant; past the cap, show it as “busy” with a longer ETA, or hide it.
- Pause new orders in the zone, with a clear message, while every placed order is still delivered.
Change levels with hysteresis — for example, enter a level at 1.6 but leave it only below 1.4, and change at most every few minutes — or the zone flaps between states.
Degrade the system gracefully, too. If the builder falls behind, the feed serves the last good lists for up to 5 minutes with padded ETAs. Personal ranking can fall back to popularity. Widgets that are not about eating — offer carousels, reviews — switch off first. Order placement, payment webhooks and dispatch get priority, protected by load shedding and rate limits on everything else. And because IPL fixtures are public, scale the feed and gateways before the innings break, not after.
Tip
Make every lever per zone and every threshold a config value with a kill switch. A storm can soak Whitefield while Jayanagar is still dry, and one city-wide switch would punish the dry half of the city.
The final design
Put the pieces together. The discovery path along the top is served from precomputed lists; the order path through the middle is small, transactional and driven by events; dispatch and locations along the bottom are periodic and lossy; and a zone controller watches supply and demand and turns the knobs.
Follow one biryani from app open to doorstep:
- Browse. The app sends its location; the feed service maps it to an H3 cell, reads that cell’s precomputed restaurants and ETA bands, adds catalog details from cache and ranks them. About 2,500 such reads a second at the dinner peak cost one lookup each.
- Order and pay. Checkout re-quotes for the exact address and cart.
POST /orderswith an idempotency key creates the order asPAYMENT_PENDING; the customer pays in their UPI app; the provider’s webhook moves the order toPLACED. - Announce. Every state change writes an outbox row in the same transaction, and a relay publishes
order.created,order.placedand the rest to Kafka. - Guard. The first event starts a durable workflow for the order: a 10-minute payment timer, then a 3-minute acceptance timer with a phone nudge at 90 seconds, and cancel-plus-refund (keyed by order ID) if the kitchen never answers.
- Cook. The restaurant app rings; staff accept with a prep time and later mark the order ready. Each tap is a conditional state change.
- Dispatch. Every 30–60 seconds the zone’s dispatch worker scores open orders against free and nearly-free partners using predicted ready time, travel and acceptance; it may batch two orders, and it often decides “not yet”. When an order’s moment comes, it offers the job through the realtime gateway, and the acceptance is a conditional write on the partner’s version and capacity.
- Locate. Partner apps stream GPS every 4 seconds — 75,000 updates a second at peak — through the realtime gateway into Kafka; consumers keep the latest position and a fresh ETA in Redis.
- Track. The customer’s tracking screen gets the partner’s position and ETA every 5 seconds over its stream; with the app closed, milestones arrive as push notifications. The partner enters the OTP at the door, and the workflow settles the order.
- Balance. The zone controller compares demand with free partners per zone; as stress rises it batches more, adds fees and boosts, shrinks the serviceable radius in the next rebuild, throttles busy kitchens, and as a last resort pauses new orders.
Check it against the requirements: the feed is a sub-300 ms lookup whatever the load; promises sit at the 80th percentile and are calibrated per zone; money and assignments move only by conditional writes, with idempotent compensation; and the bulk traffic — feed reads and GPS writes — never touches the order database.
What interviewers look for by level
| Area | Mid-level | Senior | Staff |
|---|---|---|---|
| Requirements and numbers | Lists the three apps and an order flow | Notices orders are few; sizes feed reads and GPS writes | Frames three parties with three clocks; picks what to sacrifice at peak |
| Discovery | Geo query by radius | Serviceability depends on prep and supply; caches per cell | Per-cell precompute with H3 sizing, versioned rebuilds, checkout re-quote |
| Delivery-time promise | One ETA from a maps API | Prep + first mile + last mile with max() | Censored prep labels, quantile promises from cost asymmetry, calibration per zone |
| Dispatch | Nearest free partner | Batched assignment with lateness and idle costs | Time dimension (“not yet”), next-order assignment, ML inputs to a MIP, acceptance risk |
| Batching | Not discussed | Same-restaurant batching | Batchability rules, sequence, promise padding only where batching is likely |
| Order and payment | Order then charge | Idempotency keys, webhooks, outbox | Workflow with timers, saga compensations, conditional cancels that lose gracefully |
| Peaks | Autoscale | Surge fee | Stress ratio per zone, lever ladder with hysteresis, graceful degradation |
Follow-up questions
What if we add 10-minute grocery delivery?
The clocks change. Picking a basket in a dark store takes a couple of minutes, so prep is short and predictable, and partners wait at the store rather than being dispatched to it — the time dimension in dispatch mostly disappears. Serviceability shrinks to roughly 2–3 km around each store, and the new hard problem is inventory: two customers buying the last carton of milk is a contention and double booking problem, solved with a reservation at checkout.
What if a restaurant marks food ready before it really is?
Treat restaurant signals as features, not truth. Compare “ready” taps with partner wait times at that restaurant; if partners routinely wait 6 minutes after “ready”, learn a per-restaurant offset and give the restaurant a reliability score that feeds its prep model and its feed ranking. Staff who tap ready on time get partners who arrive on time, which is the incentive.
What if the partner’s phone dies mid-delivery?
The latest-location key expires after 60 seconds, which raises an alert in the order’s workflow. Before pickup, reassign: bump the delivery’s version and run the order through the next dispatch round. After pickup the food is with that partner, so the workflow pings them by SMS or phone, tells the customer honestly, and escalates to support if nothing changes within a few minutes; a refund or a remake is a support decision, not an automatic one.
What if a customer schedules an order for 1 pm tomorrow?
Store it as an order with a target time, and let its workflow sleep on a durable timer until the target minus predicted prep minus a buffer, then send it to the restaurant and dispatch as usual. Scheduled orders also help capacity planning: they are known demand that the controller can count when it forecasts tomorrow’s lunch stress.
What if the dispatch optimiser for a city crashes at 8 pm?
Zones are independent workers, so one failure affects one zone, not the city. Each round has a deadline: if the solver misses it, the worker falls back to a greedy just-in-time rule — offer to the best partner once “ready time minus travel time” arrives — which is worse but safe. DoorDash’s practice of running a new optimiser side by side with the old one and logging the differences before switching is the right way to change this tier-zero component.
How to say it in the interview
“I’ll design a food delivery marketplace like Swiggy: about 3 million orders a day, 100 a second at the dinner peak and double that on a rainy IPL night, with up to 300,000 partners sending GPS every 4 seconds. The order rate is small; the load is feed reads at 8 pm and 75,000 location writes a second.
“What makes this harder than ride-hailing is the kitchen. Food isn’t ready when you order, so I need to decide which restaurants to show, what time to promise, and when — not just whom — to dispatch.
“Discovery is a lookup. A builder per city precomputes, for every H3 resolution-8 cell, the restaurants that can deliver there and an ETA band, every minute, from kitchen load, partner supply and two-wheeler travel times. The feed maps you to a cell and reads that list; checkout re-quotes for your exact address.
“The delivery time is max of assignment plus first mile and prep, plus last mile and the gate-to-door time. I predict a distribution and promise about the 80th percentile, because being late costs more than being early, and I check calibration per zone.
“Orders are a state machine with conditional writes: idempotency key, payment webhook, an outbox to Kafka, and a durable workflow per order that cancels and refunds if the restaurant doesn’t accept in three minutes.
“Dispatch runs per zone every 30 to 60 seconds. It builds a cost matrix of orders against free and nearly-free partners — lateness, idle time, empty riding — with a ‘not yet’ option, so partners arrive as the food is ready. A mature version uses ML predictions for prep, travel and acceptance and a MIP solver that can also batch two orders on one trip.
“Partner GPS goes through Kafka into Redis and is pushed to the one customer watching. And when it rains, a controller per zone measures orders versus free partners and pulls levers in order: batch more, fees and boosts, shrink the radius, throttle kitchens, and finally pause new orders.”
Concepts used in this design
- Geospatial indexes: geohash, quadtrees, S2 and H3 — H3 cells for serviceability lists and zones.
- Proximity search — the radius query the feed outgrows.
- Scaling reads — precomputing the feed so app opens cost a lookup.
- Kafka — order events and the partner location stream.
- Redis — latest partner positions with a TTL.
- Idempotency — safe order creation, webhooks and refunds.
- Transactional outbox — an order and its event commit together.
- Effectively-once consumers and the inbox pattern — consumers that tolerate duplicates.
- Workflow engines — durable timers for acceptance, payment and delivery.
- Distributed transactions: two-phase commit vs sagas — cancel and refund as compensation.
- Contention and double booking — never two conflicting jobs for one partner.
- Real-time connections — the tracking stream.
- Backpressure and load shedding — protecting ordering when the feed is overloaded.
- Design Uber — the sibling problem: moving-driver indexes, double-assignment guards and push.
References and further reading
- The Swiggy Delivery Challenge — Part One (Swiggy Bytes)bytes.swiggy.comServiceability on app open, 300–500 restaurants per customer in half a second, and the delivery-time equation max(assignment delay + first mile, prep) + last mile.
- The Swiggy Delivery Challenge — Part Two (Swiggy Bytes)bytes.swiggy.comJust-in-time assignment, next-order assignment for busy partners, and why batching is NP-hard yet can double fleet capacity.
- Using ML and Optimization to Solve DoorDash’s Dispatch Problem (DoorDash Engineering, 2021)careersatdoordash.comDeepRed: candidate offers, ML predictions of ready time, travel and acceptance, a MIP optimiser, batching and deliberately delayed dispatch.
- Next-Generation Optimization for Dasher Dispatch at DoorDash (DoorDash Engineering, 2020)careersatdoordash.comMoving from the Hungarian algorithm to a mixed-integer program solved per region several times a minute, up to 10× faster and able to route multiple deliveries.
- Solving for Unobserved Data in a Regression Model Using a Simple Data Adjustment (DoorDash Engineering, 2020)careersatdoordash.comWhy food prep-time labels are censored and how a two-stage adjustment fixes training data.
- Building Reliable Workflows: Cadence as a Fallback for Event-Driven Processing (DoorDash Engineering, 2020)careersatdoordash.comDelivery creation over Kafka and Postgres with Cadence retries, a cancellation step after repeated failures, and making non-idempotent steps safe.
- H3 documentation: tables of cell statistics across resolutionsh3geo.orgAverage cell area and edge length per resolution, used to size serviceability cells.
- Hungarian algorithm (Wikipedia)en.wikipedia.orgThe assignment problem and the O(n³) bound for the Kuhn–Munkres method.
- DoorDash releases fourth quarter and full year 2024 financial resultsir.doordash.com685 million total orders in Q4 2024, used as a sanity check on daily order volume.
- Optimize location use for battery (Android Developers)developer.android.comAccuracy, frequency and latency as the three battery costs of location, and batched delivery of location updates.