Design restaurant search and discovery (Zomato)
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
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
3 API
| Endpoint | What 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-state | Internal event: restaurant opened/closed, prep time changed, zone serviceability changed |
4 Data model
restaurant_docElasticsearch / OpenSearchrestaurant_idnamename_translitcuisines[]location (geo_point)delivery_polygon (geo_shape)ratingrating_countcost_for_twois_pure_vegtags[]popularity_scorecity_idIndex 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 / OpenSearchdish_idrestaurant_idnamedescriptioncategorypriceis_vegorder_count_30dSearched 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_atChanges 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_serviceabilityRedish3_cell or zone_id -> serviceablemax_radius_kmsurge_flagrider_supply_ratioUpdated 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_valueComputed daily in batch plus a few real-time features (last viewed); used by the ranker.
restaurants (source of truth)Postgres/MySQLrestaurant_id (PK)nameaddresslatlngpolygonmenu tables...Owned by the catalogue service; changes stream to search via CDC (Debezium -> Kafka -> indexer).
5 Architecture
Hover a box to highlight its connections.
6 Request flows
User searches "biryani"
- 1App calls GET /v1/search?q=biryani with lat/lng; the gateway authenticates and forwards to the search service.
- 2Search service resolves the location to a city and H3 cell, and normalises the query (lowercase, spelling 'biriyani' -> 'biryani', synonym expansion).
- 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.
- 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).
- 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.
- 6The ranking service scores the remaining ~300 using text relevance, ETA, rating, price fit, offers and user features, and returns the order.
- 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
- 1Restaurant partner app toggles 'not accepting orders' or the system detects rising prep times.
- 2An event is published to Kafka; the live-state updater writes accepting_orders=false to Redis within a second.
- 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.
- 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
Lessons this design uses
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.