Design problems
Search & discoveryMediumSDE-2 35 min

Design restaurant search and discovery (Zomato)

Asked at
SwiggyUberZeptoZomato

The interviewer asks

“Design the search and discovery experience of a food delivery app like Zomato or Swiggy. A user opens the app, sees a ranked list of restaurants that can deliver to them right now, and can search for a dish or a restaurant by name.”

What a strong answer looks like

A strong answer starts from the hard constraint that makes this different from generic search: a restaurant is only a valid result if it is open, serviceable to this exact location (delivery polygon and rider availability) and not overloaded right now. The design keeps a search index (Elasticsearch/OpenSearch) of restaurants and dishes with geo fields and slowly changing attributes, and a separate fast availability layer (Redis) for live state like open/closed, prep-time and serviceability, updated from events. A query first does geo + text retrieval for a few hundred candidates, filters by live availability, then a ranking service orders them by predicted conversion using relevance, ETA, rating, price, offers and personal history. Home feed results are cached per geo cell for a short time. Typo tolerance, transliteration (paneer / panir, Hinglish) and synonyms ("biryani" matching "dum biryani") matter in India.

Concepts to mention

Delivery polygon / serviceability filterGeo + text retrieval in ElasticsearchLive state in Redis separate from the indexCDC from catalogue DB to search indexTwo-stage retrieval then ML rankingH3 cell based cachingTypo tolerance, synonyms and transliterationGraceful fallback ranking

1 Requirements

Functional

  • Home feed: ranked list of restaurants that deliver to the user's current address
  • Search by restaurant name, cuisine or dish ("butter chicken", "pizza") with typo tolerance
  • Filters and sorts: rating 4+, pure veg, price for two, delivery time, offers
  • Show live ETA, open/closed state and offer badges on every card
  • Personalise ranking using past orders and preferences (veg, cuisines, price band)
  • Hide or mark restaurants that are closed, too far or temporarily unserviceable (rain, rider shortage)

Non-functional

  • p99 search latency under 200 ms at the backend
  • Peak ~30k search/feed QPS at dinner time (8-9:30 pm) across India
  • Live state (open/closed, busy) reflected in results within ~30 seconds
  • Menu and price changes searchable within ~5 minutes
  • Availability 99.95%; on failure fall back to a cached, less personal list rather than an empty screen
  • Work well on slow 3G/4G in Tier-2/3 cities: small payloads, paginated cards

Out of scope (say it out loud)

  • Cart, checkout and payment
  • Rider assignment and delivery tracking
  • Restaurant onboarding and menu management tools
  • Reviews and photo upload pipeline

2 Estimates

Restaurants~300k active, ~30M menu items~300k listed restaurants x ~100 dishes each ~ 30M dish documents
Search + feed QPS~10k/s average dinner, ~30k/s peak~2.5M orders/day; ~10 feed/search calls per order session plus browsing without ordering ~ 40-50M calls/day; dinner hour concentrates ~25% of it; IPL nights and rain push higher
Index size~30-60 GB30M dish docs x ~1-2 KB with text and attributes ~ 30-60 GB; fits on a modest Elasticsearch cluster with replicas
Live-state updates~5-10k/s at peak300k restaurants change prep time / busy state every few minutes plus serviceability per zone every ~30 s ~ thousands of updates/s
Candidates per query~500 -> ~50 shownA user in a dense Bangalore area has 1,000+ restaurants within 7 km; retrieve ~500 by geo + text, filter by live state, rank, return first page of 20
Feed cache~70% hit rate on unpersonalised base listCache base candidate list per H3 cell (res 8, ~0.7 km^2) for 60 s; dense cities have a few thousand active cells, so the hit rate is high at peak

3 API

EndpointWhat it does
GET/v1/feed?lat={lat}&lng={lng}&cursor={c}Home feed: ranked restaurant cards for the user's location, paginated
GET/v1/search?q={text}&lat=&lng=&filters=veg,rating4&sort=relevance&cursor=Search restaurants and dishes; returns restaurant cards with matched dishes highlighted
GET/v1/suggest?q={prefix}&lat=&lng=Typeahead suggestions: dishes, cuisines, restaurant names near the user
POST/internal/restaurant-stateInternal event: restaurant opened/closed, prep time changed, zone serviceability changed

4 Data model

restaurant_docElasticsearch / OpenSearch
restaurant_idnamename_translitcuisines[]location (geo_point)delivery_polygon (geo_shape)ratingrating_countcost_for_twois_pure_vegtags[]popularity_scorecity_id

Index per city (or routing by city_id) keeps shards small and queries local. Custom analysers: edge n-grams for prefix, phonetic/transliteration filter, synonym list ("biryani, biriyani").

dish_docElasticsearch / OpenSearch
dish_idrestaurant_idnamedescriptioncategorypriceis_vegorder_count_30d

Searched together with restaurants; results are grouped by restaurant_id (collapse) so the user sees restaurants with the matching dishes underneath.

restaurant_live_stateRedis (hash per restaurant)
restaurant_id -> is_openaccepting_ordersprep_time_minbusy_levelupdated_at

Changes every few minutes; keeping it out of Elasticsearch avoids a flood of re-index writes and segment churn. Read with one MGET-style pipeline for ~500 candidates.

zone_serviceabilityRedis
h3_cell or zone_id -> serviceablemax_radius_kmsurge_flagrider_supply_ratio

Updated by the logistics team every ~30 s. Lets search shrink delivery radius during heavy rain or rider shortage.

user_profile_featuresFeature store (Redis online, data lake offline)
user_idveg_affinitycuisine_affinity{}price_bandlast_10_restaurantsavg_order_value

Computed daily in batch plus a few real-time features (last viewed); used by the ranker.

restaurants (source of truth)Postgres/MySQL
restaurant_id (PK)nameaddresslatlngpolygonmenu tables...

Owned by the catalogue service; changes stream to search via CDC (Debezium -> Kafka -> indexer).

5 Architecture

CLIENTSEDGESERVICESASYNCDATAEXTERNALfeed / searchgeo + text retrievelive state, cache~300 candidatesuser featuresCDCupsert docszone + busy eventsCustomer appAPI gatewaySearch / feed serviceRanking service (ML)Catalogue serviceIndexer workersLive-state updaterKafka (CDC + stateevents)Elasticsearch (percity)Redis live state +cacheFeature storeCatalogue DB(Postgres)Logistics / ridersupply
ClientEdge / gatewayService / workerQueue / streamCacheDatabase / storageAI / modelThird partyrequestasync (event / queue)

Hover a box to highlight its connections.

6 Request flows

User searches "biryani"

  1. 1App calls GET /v1/search?q=biryani with lat/lng; the gateway authenticates and forwards to the search service.
  2. 2Search service resolves the location to a city and H3 cell, and normalises the query (lowercase, spelling 'biriyani' -> 'biryani', synonym expansion).
  3. 3It queries the city's Elasticsearch index: restaurants whose delivery_polygon contains the user point (geo_shape) and whose name, cuisine or dishes match, with results collapsed by restaurant; top ~500 by text score x popularity.
  4. 4It reads live state for those ~500 IDs from Redis in one pipelined call and drops closed or not-accepting restaurants; it applies zone serviceability (shrunken radius in heavy rain).
  5. 5It computes ETA per restaurant = prep time + distance-based travel time (from a precomputed table or ETA service) and drops anything above the max ETA.
  6. 6The ranking service scores the remaining ~300 using text relevance, ETA, rating, price fit, offers and user features, and returns the order.
  7. 7Search service returns the first 20 cards with the top matching dishes, plus a cursor that stores the ranked list ID for the next pages.

Restaurant gets busy and pauses orders

  1. 1Restaurant partner app toggles 'not accepting orders' or the system detects rising prep times.
  2. 2An event is published to Kafka; the live-state updater writes accepting_orders=false to Redis within a second.
  3. 3The next search filters it out; feed caches per H3 cell hold only the base candidate list, so live filtering still applies to cached results.
  4. 4No Elasticsearch write happens, so the index is not churned by thousands of short-lived state changes.

7 Deep dives

Serviceability is the real filter, not distance

A naive design says 'find restaurants within 5 km'. Real delivery apps use delivery polygons, not circles: a restaurant across a river or a flyover with no U-turn may be 2 km away by air but 25 minutes by road. Each restaurant (or its zone) has a geo_shape polygon drawn from road-network travel time, and the query is 'polygons that contain the user's point'. Elasticsearch supports this with geo_shape queries backed by BKD trees; you can also precompute a mapping from H3 cell to the set of restaurants that serve it and store it in Redis, which turns the geo query into a key lookup. On top of the static polygon sits dynamic serviceability: in heavy Mumbai rain or when riders are short, logistics shrinks the effective radius per zone. That is why live zone state lives in a fast store and is applied at query time. In the interview, say clearly that relevance does not matter if the restaurant cannot deliver in 40 minutes, and that the system must never show a restaurant the user cannot order from.

Splitting static index from live state

Restaurant data changes at very different speeds. Name, cuisine and location change monthly. Menus and prices change daily. Open/closed, prep time and busy level change every few minutes, and at 8 pm thousands of restaurants change state each second. Writing every state change into Elasticsearch is a mistake: each update is a delete-plus-insert of the document, creates new segments, triggers merges and caches get invalidated, so search latency rises exactly at peak. The fix is two layers. Elasticsearch holds the slow-moving searchable fields, fed by CDC from the catalogue DB through Kafka with ~1-5 minute freshness. Redis holds live state keyed by restaurant_id, updated by events within a second. The query does retrieval in Elasticsearch, then a pipelined Redis read for a few hundred IDs (~1-2 ms), then filters. The same split lets you cache the expensive geo + text candidate list per H3 cell for 60 seconds while live filtering stays exact.

Ranking for conversion

The goal of ranking is the probability that the user orders from this restaurant now and is happy with it. Use a two-stage approach. Retrieval gives ~300 serviceable candidates. A learning-to-rank model (gradient-boosted trees such as LightGBM, later maybe a two-tower plus re-ranker) scores them with features in four groups: query match (text score, dish match count), restaurant quality (rating, rating count, cancellation rate, order volume), context (ETA, distance, time of day, rain, offers active, delivery fee) and personal fit (cuisine affinity, veg preference, price band, ordered here before). Labels come from logs: impression -> click -> order. Business rules apply after the model: sponsored slots at fixed positions, clearly marked; a diversity rule so the top 10 is not all biryani places of one chain; a penalty for restaurants with high recent cancellations. Measure with A/B tests on orders per session and conversion, not click-through alone, because clickbait images raise clicks without raising orders.

Indian language and spelling problems

Users type 'panir tika', 'chiken biriyani', 'dosa' vs 'dosai', or a Hindi word in Latin script. Plain English analysers fail here. Use several layers: a custom synonym and transliteration dictionary built from query logs (queries that led to the same orders are probably the same intent), fuzzy matching with edit distance 1-2 on longer tokens, phonetic filters, and edge n-grams for prefix search in typeahead. Index a normalised 'translit' field next to the original name. Query logs are the best source: if 'biriyani' searches usually end in orders for 'biryani' items, add the mapping. Also map dishes to cuisines and categories so that 'something spicy' or 'healthy' can fall back to tag search instead of zero results. Zero-result rate per city is a key metric to watch.

8 Trade-offs

Elasticsearch vs a custom geo index

Elasticsearch gives text search, geo_shape, aggregations for filter counts and collapse in one system, and 30-60 GB of data is small for it. A custom in-memory index (H3 cell -> restaurant IDs) is faster for the feed. Common answer: use the H3 precomputation for the home feed and Elasticsearch for text search.

Precomputed feeds vs on-request ranking

Precomputing a personal feed per user is wasteful because availability changes every minute and most users open the app only a few times a week. Compute on request, but cache the non-personal candidate list per H3 cell for ~60 s.

Index per city vs one global index

No query crosses cities, so per-city indexes (or routing by city_id) keep each query on one shard group, isolate a hot city (Bengaluru at dinner) and allow per-city analysers. Cost: more indices to manage.

Freshness of menus vs indexing cost

CDC with a 1-5 minute lag is good enough for menus and prices; the checkout service re-validates the price at order time, so a stale search price never charges the user wrongly.

9 Bottlenecks

Dinner-time and IPL-night spikes

Traffic jumps 3-5x in an hour. Autoscale stateless search and ranking pods ahead of time on a schedule, keep Elasticsearch over-provisioned with replicas (it scales slowly), and raise the per-cell cache TTL from 60 s to 120 s under load.

Ranking model latency

Scoring 300 candidates with hundreds of features can take 30-50 ms. Batch feature fetches, keep the model small, and fall back to a simple heuristic score (rating x ETA penalty) when the ranker misses its deadline.

10 Failure handling

Ranking service is down

Search service has a timeout of ~50 ms on the ranker and falls back to a static popularity + ETA ordering. Users get a less personal list, not an error.

Redis live state is unavailable

Fail safe, not open: treat state as unknown, show restaurants based on scheduled opening hours from the index, and let checkout re-validate. Alert loudly, because showing closed restaurants hurts conversion.

Indexer lags behind CDC

Kafka retains events, so the indexer catches up after a restart. Monitor consumer lag; menus may be a few minutes stale, which checkout validation absorbs.

11 What impresses

  • Saying early that serviceability and ETA filter results before relevance ranks them
  • Explaining why frequent state changes must not go into Elasticsearch
  • Using H3 cells for both caching and precomputed restaurant lists
  • Optimising for orders and satisfaction in A/B tests, not clicks
  • Handling rain and rider shortage by shrinking the radius dynamically
  • Measuring zero-result rate and mining query logs for synonyms

12 Mistakes

  • Using a simple radius query and ignoring road distance and delivery polygons
  • Re-indexing documents for every open/closed toggle
  • Showing restaurants that are closed or cannot deliver
  • Designing only text search and forgetting the home feed, which drives most orders
  • No fallback when the ranking model is slow
  • Treating sponsored listings as hidden ranking boosts instead of labelled slots

13 Follow-ups

Q1How do you show filter counts ("Pure veg (124)") quickly?
Q2How would you add semantic search for queries like "something light for dinner"?
Q3How do you stop a large chain from taking all the top slots?
Q4How would you rank when a user has no order history?
Q5How do you keep pagination stable when live state changes between page 1 and page 2?
Q6How would you support grocery items (quick commerce) in the same search?

Saved on this device. Create a free account to keep it on every device.

Practise this with Sam

A real mock interview, out loud, with an honest scorecard. Free to start.

Start free