- Food delivery is a three-sided, hyperlocal, real-time marketplace: customers, restaurants and delivery partners, all within a few kilometres, all moving within a 30–45 minute window.
- Split the system into discovery (read-heavy, cacheable, geo-filtered), ordering (transactional, an explicit state machine), and logistics (rider location, dispatch, tracking, ETA).
- Rider assignment works best as batched matching per zone every few seconds, timed so the rider arrives when the food is ready, not as "nearest free rider wins".
- Live tracking is a write-heavy location stream: keep only the latest position hot (Redis/geo index), push updates to watchers over WebSocket/SSE, archive history asynchronously.
- The dinner peak is predictable. Pre-scale, cache per geo-cell, isolate checkout from browsing, and use serviceability controls (radius, surge fee) as a business-level load shedder.
"Design Swiggy" sounds like a CRUD app with a map. It isn't. The hard parts are the ones you can't see in the app: deciding which of 40 nearby riders should pick up an order that will be ready in 11 minutes, keeping a customer's map moving smoothly while a million riders send GPS pings, and promising "32 mins" with a straight face at 8:30 PM on a rainy Saturday. This guide is structured around those hard parts, with the plain parts kept brief.
Why Food Delivery Is Different from Ride-Hailing and E-commerce
Candidates often reuse their Uber answer. There is overlap (location, matching), but three differences change the design:
| Dimension | Ride-hailing (Uber/Ola) | E-commerce (Flipkart) | Food delivery (Swiggy/Zomato) |
|---|---|---|---|
| Sides | Rider, driver | Buyer, seller, warehouse | Customer, restaurant, delivery partner |
| When to dispatch | Immediately | Hours later, planned | When food will be ready minus rider travel time |
| Perishability | None | Low | High: cold food is a failed order |
| Demand shape | Commute peaks | Sale events | Sharp lunch and dinner peaks every single day |
| Batching | Pooling, optional | Core | Valuable: two orders from one restaurant, same direction |
The preparation time of the restaurant is the variable that makes this problem unique. Assign a rider too early and they wait at the restaurant (wasted fleet capacity at peak). Too late and the food sits on the counter.
Requirements
Functional
- Customer: browse/search restaurants and dishes deliverable to their location, see ETA and fees, place an order, pay, track live, rate.
- Restaurant: receive orders, accept/reject, mark "food ready", toggle availability and menu items.
- Delivery partner: go online/offline, receive assignment offers, accept, navigate, mark picked up and delivered, see earnings.
- Platform: assign riders, compute ETAs, handle cancellations and refunds.
Non-functional
- Browse latency under ~200 ms at p99; it is the top of the funnel.
- Order placement and payment must be correct and durable (no lost or duplicate orders).
- Rider location freshness within a few seconds; tracking map updates every few seconds.
- High availability through the lunch and dinner peaks; graceful degradation when the fleet is saturated.
Out of scope (say so): restaurant onboarding, ads, grocery/quick commerce, rider payouts.
Capacity Estimates
Use round, clearly assumed numbers. Real company figures are not public at this granularity, so state your assumptions:
- 3 million orders/day across India; 40% of them in a 3-hour dinner window.
- Dinner order rate: 1.2M / 10,800 s ≈ 110 orders/s average, with the peak minute maybe 3x that: ~350 orders/s.
- Browse-to-order ratio ~20:1 on sessions, ~10 API calls per session → 1.2M × 20 × 10 / 10,800 ≈ 22,000 read requests/s at dinner, more at the peak minute.
- 300,000 riders online at peak, pinging every 4 seconds → 75,000 location writes/s.
- Each ping ~100 bytes → 7.5 MB/s; over a day with ~12 active hours on average, roughly 300 GB/day of raw location history. Hot store only needs the latest point: 300K × 100 B = 30 MB. Tiny.
- Active orders being tracked at peak: if an order lasts ~35 min, 350/s × 2,100 s ≈ 735,000 concurrent tracked orders.
The insight: orders are a small, high-value write stream; location is a large, low-value write stream; browsing is a large read stream. They need different storage and scaling strategies, which is the backbone of the architecture.
APIs
# Customer
GET /v1/feed?lat=12.9716&lng=77.5946&cursor=... → restaurants serviceable here
GET /v1/search?q=biryani&lat=..&lng=..&veg=true
GET /v1/restaurants/{id}/menu
POST /v1/cart/quote { restaurant_id, items[], address_id } → price, fees, ETA, quote_id
POST /v1/orders { quote_id, payment_method } Idempotency-Key: ...
GET /v1/orders/{id}
WS /v1/orders/{id}/track → stream of {status, rider_lat, rider_lng, eta_min}
# Restaurant
POST /v1/restaurant/orders/{id}/accept { prep_time_min: 15 }
POST /v1/restaurant/orders/{id}/ready
# Delivery partner
POST /v1/rider/location { lat, lng, speed, heading, ts, battery } (batched)
POST /v1/rider/offers/{offer_id}/accept
POST /v1/rider/orders/{id}/picked_up
POST /v1/rider/orders/{id}/delivered { otp }
The quote step matters: price, delivery fee and ETA are computed once, given a short-lived quote_id, and the order is placed against that quote. This prevents the "fee changed between cart and checkout" race and gives you a natural place to apply surge pricing.
High-Level Architecture
Each box maps to one of the three streams from the estimates: Discovery handles reads, Order Service handles the transactional writes, and Location Ingestion plus Tracking handle the GPS firehose. Kafka decouples them so a slow consumer (say, analytics) never blocks order placement.
Deep Dive: Restaurant Discovery and Search
Serviceability first, ranking second
A restaurant appears in your feed only if it is serviceable for your address right now: open, within its delivery radius or polygon, and the zone has enough riders. Serviceability is computed per geo-cell, not per user. Divide the city into cells (geohash precision 6 is about 1.2 km × 0.6 km, or H3 resolution 8 hexagons, roughly 0.7 km² each) and precompute, every minute or so:
serviceable:{cell_id} → [restaurant_id, base_eta_min, delivery_fee] (sorted)
Now the feed request is: map the user's lat/lng to a cell, fetch the precomputed list from cache, apply personalisation (past orders, cuisine affinity, offers) and return a page. This turns an expensive geo query into a cache lookup, which is the main reason browse scales. The geo-indexing options are compared in geohash vs quadtree vs S2/H3.
Search
Search for "paneer biryani" spans restaurant names, cuisines and dish names. Use Elasticsearch/OpenSearch with one document per restaurant containing nested or flattened dishes, plus a geo_shape for its delivery area:
{
"query": {
"bool": {
"must": [{ "multi_match": {
"query": "paneer biryani",
"fields": ["dish_names^3", "name^2", "cuisines"],
"fuzziness": "AUTO" } }],
"filter": [
{ "term": { "is_open": true } },
{ "term": { "is_veg": true } },
{ "geo_shape": { "delivery_area": {
"shape": { "type": "point", "coordinates": [77.5946, 12.9716] },
"relation": "contains" } } }
]
}
}
}
Note GeoJSON order is [lng, lat], a classic bug. Handle Indian-language and spelling variation ("biriyani", "biryani") with synonyms and fuzziness. Real-time fields like is_open and item availability change often; update them via partial updates from a Kafka stream, and re-check availability at quote time anyway, because the search index is eventually consistent.
Menu reads
Menus are read millions of times and changed rarely. Cache the full menu JSON per restaurant in Redis/CDN with a version number; restaurant edits bump the version. Item out-of-stock toggles are a small overlay fetched separately so you don't invalidate the whole menu at peak.
Deep Dive: The Order Lifecycle
The order is the system's contract with three parties, so model it as an explicit state machine owned by one service:
Note that "rider assigned" and "preparing" happen in parallel in reality; model rider assignment as a separate sub-state or a separate delivery entity linked to the order. Many teams split Order (what the customer bought) from Delivery/Trip (how it reaches them), which keeps reassignment of riders from touching order state.
Placing an order safely
- Validate the quote (not expired, items still available, restaurant still open).
- Create the order in
PLACEDwith the client's idempotency key under a unique constraint, so double-taps on "Place order" create one order. - Initiate payment. For UPI the result is asynchronous; the order waits in
PLACEDuntil a payment success event arrives (the payment flow is covered in payment system design). Cash on delivery skips straight to confirmed. - Notify the restaurant via a persistent connection to the restaurant tablet (and an IVR/phone fallback if not acknowledged within a minute or two).
- Restaurant accepts with a prep time; this is the most important input to dispatch.
Timeouts as first-class events
Every state has a deadline: restaurant must accept within N seconds, rider must accept an offer within ~15–30 seconds, rider must reach pickup by T. Use a delay queue or a timer service (see distributed job scheduler) that fires a "deadline" event; the order service checks whether the state already moved on and otherwise escalates (reassign, call the restaurant, cancel and refund).
Deep Dive: Delivery Partner Assignment
This is where you can stand out. The naive answer, "find the nearest free rider with a geo query", has three problems: the nearest rider might be better used for another order a minute later, the rider might arrive 15 minutes before the food is ready, and it ignores batching.
When to dispatch
Target rider arrival at restaurant ≈ food ready time. If the restaurant says 15 minutes and riders in the area are typically 6 minutes away, start looking around minute 7–9, not at minute 0. In practice platforms start somewhat earlier at peak because finding a rider takes time and offers get declined.
Batched matching
Every few seconds, per zone, the dispatcher takes the set of orders that are due for assignment and the set of candidate riders (free, or finishing a delivery nearby, or already carrying a compatible order), builds a cost matrix and solves an assignment.
cost(rider, order) =
w1 * max(0, rider_arrival_at_restaurant - food_ready_time) # food waits
+ w2 * max(0, food_ready_time - rider_arrival_at_restaurant) # rider waits
+ w3 * predicted_delivery_time_to_customer
+ w4 * (1 if rider would exceed shift / max orders else 0) * BIG
- w5 * batching_bonus(rider.current_order, order)
With N orders and M riders in a zone, the Hungarian algorithm solves the optimal one-to-one assignment in O(n³), fine for zones of a few hundred. For larger zones, or when batching makes it not one-to-one, a greedy approach over sorted (rider, order) pairs is a robust approximation. Here is a compact, runnable greedy version:
import heapq
def assign(orders, riders, travel_min, now_min):
"""orders: {oid: ready_at_min}; riders: list of rider ids;
travel_min(rid, oid) -> minutes for rider to reach the order's restaurant."""
heap = []
for oid, ready_at in orders.items():
for rid in riders:
arrive = now_min + travel_min(rid, oid)
food_waits = max(0, arrive - ready_at)
rider_waits = max(0, ready_at - arrive)
cost = 3.0 * food_waits + 1.0 * rider_waits
heapq.heappush(heap, (cost, oid, rid))
assigned_o, assigned_r, result = set(), set(), {}
while heap and len(result) < min(len(orders), len(riders)):
cost, oid, rid = heapq.heappop(heap)
if oid in assigned_o or rid in assigned_r:
continue
result[oid] = rid
assigned_o.add(oid); assigned_r.add(rid)
return result
# Dry run
dist = {("r1","o1"): 4, ("r1","o2"): 9, ("r2","o1"): 3, ("r2","o2"): 5}
print(assign({"o1": 10, "o2": 6}, ["r1","r2"],
lambda r, o: dist[(r, o)], now_min=0))
# {'o2': 'r2', 'o1': 'r1'}
Dry run: o2 is ready at minute 6, o1 at minute 10. Pair costs: (r2,o2) arrives at 5, rider waits 1 → cost 1. (r1,o1) arrives at 4, rider waits 6 → cost 6. (r2,o1) arrives 3, waits 7 → 7. (r1,o2) arrives 9, food waits 3 → 9. Greedy picks (r2,o2) then (r1,o1). Nearest-rider-first would have given o1 to r2 (3 minutes away) and left o2's food waiting 3 minutes for r1. Batched matching avoided that.
Offers and concurrency
- An assignment becomes an offer pushed to the rider app. The rider accepts or it times out; on decline/timeout the order returns to the next matching round with that rider excluded.
- A rider must never hold two conflicting offers. Lock rider state with an atomic compare-and-set in Redis (
SET rider:{id}:offer {order} NX EX 30) before sending an offer. - Run one dispatcher instance per zone (partition zones across instances, e.g. via Kafka partitions keyed by zone) so matching within a zone is single-threaded and lock-free. Zone boundaries cause edge effects; allow riders near a boundary to be candidates in both and resolve with the CAS lock.
Batching (stacked orders)
Two orders from the same restaurant, or from restaurants a few hundred metres apart, going in the same direction can share a rider. Batch only when the added delay for the first customer is under a threshold (a few minutes) and the food can still arrive hot. Batching is an enormous efficiency lever at peak when riders are the bottleneck.
Compare this with immediate dispatch in Uber/Ola system design; the location index is the same, the timing problem is not.
Deep Dive: Live Order Tracking
Ingestion
The rider app collects GPS every 2–4 seconds and sends pings in small batches (every 4–10 seconds) to save battery and data, over HTTP/2 or a persistent connection. Ingestion is stateless: validate, drop obviously bad points (accuracy > 100 m, impossible speed), and:
- Write the latest position to Redis:
GEOADD riders:{city} lng lat rider_idplus a hash with heading, speed, timestamp and status. This is the index dispatch queries. - Publish the ping to Kafka topic
rider-locations, partitioned by rider ID, for tracking fan-out, ETA updates and archival to a cheap store (S3/columnar) for model training and dispute resolution.
Expire stale riders: if no ping in 60 seconds, mark them as unreachable for dispatch rather than letting a phone that died in a basement receive offers.
Fan-out to customers
Only riders with an active order have watchers, and usually one watcher (the customer, sometimes also a support agent). The tracking service consumes rider-locations, looks up rider → active order → subscribed connections, and pushes an update. A customer connects with a WebSocket or SSE to any tracking gateway node; the gateway registers order_id → gateway_node in Redis so the tracking consumer can route messages to the right node (or use Redis pub/sub channels per order). Trade-offs between transports are discussed in WebSockets vs SSE vs long polling; for a one-way stream, SSE is enough and simpler through proxies.
Throttle updates per watcher to one every 3–5 seconds and let the client interpolate the marker along the route polyline. Users perceive smooth movement; you send 5x fewer messages. When the app is backgrounded, drop the stream and fall back to push notifications at state changes only.
Map matching
Raw GPS in dense Indian cities jumps across buildings and flyovers. Snap points to the road network (map matching, often a hidden Markov model over candidate road segments) before showing them, or at least smooth them. This also improves ETA, since you know which road the rider is on.
Deep Dive: ETA Prediction
The ETA shown in the feed, at checkout and during tracking is the product's most visible promise. Decompose it:
| Component | Key features | Notes |
|---|---|---|
| Food preparation time | Restaurant, dishes, current open orders at the restaurant, time of day, historical accuracy of the restaurant's own estimates | Restaurants' self-reported prep times are noisy; learn a correction per restaurant |
| Rider to restaurant | Rider availability in the zone, distance, road travel time | At peak, include expected time to find a rider |
| Wait at pickup | Restaurant's historical handover delay, parking, mall vs street-front | Malls and food courts add minutes |
| Last mile | Route distance, traffic for the hour, weather, building type (gated society, high-rise) | Gated societies add walk and security time |
Models are typically gradient-boosted trees or neural nets trained on historical orders, with predictions served from a low-latency feature store. Two engineering points interviewers like:
- Predict distributions, show a conservative quantile. Showing the median means half the orders are late; companies tune which percentile to show to balance conversion against lateness.
- Recompute ETA on events (restaurant accepted, food ready, picked up) and periodically from location, but smooth what you show so the number doesn't jump around. Users hate "25 min" becoming "38 min" without explanation.
Road travel time itself comes from a routing engine over the road graph with time-dependent edge weights, as in Google Maps system design.
Surviving the 8 PM Dinner Peak
Unlike a flash sale, the dinner peak happens every day and is highly predictable. That changes the playbook from "react" to "prepare":
- Scheduled pre-scaling. Scale stateless services (API, discovery, tracking gateways) ahead of 7 PM based on forecast, not just CPU autoscaling, which lags sudden ramps.
- Cache the feed per geo-cell with short TTLs (30–60 s). 22K reads/s become a few thousand cache keys per city.
- Bulkhead the critical path. Separate pools and databases for checkout/order/payment versus browse/recommendations. If recommendations time out, show a non-personalised list; checkout keeps working.
- Serviceability as load shedding. When the rider supply in a zone drops below demand, shrink the delivery radius, raise delivery fees, show longer ETAs, or temporarily mark distant restaurants unserviceable. This sheds load at the business level, which is far better than letting orders in and delivering them cold in 90 minutes.
- Shard orders by city/zone so a hot city (Bengaluru on a Friday) cannot affect others, and dispatch is naturally partitioned.
- Protect restaurants. A restaurant with 30 open orders will be late. Throttle its visibility or extend its prep time automatically.
- Weather events. Rain in Mumbai spikes demand and cuts rider supply at the same time. Feed weather into ETA and serviceability, and pre-announce surge fees.
Data Stores Summary
| Data | Store | Why |
|---|---|---|
| Orders, payments, refunds | Sharded PostgreSQL/MySQL (by city or order ID) | Transactions, state machine updates with conditions, idempotency |
| Restaurants, menus (source of truth) | SQL | Relational, low write rate |
| Search index | Elasticsearch/OpenSearch | Full text, fuzzy, geo-shape filters |
| Feed per cell, menus, quotes | Redis / CDN | Hot reads, short TTLs |
| Rider latest location and state | Redis GEO + hashes, sharded by city | 75K writes/s, radius queries |
| Location history | Kafka → object storage / Cassandra | Append-heavy, analytics and disputes |
| Events between services | Kafka | Decoupling, replay |
| ETA features | Feature store (online Redis + offline warehouse) | Low-latency model inference |
Interview Tips and Common Mistakes
How to pace a 45-minute round
- 5 min requirements; explicitly name the three sides and the three data streams.
- 5 min estimates; highlight that location writes dominate.
- 10 min architecture and order state machine.
- 15–20 min on one or two deep dives the interviewer picks: assignment, tracking or ETA. Offer them; don't wait to be asked.
- 5 min on peak handling and failure modes.
Follow-up questions to expect
- "A rider's phone dies mid-delivery. What happens?" Pings stop; after a timeout, flag the trip, attempt to call the rider, and if unreachable either reassign (if not picked up) or escalate to support with the last known location.
- "Restaurant rejects after payment." Order to
CANCELLED, automatic refund via an idempotent refund request, suggest similar restaurants. - "How do you avoid two riders getting the same order?" Order-level assignment is a conditional update (
WHERE rider_id IS NULL) and rider-level offers use CAS locks. - "How would you A/B test a new dispatch algorithm?" Randomising per order contaminates the shared rider pool; use switchback experiments (alternate algorithm by zone and time window).
Mistakes I see in mock interviews
- Spending 15 minutes on menu CRUD and user login.
- Assigning the nearest rider immediately at order time, ignoring prep time.
- Storing every GPS ping in the orders database.
- Using a relational geo query per feed request instead of precomputing serviceability per cell.
- No mention of timeouts in the order lifecycle (restaurant never responds, rider never accepts).
- Showing a single ETA number with no discussion of how it is predicted or updated.
Get the three streams, the order state machine and a batched, prep-time-aware dispatcher right, and the rest of the design follows. For more location-heavy practice, try designing a proximity service next.
Frequently Asked Questions
Crack System Design at Swiggy, Zomato & Flipkart
Prepflix's system design program drills marketplace problems like food delivery with live mock interviews and detailed feedback.
Explore the Prepflix Course →