Limited time: AI code review, hints, mock interviews, whiteboard analysis, and all Pro features are unlocked. Enroll
⏱️ 38 min read

Designing a Food Delivery Service Like Zomato / Uber Eats / DoorDash

Difficulty: Intermediate–Advanced Prerequisites:Geospatial Indexing, Message Queues, and WebSockets


TL;DR

A food delivery platform connects customers, restaurants, and riders. The system handles search (Elasticsearch), ordering (Postgres), live rider tracking (Redis Geo + WebSocket), and dispatch matching (Temporal workflow).

πŸ’‘ Elasticsearch is a search engine optimized for full-text search, filtering, and faceted queries. It indexes data in inverted indexes for sub-100ms search across millions of documents.

flowchart LR
    CUST["Customer App"]:::client
    RIDER["Rider App"]:::client
    SEARCH["Search<br/>Elasticsearch"]:::service
    ORDER["Order Service"]:::service
    LOC["Location<br/>Redis Geo"]:::data
    MATCH["Dispatch<br/>Temporal"]:::service
    WS["WebSocket<br/>live tracking"]:::service

    CUST -->|"1. Search restaurants"| SEARCH
    CUST -->|"2. Place food order"| ORDER
    RIDER -->|"3. Read cache"| LOC
    ORDER -->|"4. Assign rider"| MATCH
    MATCH -->|"5. Write rider assignment"| LOC
    LOC -->|"6. Stream rider position"| WS
    WS -->|"7. Deliver"| CUST

    classDef client fill:#4c3a5e,stroke:#818cf8,color:#e2e8f0
    classDef service fill:#1a3a2a,stroke:#4ade80,color:#e2e8f0
    classDef data fill:#3b3520,stroke:#fbbf24,color:#e2e8f0
Color Layer
🟠 Orange Clients
πŸ”΅ Blue Edge
🟒 Green Services
🟣 Purple Async / Streaming
🟑 Yellow Data
🩷 Pink External

In 3 sentences: Customer searches for restaurants (Elasticsearch with geo + relevance scoring), places an order (Postgres with idempotency), and the system finds a nearby rider (Redis Geo for proximity, Temporal for the multi-step dispatch workflow). The rider’s live location streams to the customer via WebSocket. Each component is independently scalable.

πŸ’‘ WebSocket is a persistent two-way connection. Unlike HTTP (ask β†’ answer β†’ done), WebSocket stays open so the server can push updates instantly. Learn more β†’


Understanding the Problem

πŸ” What is Zomato? Zomato (known as Uber Eats or DoorDash in the US) is an on-demand food delivery platform that connects customers with nearby restaurants. Customers browse menus, place orders, pay, and watch their food travel from the restaurant to their door via a delivery partner.


Prior Art We’re Drawing From


Functional Requirements

Core Requirements

  1. Customers should be able to search for nearby restaurants and browse their menus.
  2. Customers should be able to place an order and pay for it.
  3. The system should dispatch the order to an available delivery partner and let the customer track it in real time.

Below the line (out of scope)

Non-Functional Requirements

Core Requirements

Below the line

Scale Estimation (Back-of-Envelope)


Core Entities


API / System Interface

GET  /v1/restaurants?lat=<>&lng=<>&q=<>        β†’ Restaurant[]
GET  /v1/restaurants/:id/menu                  β†’ Menu
POST /v1/orders                                β†’ Order
     Body: { restaurantId, items, addressId, paymentMethod }
     Header: Idempotency-Key: <uuid>
POST /v1/riders/location                       β†’ 200
     Body: { lat, lng }
     (riderId comes from JWT, never trust the body)
PATCH /v1/rides/:rideId                        β†’ Ride
     Body: { accept | decline }
GET  /v1/orders/:id/track                      β†’ WebSocket stream

Security: every endpoint requires a JWT. customerId, riderId, and server-side amounts must never be taken from the client body.


High-Level Design

Let’s build up the design by walking through each functional requirement.

1) Customers can search restaurants and browse menus

Customer opens the app β†’ hits our backend through an API Gateway β†’ a Restaurant Service returns a list of nearby restaurants filtered by location, cuisine, and availability.

New components we need:

  1. API Gateway - authenticates users, applies rate limits, and routes to the right service. Every request passes through here first.
  2. Restaurant Service - handles restaurant listings and menus. Reads from the database and returns results filtered by location and availability.
  3. Restaurants and Menus DB - stores restaurant info, operating hours, and menu items. The source of truth for catalog data.
flowchart LR
    CUST["Customer App"]:::client
    GW["API Gateway"]:::edge
    RESTSVC["Restaurant Service"]:::service
    DB[("Restaurants and Menus DB")]:::data

    CUST -->|"1. Browse restaurants"| GW
    GW -->|"2. Forward to restaurant svc"| RESTSVC
    RESTSVC -->|"3. Fetch restaurant data"| DB

    classDef client fill:#4c3a5e,stroke:#818cf8,color:#e2e8f0
    classDef edge fill:#1e3a5f,stroke:#60a5fa,color:#e2e8f0
    classDef service fill:#1a3a2a,stroke:#4ade80,color:#e2e8f0
    classDef data fill:#3b3520,stroke:#fbbf24,color:#e2e8f0

Color Legend

Step-by-step flow:

  1. Customer opens the app and types β€œbiryani” β†’ app calls GET /v1/restaurants?lat=12.9&lng=77.6&q=biryani
  2. API Gateway checks: valid JWT? Within rate limits? Good β†’ forwards to Restaurant Service
  3. Restaurant Service queries the database for restaurants near the customer’s coordinates that serve biryani and are currently open
  4. Customer picks β€œHyderabad House” and taps it β†’ app calls GET /v1/restaurants/:id/menu β†’ same service returns the full menu with prices and availability

The naive version works for now - just a DB query. But at Zomato scale (500K restaurants, 50K search QPS), a raw database query melts. We’ll evolve this into an Elasticsearch-powered search in the deep dives.

2) Customers can place and pay for an order

When the customer confirms a cart, we need to create an order, charge them, and move on to dispatch. We add an Order Service and a Payment Service.

New components we need (in addition to the ones above):

  1. Order Service - the order lifecycle manager. Creates orders, validates carts, computes totals, and manages the order state machine from CREATED β†’ DELIVERED.
    πŸ’‘ This service is the single source of truth for β€œwhat’s happening with my order?” - every state change goes through it.
  2. Payment Service - handles charging the customer. Wraps the payment gateway and manages the authorization + capture flow.
  3. Orders DB (Postgres) - stores order state with strong consistency. We use Postgres because money is involved - ACID transactions prevent double-charges and lost orders.
  4. Payment Gateway (Razorpay, Stripe, UPI) - the external service that actually moves money. We don’t process cards ourselves - that would require PCI compliance.
    πŸ’‘ The gateway is a β€œtrusted intermediary” between us and banks.
flowchart LR
    CUST["Customer App"]:::client
    GW["API Gateway"]:::edge
    ORDER["Order Service"]:::service
    PAY["Payment Service"]:::service
    DB[("Orders DB")]:::data
    PG["Payment Gateway<br/>Razorpay Stripe UPI"]:::external

    CUST -->|"1. Place food order"| GW
    GW -->|"2. Forward to order svc"| ORDER
    ORDER -->|"3. Persist order record"| DB
    ORDER -->|"4. Initiate payment"| PAY
    PAY -->|"5. Charge via gateway"| PG

    classDef client fill:#4c3a5e,stroke:#818cf8,color:#e2e8f0
    classDef edge fill:#1e3a5f,stroke:#60a5fa,color:#e2e8f0
    classDef service fill:#1a3a2a,stroke:#4ade80,color:#e2e8f0
    classDef data fill:#3b3520,stroke:#fbbf24,color:#e2e8f0
    classDef external fill:#4a1942,stroke:#f472b6,color:#e2e8f0

Step-by-step flow:

  1. Customer confirms their cart β†’ app calls POST /v1/orders with an Idempotency-Key (so accidental retries don’t create double orders)
  2. Order Service validates: Are all items still available? Is the restaurant still open? It computes the authoritative total itself - never trust the client’s amount (clients can be tampered with)
  3. Order Service calls Payment Service β†’ which authorizes the charge through Razorpay/Stripe/UPI
  4. On payment success β†’ Order Service writes the order with status CONFIRMED to Postgres (one atomic transaction: order + payment record)
  5. Customer sees β€œOrder Confirmed! πŸŽ‰β€ - now the dispatch flow kicks in

Why the idempotency key? Indian mobile networks are flaky. The user’s phone might retry the request when it loses signal for a second. Without an idempotency key, they’d get charged twice. With it, the second request returns the same response as the first - safe retries for free.

3) Match a rider and let the customer track the delivery

Now we introduce a Rider Client, a Location Service that receives live GPS pings, and a Ride Matching Service that picks a rider for each confirmed order. We also need a push channel so the rider gets notified immediately.

New components we need (in addition to the ones above):

  1. Location Service - ingests live GPS pings from rider phones (every 3-5 seconds) and stores them. The β€œwhere is everyone right now?” service.
  2. Location Store (Redis Geo) - holds live rider positions in memory, sharded by city.
    πŸ’‘ Redis Geo uses geohashing under the hood - it can answer β€œfind all riders within 3km of this restaurant” in microseconds across 200K riders. Learn more β†’
  3. Ride Matching Service - finds the best available rider for a confirmed order. Queries nearby riders, scores them, and sends an offer.
  4. Notification Service - pushes the ride offer to the rider’s phone via FCM/APNs. Also notifies the customer about order updates.
  5. FCM / APNs - Firebase Cloud Messaging and Apple Push Notification service. External services that deliver push notifications to rider phones, even when the app is backgrounded.
flowchart TD
    CUST["Customer App"]:::client
    RIDER["Rider App"]:::client
    GW["API Gateway"]:::edge
    ORDER["Order Service"]:::service
    MATCH["Ride Matching Service"]:::service
    LOC["Location Service"]:::service
    NOTIF["Notification Service"]:::service
    LOCDB[("Location Store")]:::data
    ORDERDB[("Orders DB")]:::data
    PUSH["FCM and APNs"]:::external

    RIDER -->|"1. Location pings"| LOC
    LOC -->|"2. Query nearby riders"| LOCDB
    CUST -->|"3. Place order"| GW
    GW -->|"4. Forward to order svc"| ORDER
    ORDER -->|"5. Persist order record"| ORDERDB
    ORDER -->|"6. Match request"| MATCH
    MATCH -->|"7. Find nearest rider"| LOCDB
    MATCH -->|"8. Notify selected rider"| NOTIF
    NOTIF -->|"9. Push via FCM"| PUSH
    PUSH -->|"10. Notify rider"| RIDER
    RIDER -->|"11. Accept"| GW

    classDef client fill:#4c3a5e,stroke:#818cf8,color:#e2e8f0
    classDef edge fill:#1e3a5f,stroke:#60a5fa,color:#e2e8f0
    classDef service fill:#1a3a2a,stroke:#4ade80,color:#e2e8f0
    classDef data fill:#3b3520,stroke:#fbbf24,color:#e2e8f0
    classDef external fill:#4a1942,stroke:#f472b6,color:#e2e8f0

Step-by-step flow:

  1. Riders’ phones continuously stream GPS pings to the Location Service (adaptive: 30s when parked, 3-5s when moving). Location Service writes each ping to Redis Geo, keyed by city
  2. Once the order hits CONFIRMED, the Order Service fires a β€œfind me a rider” request to the Ride Matching Service
  3. Matching Service queries Redis Geo: β€œWho’s within 3km of Hyderabad House and currently available?” β†’ gets a ranked list of candidates
  4. Matching picks the best rider (closest + high acceptance rate + estimated pickup time) and sends them an offer via the Notification Service β†’ rider’s phone buzzes with β€œNew delivery: Hyderabad House β†’ 2.1km away”
  5. Rider taps β€œAccept” β†’ PATCH request comes in β†’ Order Service updates state to RIDER_ASSIGNED
  6. Customer’s app opens a WebSocket connection for live tracking β†’ sees the rider’s blue dot moving toward the restaurant on a map

Why Redis Geo instead of a regular database? 200K riders Γ— 1 ping every 4 seconds = 50K writes/sec. A traditional database would collapse under this write volume. Redis keeps everything in memory - writes are sub-millisecond - and its GEOSEARCH command answers β€œriders within 3km” in microseconds. Perfect for a read pattern that happens on every single order.


Technology Choices

Tier Purpose Primary Pick Alternatives Why Primary Wins Here
Search Index Restaurant discovery with geo + relevance Elasticsearch Algolia, Meilisearch, Typesense Geo-distance scoring + full-text + facets (cuisine, rating, delivery time) in one query
Order DB Order state machine + payment records Postgres CockroachDB, MySQL, Spanner ACID for payment consistency; order volume fits sharded Postgres
Location Store Real-time rider GPS coordinates Redis Geo PostGIS, ElastiCache, DynamoDB Sub-ms GEORADIUS queries; TTL auto-expires stale rider positions
Dispatch Workflow Multi-step rider assignment orchestration Temporal Cadence, Step Functions, custom state machine Handles timeouts, retries, and human-in-loop (rider acceptance) as durable workflow
Event Bus Order events, location streams, notifications Kafka Kinesis, Pulsar, RabbitMQ Ordered per-order-id partition; replay for failed consumers
Real-time Push Live tracking to customer app WebSocket SSE, Long Polling, gRPC stream Bi-directional for tracking + rider chat; persistent connection during order lifecycle
CDN Menu images, restaurant photos CloudFront Cloudflare, Fastly, Akamai High cache-hit for static menu assets; edge compression for mobile

Why Redis Geo over PostGIS for rider location? 200K riders pinging every second needs 200K writes/sec. Redis Geo handles this in-memory with O(log N) for GEOADD and GEORADIUS. PostGIS would require disk I/O per write and can’t match the throughput for ephemeral location data that expires in seconds.


Data Modeling

Postgres (Order DB β€” order lifecycle):

CREATE TABLE orders (
    order_id UUID PRIMARY KEY,
    customer_id UUID NOT NULL,
    restaurant_id UUID NOT NULL,
    rider_id UUID,
    status VARCHAR(20) NOT NULL,  -- CREATED, CONFIRMED, ASSIGNED, READY, PICKED_UP, DELIVERED, CANCELLED
    items JSONB NOT NULL,
    total_amount DECIMAL(10,2) NOT NULL,
    delivery_address JSONB NOT NULL,
    payment_method VARCHAR(20),
    payment_status VARCHAR(15),
    idempotency_key UUID UNIQUE,
    placed_at TIMESTAMP NOT NULL,
    delivered_at TIMESTAMP
);
CREATE INDEX idx_orders_customer ON orders(customer_id, placed_at DESC);
CREATE INDEX idx_orders_restaurant ON orders(restaurant_id, status);
CREATE INDEX idx_orders_rider ON orders(rider_id, status);

-- A rider can hold at most one in-flight order. This is the invariant that
-- stops double-booking; see Deep Dive 2.
CREATE UNIQUE INDEX one_active_order_per_rider ON orders (rider_id)
WHERE status IN ('ASSIGNED', 'READY', 'PICKED_UP');

Redis Geo (Location Store β€” real-time rider positions):

Key: "riders:active:{city}" β†’ Geo Set (member = riderId, lat/lng as geohash score)
Key: "rider:status:{riderId}" β†’ Hash { status, orderId, lastPing, heading }
TTL: 30s on status key (auto-expires stale riders)

Elasticsearch (Search Index β€” restaurant discovery):

Index: restaurants
  Fields: name (text), cuisines (keyword[]), rating (float), delivery_time_min (int),
          price_range (int), is_open (boolean), location (geo_point), city (keyword)
  Scoring: function_score combining relevance + geo_distance + rating boost

Access Patterns:

Query Data Source How
Search restaurants near me Elasticsearch geo_distance filter + function_score (relevance Γ— proximity Γ— rating)
Place order Postgres INSERT INTO orders with status=PLACED, trigger Temporal workflow
Find nearest rider Redis Geo GEOSEARCH riders:active:{city} FROMLONLAT lng lat BYRADIUS 5 km COUNT 10 ASC
Update rider location Redis Geo GEOADD riders:active:{city} lng lat riderId every 3-5s
Track order live WebSocket + Redis Pub/Sub Subscribe to ride:track:{orderId}, rider publishes location updates

How Rider Dispatch Works (Temporal Workflow):

  1. Order confirmed β†’ Temporal starts a dispatch_workflow with 5-minute timeout
  2. Workflow calls GEOSEARCH riders:active:{city} to find 5 nearest available riders
  3. Sends ride offer to top candidate via WebSocket. Starts 30s accept timer.
  4. If accepted: assign rider to order, update Postgres, publish event. Workflow completes.
  5. If declined/timeout: try next candidate. After 5 candidates or 5 minutes: escalate (expand radius, boost payout)
  6. Temporal handles crash recovery: if worker dies mid-dispatch, another picks up from the last checkpoint

Deep Dives

Deep Dive 1 - How do we handle 200K riders pinging their location every few seconds?

In simple terms: 200K delivery drivers are sending their GPS location every 4 seconds. That’s 50K writes per second of tiny geo records. A normal database can’t handle this volume.

Problem. 200K riders Γ— 1 ping / 4s β‰ˆ 50K writes/sec of tiny geo records. Standard databases (PostgreSQL, DynamoDB) would either fall over on write volume or cost a fortune. And we also need to answer β€œgive me riders within 3 km of this point” fast - a lat/lng scan of millions of rows is a non-starter.

Bad - store every ping in PostgreSQL with a B-tree on (lat, lng). Writes are O(log N) per insert and proximity queries require a full scan or a bounding-box lookup that’s still O(N) in the worst case. B-trees don’t understand two-dimensional data. Breaks under real load.

Good - use PostGIS or a geospatial index. PostGIS adds R-tree / GiST indexes tuned for 2D queries. ST_DWithin(point, rider_location, 3000) gives you nearby riders in log N. Handles the read side, but writes at 50K/sec still hammer the DB and each write costs multiple disk IOs.

Great - use Redis Geo for live positions, partitioned by city. Redis Geo (backed by sorted sets under the hood with geohash scoring) stores rider positions entirely in memory:

Sharding by city_id keeps each Redis shard small (say 10K riders) and evenly distributed. Riders don’t cross city boundaries often.

Freshness over durability is the right tradeoff here - if Redis loses a few seconds of pings, the rider just shows up again on the next ping. We still tee every ping to Kafka for a durable history stream consumed by analytics and fraud detection, not by the matching hot path.

πŸ’‘ Kafka is a distributed event log. Producers append events, consumers read at their own pace. Perfect for decoupling services that produce data from those that consume it. Learn more β†’

Client-side optimization matters too. Instead of dumb 5s intervals, the rider app can adapt:

This alone cuts traffic by 60-70%.

flowchart LR
    RIDER["Rider App<br/>adaptive pings"]:::client
    LOC["Location Service"]:::service
    GEO[("Redis Geo<br/>sharded by city")]:::data
    K["Kafka<br/>rider location stream"]:::async
    DW[("Cassandra<br/>history")]:::data

    RIDER -->|"1. PUT GPS location"| LOC
    LOC -->|"2. GEOADD to Redis Geo"| GEO
    LOC -->|"3. Publish location event"| K
    K -->|"4. Archive to history"| DW

    classDef client fill:#4c3a5e,stroke:#818cf8,color:#e2e8f0
    classDef service fill:#1a3a2a,stroke:#4ade80,color:#e2e8f0
    classDef async fill:#AB47BC,stroke:#4A148C,color:#fff
    classDef data fill:#3b3520,stroke:#fbbf24,color:#e2e8f0

Deep Dive 2 - How do we make sure one rider isn’t offered the same order twice, and one order isn’t offered to two riders at the same time?

Problem. Matching is a race, and it’s really two races that need two different answers:

These look like the same problem but they guard different rows, so a single mechanism won’t cover both. Getting this distinction wrong is the most common mistake here.

In simple terms: One order might get accepted by two drivers - that needs a conditional write on the order row. Separately, two orders in the same area might both grab the driver they each scored as the β€˜best match’ - that needs a lock on the driver. Same-looking bug, two different fixes.

Bad - optimistically send to the top candidate, hope for the best. Under high concurrency this produces double-offers. Riders get confused, customers get delayed.

Good - lock the rider with a Redis SET NX PX. Before sending an offer, try SET offer:rider:{rider_id} <orderId> NX PX 10000.

This is the same β€œreserve a ticket at checkout” pattern from Ticketmaster. But notice what the key is: the rider. This lock stops one rider from holding two live offers. It says nothing at all about the order.

Great - put each invariant in the store that owns the data.

The order-side race belongs in Postgres, which is the source of truth for the assignment. On accept, the Order Service runs a conditional update:

UPDATE orders SET rider_id = $1, status = 'ASSIGNED'
WHERE order_id = $2 AND status = 'CONFIRMED' AND rider_id IS NULL;

πŸ’‘ This is a compare-and-set (CAS) - the write only lands if the row still looks the way you expected it to. Two simultaneous accepts serialize on the row lock. One updates 1 row and wins; the other gets 0 rows back and learns the order is already taken. This, not the Redis lock, is what makes β€œone order β†’ one rider” correct.

The rider-side race needs its own guard, because the CAS above structurally cannot see it: order A and order B are different rows, so both pass that WHERE clause and both happily assign the same rider. Enforce it where the uniqueness actually lives:

CREATE UNIQUE INDEX one_active_order_per_rider ON orders (rider_id)
WHERE status IN ('ASSIGNED', 'READY', 'PICKED_UP');

The second writer violates the constraint and rolls back.

So what is the Redis lock for? It’s an optimization, not the correctness boundary - it stops us burning offers on a rider who already has one pending, and it gives us a cheap 10-second accept window without touching Postgres on every candidate. Keep it, but don’t lean on it for correctness.

One more case it does handle well: a rider taps accept after the TTL lapsed and someone else took the slot. Store the offer id as the lock value (SET offer:rider:r1 "orderA:42" NX PX 10000), have the accept request carry it, and run a Lua compare-and-delete that honors the accept only if the value still matches. Late accepts are rejected cleanly.

πŸ’‘ A true fencing token is stronger than that check. It’s a monotonically increasing number enforced by the resource being written to, which rejects any write carrying a token lower than the highest it has seen. Our compare-and-delete runs in Redis - the same store holding the lease - so it cannot stop a Dispatch worker that stalled past its TTL and then wrote to Postgres anyway. The CAS catches that, which is the whole lesson of this deep dive: an invariant is only safe in the store that owns the data.


Deep Dive 3 - Real-time updates for the customer tracking the order

Problem. The customer’s map needs to show the rider moving smoothly. We can’t have the app hammer the server with polling queries - at 20 million DAU, that’s catastrophic.

In simple terms: The customer wants to see their delivery driver moving on the map in real-time. Polling the server every 2 seconds for 20M users = catastrophic load. We need push updates.

Bad - short polling every 3 seconds. Easy to implement but means millions of wasted requests for orders that aren’t moving, and 3-second lag feels laggy.

Good - long polling or server-sent events (SSE). SSE gives one-way server β†’ client push over a long-lived HTTP connection. Works well for pure read streams like live tracking. But iOS Safari support is flaky for SSE, and you lose the persistent connection during app backgrounding.

Great - WebSocket from client to a WebSocket Gateway, Redis Pub/Sub behind it.

Consistent hashing at the edge routes all subscribers for one order to the same gateway pod, keeping Pub/Sub fan-out local. Supports millions of concurrent connections with a few hundred pods.


Deep Dive 4 - What if no rider accepts? What if the gateway loses the accept?

Problem. This is a multi-step human-in-the-loop workflow. Any step can fail: rider doesn’t respond, app crashes, phone loses signal, offer times out. We need to move on to the next rider automatically and never strand an order.

In simple terms: We sent a delivery offer to a rider. They didn’t respond (phone died, went to bathroom). We need to automatically move to the next rider after a timeout without losing the order.

Bad - best-effort timers in the matching service. If the matching service pod restarts mid-offer, the state is lost and the order hangs. Customer waits forever.

Good - persist the matching state in a database and poll it with a worker. Matching state lives in a DB, a background worker looks for expired offers and triggers reassignment. Works but you’re hand-rolling a workflow engine and retry logic, which is always more subtle than it looks.

Great - use a durable workflow engine like Temporal (Cadence). The whole matching workflow is expressed as a Temporal workflow: β€œoffer to top rider, wait 10s, if no accept, offer to next rider, repeat up to N times, then emit DispatchFailed.” Temporal persists every step’s state to its own storage. If any worker crashes, another worker picks up the exact same workflow at the exact same step - no custom retry code needed.

Uber themselves open-sourced Cadence for exactly this reason; food-delivery dispatch is the same class of problem.


Deep Dive 5 - How do we search across 500K restaurants with text, filters, and ranking?

Problem. Zomato isn’t just β€œtap the nearest pin” - customers type β€œbiryani,” filter by β€œpure veg, rating 4+, under β‚Ή300,” and expect relevant results in under 300ms. At 50K search QPS peak across a catalog of 500K restaurants with nested menu items, the requirements are: text search, faceted filters, geo constraint, custom ranking, and personalization, all at low latency.

In simple terms: Customers type β€˜biryani’ and expect results in under 300ms, filtered by location, rating, price, and dietary preferences. A regular SQL query can’t handle this complexity at speed.

Bad - WHERE name ILIKE '%biryani%' AND is_open = true on Postgres. Full table scans. No relevance ranking. No typo tolerance - β€œbiriyani” returns nothing. Facet counts (how many veg results? how many 4+ rated?) require a separate aggregation query per facet. Falls apart past 50 QPS.

Good - Postgres full-text search with tsvector + PostGIS for geo. Adds a GIN-indexed tsvector column, handles stemming and basic relevance. Works for small catalogs. But:

Great - Elasticsearch as a dedicated search index, fed by CDC. Elasticsearch is purpose-built for exactly this workload:

Index structure - one document per restaurant with denormalized menu highlights:

{
  "restaurant_id": "r_123",
  "name": "Pizza Hub",
  "name_ngram": "piz pizz pizza pizz hub",
  "cuisines": ["italian", "pizza"],
  "location": {"lat": 12.93, "lon": 77.61},
  "rating": 4.3,
  "price_range": 2,
  "is_open": true,
  "popular_items": ["margherita pizza", "garlic bread"],
  "popularity_score": 0.87
}

A query blends relevance + distance + rating in one shot:

{
  "query": {
    "bool": {
      "filter": [
        { "geo_distance": { "distance": "5km", "location": { "lat": 12.9, "lon": 77.6 } } },
        { "term": { "is_open": true } },
        { "range": { "rating": { "gte": 4 } } }
      ],
      "must": [{ "match": { "name_ngram": "biryani" } }]
    }
  },
  "functions": [
    { "gauss": { "location": { "origin": "...", "scale": "2km" } } },
    { "field_value_factor": { "field": "popularity_score", "modifier": "sqrt" } }
  ],
  "score_mode": "sum"
}

Keeping the index in sync. Restaurants and menus live in the primary DB (Postgres or Mongo). We don’t dual-write - that leads to drift. Instead, Debezium tails the DB’s change log and publishes to a Kafka topic; a Kafka consumer materializes the search document and bulk-indexes it into Elasticsearch. End-to-end lag under 3 seconds is fine for a catalog that changes slowly.

πŸ’‘ CDC (Change Data Capture) watches the database transaction log and streams every insert/update/delete as an event - keeps other systems in sync without polling.

Adding a cache in front. Popular queries ("pizza near me" in Bangalore CBD) repeat constantly. Cache the top results in Redis with a key like search:{geohash5}:{query_hash} and a 60-second TTL. Use single-flight / request coalescing on cache miss so a viral query doesn’t stampede Elasticsearch.

flowchart LR
    CUST["Customer App"]:::client
    GW["API Gateway"]:::edge
    SEARCH["Search Service"]:::service
    REDIS[("Redis<br/>query cache")]:::data
    ES[("Elasticsearch")]:::data
    RDB[("Primary DB")]:::data
    CDC["Debezium CDC"]:::async
    K["Kafka"]:::async
    IDX["Indexer"]:::service

    CUST -->|"1. Search restaurants"| GW
    GW -->|"2. Forward to search svc"| SEARCH
    SEARCH -->|"3. Lookup cached results"| REDIS
    SEARCH -->|"4. Full-text query"| ES
    RDB -->|"5. CDC stream"| CDC
    CDC -->|"6. Stream changes"| K
    K -->|"7. Index new menu items"| IDX
    IDX -->|"8. Update search index"| ES

    classDef client fill:#4c3a5e,stroke:#818cf8,color:#e2e8f0
    classDef edge fill:#1e3a5f,stroke:#60a5fa,color:#e2e8f0
    classDef service fill:#1a3a2a,stroke:#4ade80,color:#e2e8f0
    classDef async fill:#AB47BC,stroke:#4A148C,color:#fff
    classDef data fill:#3b3520,stroke:#fbbf24,color:#e2e8f0

This is why a separate search tier matters: we get text, geo, facets, and relevance ranking in one system, decoupled from the OLTP database that owns the truth.


Core Flows

Here’s how each functional requirement plays out end-to-end across the system.

Flow 1 - Search for nearby restaurants

sequenceDiagram
    autonumber
    participant C as Customer App
    participant GW as API Gateway
    participant S as Search Service
    participant R as Redis Cache
    participant ES as Elasticsearch
    participant RS as Restaurant Service
    participant DB as Restaurants DB

    C->>GW: GET v1 restaurants lat lng q
    GW->>GW: auth JWT and rate limit
    GW->>S: forward query
    S->>R: lookup geohash5 and query hash
    alt cache hit
        R-->>S: cached top results
    else cache miss
        S->>ES: geo text filters query
        ES-->>S: ranked candidates
        S->>R: cache 60s TTL
    end
    S-->>C: restaurant list with ETA and rating

    C->>GW: GET v1 restaurants id menu
    GW->>RS: forward
    RS->>DB: fetch menu
    DB-->>RS: menu document
    RS-->>C: menu JSON
  1. Gateway validates the JWT and applies per-user rate limits.
  2. Search Service computes a cache key from a coarse geohash plus the query and filter fingerprint.
  3. Cache miss hits Elasticsearch with a function_score query blending text relevance, geo distance, rating, and popularity in one shot.
  4. Results are cached for 60 seconds with request coalescing to prevent stampedes on viral queries.
  5. Customer picks a restaurant; client fetches the menu directly from the Restaurant Service.

Flow 2 - Place and pay for an order

sequenceDiagram
    autonumber
    participant C as Customer App
    participant GW as API Gateway
    participant O as Order Service
    participant R as Redis
    participant P as Payment Service
    participant PG as Payment Gateway
    participant DB as Orders DB
    participant K as Kafka

    C->>GW: POST v1 orders Idempotency-Key
    GW->>O: forward with JWT
    O->>R: SET NX idem key 60s TTL
    alt retry of completed request
        R-->>O: cached response
        O-->>C: replay original 201
    else first attempt
        O->>O: validate cart and compute authoritative total
        O->>P: create payment intent
        P->>PG: authorize charge
        PG-->>P: auth OK txn_ref
        P-->>O: intent confirmed
        O->>DB: insert order plus outbox row in tx
        O->>R: SET idem response 24h
        O-->>C: 201 CONFIRMED
        DB->>K: OrderConfirmed via CDC
    end
  1. Client generates a UUIDv4 idempotency key tied to the cart. Retries reuse the same key.
  2. Order Service checks Redis for the key; duplicate retries replay the cached response with no duplicate processing.
  3. Server recomputes the total authoritatively - never trust a client-supplied amount.
  4. Payment Service authorizes through the gateway. For UPI this is immediate; for 3DS cards the client finishes the extra step before capture.
  5. Order row plus outbox row go in one Postgres transaction - either both land or neither does.
  6. Debezium tails the WAL and publishes OrderConfirmed to Kafka for downstream services.

Failure worth calling out: if the gateway times out, the order stays in PAYMENT_PENDING. A reconciler polls the gateway every 5 minutes and either promotes to CONFIRMED or cancels with refund - gateway truth always wins.

Flow 3a - Dispatch a rider

sequenceDiagram
    autonumber
    participant K as Kafka
    participant D as Dispatch Service
    participant GEO as Redis Geo
    participant LOCK as Redis Locks
    participant WS as WebSocket Gateway
    participant RID as Rider App
    participant O as Order Service

    K->>D: consume OrderConfirmed partitioned by city
    D->>GEO: GEOSEARCH nearby riders 3km
    GEO-->>D: top candidates
    D->>D: ML scorer picks best rider
    D->>LOCK: SET NX PX offer rider r1 value orderA 42
    LOCK-->>D: acquired
    D->>WS: push offer with offer id 42
    WS->>RID: offer notification
    alt rider accepts within 10s
        RID->>D: accept with offer id 42
        D->>LOCK: Lua compare and delete offer id 42
        LOCK-->>D: matched and consumed
        D->>O: RiderAssigned event
        O->>O: CAS status CONFIRMED to ASSIGNED
    else timeout or reject
        LOCK-->>D: key expired
        D->>D: pick next candidate and repeat
    end
  1. Dispatch consumes OrderConfirmed from a Kafka topic partitioned by city_id so one worker owns each city and there’s no cross-worker race.
  2. Redis Geo returns nearby riders; the ML scorer ranks them by distance, acceptance rate, and expected pickup time.
  3. Before sending an offer, Dispatch grabs a per-rider lock whose value is the offer id - SET offer:rider:r1 "orderA:42" NX PX 10000. This is de-duplication of offers, not the assignment itself.
  4. Rider taps accept; the request carries the offer id. A Lua compare-and-delete verifies it still matches. Mismatch means the offer expired and someone else owns the slot - clean rejection.
  5. The assignment is only real once Order Service lands the CAS (... WHERE status = 'CONFIRMED' AND rider_id IS NULL). If two accepts somehow reach it, the loser gets 0 rows and is told the order is taken.
  6. On timeout or reject, Dispatch picks the next candidate. After N unsuccessful rounds, escalate to ops or cancel with refund.

Flow 3b - Live tracking

sequenceDiagram
    autonumber
    participant RID as Rider App
    participant L as Location Service
    participant GEO as Redis Geo
    participant K as Kafka
    participant PS as Redis PubSub
    participant WS as WebSocket Gateway
    participant CUST as Customer App

    loop every 3 to 5s adaptive
        RID->>L: gRPC stream GPS ping
        L->>GEO: GEOADD riders city
        L->>PS: PUBLISH track order channel
        L->>K: produce rider location
    end

    CUST->>WS: open WSS connection
    WS->>WS: auth JWT and subscribe track order channel
    PS-->>WS: location event
    WS-->>CUST: rider position update
  1. Rider app streams GPS pings over a long-lived gRPC connection. Intervals are adaptive - 30s when stationary, 3-5s moving, immediate on sharp turns. Cuts traffic by ~60%.
  2. Location Service validates each ping, updates Redis Geo for the matching hot path, publishes to a Redis Pub/Sub channel for customer fan-out, and tees a copy to Kafka for history.
  3. Customer opens tracking; WebSocket connects and subscribes to the order’s channel.
  4. Consistent hashing at the edge routes all subscribers for one order to the same gateway pod, keeping Pub/Sub fan-out local.
  5. Kafka feeds the durable history tier (ClickHouse warm, S3 cold) for fraud, payouts, and ETA model training.

Order state machine

For clarity, here’s the full state progression driven by events from the flows above:

stateDiagram-v2
    [*] --> CREATED
    CREATED --> CONFIRMED: payment auth succeeds
    CONFIRMED --> ASSIGNED: rider accepts
    ASSIGNED --> READY: restaurant marks ready
    READY --> PICKED_UP: rider exits restaurant geofence
    PICKED_UP --> DELIVERED: rider enters customer geofence
    DELIVERED --> [*]
    CONFIRMED --> CANCELLED: restaurant rejects or no rider found
    CANCELLED --> [*]

Every transition is a compare-and-set in Postgres (UPDATE ... WHERE status = expected_current), so no consumer can push the order into an illegal state. Each transition writes an outbox row that fans out via Kafka to notification, analytics, and recommendation services.


Final Architecture

Putting it all together:

flowchart TB
    APPS(["Customer Rider and Restaurant Apps"]):::client
    GW["API Gateway"]:::edge
    WS["WebSocket Gateway<br>live order tracking"]:::edge
    SEARCH["Search Service<br>restaurants near you"]:::service
    ORDER["Order Service<br>owns order lifecycle"]:::service
    PAY["Payment Service"]:::service
    LOC["Location Service<br>rider pings"]:::service
    MATCH["Dispatch Workflow<br>assigns a rider"]:::service
    NOTIF["Notification Service"]:::service
    K[["Kafka<br>order events"]]:::async
    ES[("Elasticsearch<br>search index")]:::data
    PGDB[("Postgres<br>orders and restaurants")]:::data
    RD[("Redis<br>rider geo and cache")]:::data
    PGW[/"Payment Gateway"/]:::external

    APPS -->|"browse and order"| GW
    GW --> SEARCH
    SEARCH --> ES
    GW --> ORDER
    ORDER --> PGDB
    ORDER --> PAY
    PAY --> PGW
    ORDER -->|"order placed"| K
    K --> MATCH
    MATCH -->|"who is nearby"| LOC
    LOC --> RD
    K --> NOTIF
    K --> WS
    WS -->|"rider is 2 min away"| APPS
    classDef client fill:#4c3a5e,stroke:#818cf8,color:#e2e8f0
    classDef edge fill:#1e3a5f,stroke:#60a5fa,color:#e2e8f0
    classDef service fill:#1a3a2a,stroke:#4ade80,color:#e2e8f0
    classDef async fill:#3b1f5e,stroke:#c084fc,color:#e2e8f0
    classDef data fill:#3b3520,stroke:#fbbf24,color:#e2e8f0
    classDef external fill:#4a1942,stroke:#f472b6,color:#e2e8f0

That’s the design. Five deep dives in Bad / Good / Great progression, each picking the right primitive for the problem: Redis Geo for live rider locations, a Postgres compare-and-set plus a rider-side unique index for correct matching, WebSockets plus Pub/Sub for real-time tracking, Temporal for durable multi-step dispatch, and Elasticsearch fed by CDC for catalog search.

How it works end-to-end (order path):

  1. Customer places order β€” request hits API Gateway, routed to Order Service
  2. Order persisted and payment charged β€” Order Service writes to Orders DB (Postgres), Payment Service charges via Razorpay/Stripe/UPI
  3. Matching Workflow triggered β€” Temporal orchestrates rider assignment: queries Redis Geo for nearby riders, scores candidates, takes a per-rider offer lock, and commits the assignment with a Postgres CAS
  4. Rider notified β€” Notification Service sends push via FCM/APNs; rider accepts or declines
  5. Restaurant notified β€” order details sent to Restaurant App for preparation

How it works end-to-end (live tracking path):

  1. Rider streams location β€” GPS pings sent to Location Service, written to Redis Geo (per city shard), events emitted to Kafka
  2. Kafka sinks to history β€” Cassandra stores full location trail for ETA recalculation and analytics
  3. Real-time push to customer β€” Redis PubSub routes location updates to WebSocket Gateway, customer sees rider on map
  4. Search stays fresh β€” Debezium CDC streams restaurant DB changes to Kafka, Elasticsearch index updated in near-real-time

Key Technologies

Term What it is
Elasticsearch Search engine with inverted indexes, geo-point filters, and function_score queries powering restaurant search with text + location + ranking in one call.
Redis Geo In-memory geospatial index for sub-millisecond β€œfind riders within 3km” queries on 200K+ active delivery partners.
WebSocket Persistent connection streaming live rider location to customers for real-time order tracking on the map.
Kafka Event bus carrying order confirmations, location streams, and CDC events between decoupled services.
Dispatch Algorithm Scoring-based matching that picks the best rider using distance, acceptance rate, and estimated pickup time - not just proximity.
Rider Assignment Two invariants, two mechanisms: a Postgres compare-and-set commits one rider to one order, a unique partial index on rider_id stops one rider taking two orders, and Redis SET NX de-duplicates outstanding offers.
ETA Estimated Time of Arrival calculated from mapping APIs, used for rider ranking and customer-facing delivery predictions.
CDN Content Delivery Network caching restaurant images and static assets at edge nodes for fast app loading.

What’s Expected at Each Level

This section helps you calibrate your depth. You don’t need to cover everything - just know what’s expected for your level.

Mid-level

Produce a working 3-service design (search, order, dispatch). Recognize the need for geo-queries and a basic payment flow. With prompting, discuss caching for search. You should be able to articulate why a regular SQL query won’t work for β€œrestaurants near me” at scale, and sketch a happy-path order flow from placement to delivery.

Senior

Drive the design proactively. Propose Elasticsearch for search with CDC sync, Redis Geo for rider proximity, and idempotency for orders. Discuss real-time tracking trade-offs (polling vs WebSocket vs SSE) without prompting. You should articulate the fan-out problem for location updates and explain why the dispatch workflow needs durability beyond a simple queue.

Staff+

Address dispatch workflow durability (Temporal/Cadence), where the assignment invariants actually live (a Postgres CAS for the order, a unique partial index for the rider, and why a Redis lock alone is not the correctness boundary), adaptive location pinging to reduce write volume, and operational concerns like what happens during Redis failover. Show awareness of cost at scale - quantify rider location write volume, explain why city-sharded Redis Geo is cheaper than DynamoDB, and discuss graceful degradation when the matching service is overloaded.


🎯 Key Takeaways



Understand the building blocks used in this design:

Discussion

Newest first
You

Free system design + DSA prep. If it helped you crack an interview, consider supporting.

SensAI SensAI
Beta
Listening...
Tap mic to stop voice mode

Shape what we build next

Every piece of feedback is read by the team and directly influences our roadmap.

What type of feedback?

Install SystemCraft

Add to your home screen for instant access, offline reading, and a distraction-free experience.

Offline reading Faster loads No browser tabs App-like feel

Unlock AI Features

One click to activate - no payment, no credit card. Just sign in and you're in.

AI code review and hints
SensAI chat assistant
AI mock interviews
Whiteboard analysis
100% free during early access