Course contents

Design product search for an online store

Design product search for an online store like Amazon or Flipkart: keyword search with typo tolerance, facets, sorting and pagination over 100M products, with Postgres, CDC, Kafka and Elasticsearch, sharding, ranking and search_after explained for the system design interview.

FreeMust know for interviewsMediumDesign problem19 min readBeginner → SeniorReviewed Oct 2026
On this page
  1. Requirements
  2. Core entities and API
  3. Back-of-envelope estimates
  4. High-level design
  5. Deep dives
  6. What interviewers look for
  7. Follow-up questions
  8. How to say it in the interview

🛒 What are we building?

The search box and results page of an online store like Amazon or Flipkart. A shopper types "nike running shoes", gets a ranked grid, narrows it with filters such as brand, price and rating (each with a count) and keeps scrolling. Picture a shop assistant who has memorised the whole warehouse and says "we have 1,243 Nike shoes, here are the 24 you'll most likely buy" before you finish blinking.

For this problem, we'll design keyword search over a large catalogue: query understanding, retrieval, ranking, facets and pagination, plus the part most candidates rush, keeping the index in step with the product database. Interviewers like it because it sounds like "put Elasticsearch in front of it" and then quietly tests sync, sharding, relevance and deep paging. Autocomplete lives in Design typeahead, and discovering documents on the open web in Design a web crawler.

The 30-second answer

"Postgres is the source of truth. Changes go out through an outbox and CDC into Kafka, and an indexer bulk-writes them into Elasticsearch, so search trails the database by seconds but never drifts. A stateless query service fixes up the query (spelling, synonyms, likely category) and sends one request that does BM25 retrieval, filters, a business-signal re-rank and facet counts, with popular queries cached briefly. The index is sharded at tens of GB per shard, replicas carry read load, and paging uses search_after cursors, not deep offsets."

What you’ll learn

  • Scope an e-commerce search problem with requirements, a GET /search API and estimates that show read fan-out drives the design
  • Keep the index in sync with Postgres using a transactional outbox and CDC instead of dual writes, and explain the consistency lag
  • Size shards and replicas from data size and peak QPS, and explain scatter-gather and why latency follows the slowest shard
  • Rank with BM25, boosts and business signals, and know where learning to rank fits
  • Replace deep from/size paging with **search_after cursors** and keep price and stock fresh with coalesced partial updates
  • Tell when Postgres full-text search is enough and Elasticsearch is not needed yet

Interview radar

What they probe: how the index stays in sync with the database, how a query fans out across shards, how you rank beyond plain text match, and how facets and pagination behave at scale. Where candidates slip: writing to the DB and the index from the app (dual writes), treating the index as the source of truth for price and stock, and offering page=500 with offset paging without knowing what it costs.

01Requirements

Functional requirements

  • Users should be able to search products by keywords and still get results when they make a typo ("addidas" finds Adidas).
  • Users should be able to filter by category, brand, price range, rating and availability, and see a count next to each filter value.
  • Users should be able to sort by relevance (default), price, rating or newest.
  • Users should be able to page or scroll through results.
  • Users should see current price and stock: a product that sold out should stop showing as available within seconds.

Non-functional requirements

  • Scale: a catalogue of about 100 million products (our assumption) and a few thousand searches per second at peak.
  • Latency: p99 under 200 ms at the search API. That's our own target, not a standard: mobile networks add their own delay on top, and the results page is the main path to a purchase.
  • Freshness: price and stock changes visible in search within about 5 seconds; new products within a minute is fine.
  • Availability: search should degrade (slightly stale results, fewer facets) rather than go down. A store with no search is a store with no sales.

Below the line (out of scope)

  • Autocomplete suggestions while typing (see Design typeahead)
  • Personalised recommendations and sponsored listings
  • Image search and fully semantic (vector) search, beyond a mention
  • Checkout, cart and the product detail page itself

At this point, ask your interviewer: how big is the catalogue, and how often do prices and stock change? A 50,000-product shop and a 100-million-product marketplace with flash sales need very different designs.

02Core entities and API

  • Product: id, title, description, brand, category path, attributes (size, colour), price, rating, review count, in-stock flag, seller. Lives in Postgres.
  • Search document: a flattened, read-optimised copy of a product in the index, with extra signals such as sales in the last 30 days.
  • Facet: a filterable field plus value counts for the current query, for example brand: Nike (1,243).
  • Cursor: an opaque token that encodes where the next page starts.
GET /v1/search?q=running+shoes
             &filters=brand:nike|adidas;price:2000-5000;rating:4+
             &sort=relevance
             &cursor=eyJzIjpbMTIuNCwiUDk5MSJdfQ
             &limit=24

200 OK
{
  "results": [{ "productId": "P991", "title": "...", "price": 3499, "rating": 4.4, "inStock": true }],
  "facets": { "brand": [{ "value": "Nike", "count": 1243 }], "price": [...] },
  "correctedQuery": null,
  "nextCursor": "eyJzIjpbMTEuOSwiUDQwMiJdfQ"
}

GET /v1/products/{productId}        // product page, served from Postgres plus a cache

Filters travel as one structured parameter the service validates against the facet schema. The API returns a nextCursor instead of taking a page number (see deep dive 4), and correctedQuery lets the UI say "Showing results for adidas".

03Back-of-envelope estimates

Quantity Assumption Result (roughly)
Catalogue 100M products 100M documents
Index size per copy ~2 KB indexed per product (assumed) 100M × 2 KB ≈ 200 GB
Primary shards Elastic suggests 10-50 GB per shard 200 GB ÷ 25 GB ≈ 8 primaries
Docs per shard 100M ÷ 8 ≈ 12.5M (Elastic's guideline is under 200M)
Searches 20M daily searchers × 5 searches (every filter click is a search) 100M/day ≈ 1,200 QPS average
Peak searches 5× average during a sale (assumed) ≈ 6,000 QPS
Shard-level queries at peak every search hits all 8 shards 6,000 × 8 ≈ 48,000 per second
Catalogue writes 10% of products change price or stock ~3 times a day (assumed) 30M/day ≈ 350 per second, with bursts far higher

Notice three things. The data is small: 200 GB fits on a handful of machines, so storage does not drive the design. Read fan-out drives the design: 48,000 shard queries per second at peak is what decides the replica count. And the write churn, mostly price and stock, is what decides how hard you have to work on freshness.

04High-level design

Let's start with the simplest thing that works, then grow it one requirement at a time.

Step 0: one Postgres box. For a catalogue of up to a few million products, Postgres full-text search is enough: a tsvector column with a GIN index (Postgres's inverted index), ts_rank for ordering, pg_trgm for typos, WHERE for filters and GROUP BY for facet counts. One system, no sync problem. Say this out loud, because it shows you won't reach for a cluster before you need one (Instacart even moved its catalogue search from Elasticsearch to sharded Postgres). It stops being enough for us because we want fuzzy matching, field boosts and many facet counts over 100M rows at thousands of QPS, without competing with checkout traffic.

So we split reads and writes, and the two paths meet at the index.

Fig. 1

Product search: a read path and a write path that meet at the index

Product search: a read path and a write path that meet at the indexTop row, the read path: a shopper calls the query service, which checks a Redis cache of popular queries and otherwise sends the query and aggregations to the Elasticsearch cluster. Bottom row, the write path: the catalog service writes products and an outbox row to Postgres, a CDC connector reads the change log into Kafka, and an indexer bulk-writes the changes into Elasticsearch.Read pathWrite pathquery + aggsbulk indexShoppersearch box, filtersQuery serviceunderstand, retrieve, rankRedis cachepopular queries, short TTLElasticsearchshards + replicasCatalog serviceseller edits, stockProduct DBPostgres + outboxCDCDebezium, reads WALKafkakey: product_idIndexerbulk upsertsasync: the index trails the DB by seconds
Two paths, one index. Shoppers only ever read from Elasticsearch; product changes reach it asynchronously through Postgres, CDC and Kafka.

To support keyword search with typos, we add an Elasticsearch index of search documents. An analyzer lowercases, splits and stems text into terms in an inverted index. At query time we run a multi_match over title, brand and description with fuzziness: AUTO: terms of 1-2 characters must match exactly, 3-5 characters allow one edit and longer terms two, so "addidas" still finds "adidas".

To support filters and facets, we map brand, category and attributes as keyword fields, and price and rating as numbers. Filters go in the filter clause of a bool query: they don't affect the score, and Elasticsearch caches frequently used filters automatically. Facet counts come from aggregations in the same request: a terms aggregation per keyword facet and a range aggregation for price buckets. Here's the shape of one request:

{
  "query": {
    "bool": {
      "must":   [{ "multi_match": { "query": "running shoes", "fields": ["title^3", "brand^2", "description"], "fuzziness": "AUTO" } }],
      "filter": [{ "terms": { "brand": ["nike", "adidas"] } },
                 { "range": { "price": { "gte": 2000, "lte": 5000 } } },
                 { "term":  { "in_stock": true } }]
    }
  },
  "aggs": {
    "brand": { "terms": { "field": "brand", "size": 20 } },
    "price": { "range": { "field": "price", "ranges": [{ "to": 1000 }, { "from": 1000, "to": 3000 }, { "from": 3000 }] } }
  },
  "sort": ["_score", { "product_id": "asc" }],
  "size": 24
}

To support relevance beyond text match, the query service adds business signals (rating, recent sales, stock) on top of the text score. We cover this in the ranking deep dive.

To understand messy queries, the query service runs a small pipeline before it calls the index:

  • Normalise: lowercase, trim, unify units ("1tb" and "1 TB").
  • Spell correction: check rare terms against a dictionary built from the catalogue and past successful queries, and return correctedQuery when you rewrite.
  • Synonyms: expand "tv" to "television" with a search-time synonym list, so you can edit it without reindexing.
  • Category prediction: a classifier guesses that "iphone case" means Phone Accessories and boosts that category. Mention it; you don't need to design it.

To keep the index fresh, the catalogue service writes to Postgres only. A CDC connector streams committed changes into Kafka, and an indexer bulk-writes them to Elasticsearch. Deep dive 1 explains why.

To hit the latency target at peak, replicas spread the read load (deep dive 2), and a Redis cache holds result pages for popular queries, keyed by normalised query, filters, sort and cursor, with a 30-60 second TTL. Search traffic is skewed toward head queries ("iphone", "saree", "air fryer"), so a small cache absorbs a big share of reads, and the short TTL caps how stale a cached price can be.

Put together: the query service normalises the request and checks Redis. On a miss it sends one request to Elasticsearch, which retrieves, filters, scores and aggregates across all shards, and returns 24 products, facet counts and a cursor.

05Deep dives

1. Keeping the index in sync with the database

Postgres is the source of truth and the index is a copy. Every price change, stock change and new listing has to reach the copy in order, without loss, and without slowing the seller's write.

Bad: dual writes from the application. The catalogue service updates Postgres and then calls Elasticsearch. In production the second call times out after the first committed, and the database says ₹499 while search shows ₹599. Two concurrent updates can also reach the index in the opposite order. No transaction spans both systems, so there's no clean fix inside the app.

Fig. 2

Dual writes versus outbox plus CDC

Dual writes versus outbox plus CDCLeft: the app writes to Postgres and then separately to Elasticsearch. If the second write fails or two writes race, the database and the index disagree. Right: the app writes the product row and an outbox row in one transaction. A CDC connector reads the log into Kafka, and an indexer applies the changes to Elasticsearch in order, retrying until it succeeds.1. commit2. timeoutCatalog appPostgreswrite 1: okElasticsearchwrite 2: lostCatalog appPostgresproduct + outbox rowin ONE transactionCDC to Kafkaordered per productIndexerretries, idempotentElasticsearchcatches up, convergesDual write (fragile)DB says 499, index says 599Outbox + CDC (reliable)one commit, one source of truth
One commit, not two. With dual writes a failed second write leaves the systems disagreeing; with an outbox the database commit is the only write that must succeed, and everything after it can retry.

Good: transactional outbox. In the same transaction that updates products, the service inserts a row into an outbox table: {product_id, event_type, payload}. Both commit or neither does. A relay publishes new outbox rows to Kafka, so exactly one write must succeed and everything after it can retry.

Great: outbox read through CDC. Instead of polling the outbox, run a CDC connector such as Debezium, which reads Postgres's logical replication stream and publishes each committed change to Kafka. Key messages by product_id so all changes for one product stay in order in one partition. Two details make it robust:

  • Versioned, idempotent writes. The indexer writes each document with its database version (a version column or updated_at) as the Elasticsearch external version, so a replayed or late event can never roll a product backwards.
  • Rebuilds without downtime. When the mapping changes, build a new index from a snapshot plus the Kafka stream, then flip an alias from products_v7 to products_v8. Readers always query the alias.

Why an outbox rather than streaming raw table rows? Raw rows tie every consumer to your table schema, while an outbox event (ProductPriceChanged) is a deliberate contract, and Debezium can route outbox rows directly. I'd default to outbox table plus Debezium plus Kafka.

The consistency lag, explained. Search is eventually consistent. A change goes: commit, CDC reads it from the log, Kafka, an indexer batch (say we flush every second or every 1,000 docs), then an Elasticsearch refresh, which by default makes writes searchable within 1 second. In normal operation the index trails the database by roughly a few seconds, inside our 5-second target. During a backlog it can be minutes, so alert on consumer lag. The rule that keeps this safe: search may be slightly stale, checkout never is. Cart and checkout read price and stock from Postgres, not the index.

Freshness for price and stock. These fields change far more often than titles. Three options:

  • Partial updates (_update with only price). Easy, but Elasticsearch still reindexes the whole document internally, so a flash sale that changes 1M prices costs 1M reindexes.
  • Coalesce in the indexer: keep only the latest change per product within a short window, so 20 stock ticks become one write. This is my default.
  • Index only a price band and an in_stock flag for filtering, and fetch the exact live price for the 24 shown products from a price cache at render time. One extra lookup per page buys an always-current price.
Many candidates...

Many candidates make Elasticsearch the place where products are created and updated "because that's where search reads from". This is frequently a mistake: Elasticsearch has no multi-document transactions, and newly written documents become searchable only after a refresh. Keep a transactional store as the source of truth and treat the index as a rebuildable copy.

2. Sharding and replicas for scale

An Elasticsearch index is split into primary shards, and each document lands on one of them by hashing its routing value (by default the document ID) modulo the number of primaries. Every shard is a self-contained Lucene index.

A product search does not know which shard holds the matching products, so it must ask all of them. This is scatter-gather: a coordinating node sends the query to one copy of every shard, each returns its own top 24 IDs and scores, and the coordinator merges them into a global top 24, then fetches only those documents.

Fig. 3

Every search fans out to one copy of every shard

Every search fans out to one copy of every shardThe query service sends a search to a coordinating node. The coordinator sends it to one copy of each of three shards, sometimes the primary and sometimes a replica. Each copy returns its own top 24 product IDs and scores, and the coordinator merges 72 candidates into the global top 24.Shard 1Shard 2Shard 3Query serviceCoordinatorscatter, then mergePrimarytakes writesReplicasame data, more readsPrimarytakes writesReplicasame data, more readsPrimarytakes writesReplicasame data, more readseach copy returns its top 24merge 3 x 24 = 72 into top 24latency = the slowest shard
Ask everyone, merge, keep the best. Each shard answers with its own top hits; the response is only as fast as the slowest shard it waited for.

Two consequences follow. One slow shard makes the whole query slow, so keep shards similar in size. And more shards is not free: each is another sub-query per search, so 100 tiny shards for 200 GB is worse than 8 healthy ones.

Shard sizing. Elastic's guidance is roughly 10-50 GB per shard and under 200M documents per shard. With about 200 GB of index, 8 primaries puts each near 25 GB with room to grow. The primary count is fixed when you create the index; to change it you use the split API or reindex into a new index behind an alias. Plan for 2-3 years of growth up front.

Replicas for read throughput. Every replica is a full copy of a shard that can serve searches, and by default Elasticsearch picks among copies with adaptive replica selection, which aims to cut latency. Replicas also give you failover.

Sizing replicas from a load test

Suppose a load test shows one shard copy sustains about 1,500 searches per second at our p99 target (a made-up figure; measure yours with real queries). At 6,000 peak QPS every shard sees all 6,000 searches, so each shard needs roughly 6,000 ÷ 1,500 = 4 copies: 1 primary plus 3 replicas. That's 8 × 4 = 32 shard copies, or about 800 GB of index (roughly 4 × 200 GB) spread across nodes with enough RAM for the OS page cache. Add a node's worth of headroom so losing one machine doesn't push the rest past the target.

What about routing by category? It lets a query scoped to "Shoes" hit one shard, but categories are badly skewed (Electronics dwarfs Stamps), so you get hot shards, and unscoped queries still fan out. Start with default ID routing.

One gotcha with facets. A terms aggregation also runs as scatter-gather: each shard returns its top buckets (by default it over-fetches size × 1.5 + 10 per shard), and counts can be slightly approximate for long-tail values. For a store's brand facet that's acceptable; say so if asked.

3. Relevance and ranking

Finding matching products is the easy part. With 40,000 matches for "shoes", the order decides whether anyone buys.

Bad: sort by text score alone. Elasticsearch scores matches with BM25 by default. BM25 rewards rare terms (IDF), gives diminishing returns for repeating a term (term frequency saturates, controlled by k1, default 1.2) and normalises for field length (b, default 0.75). It's a strong first pass, but it knows nothing about commerce: a cheap phone case with "iPhone 15" in the title three times can outrank the iPhone 15.

Good: BM25 plus boosts and business signals. Boost fields (title matters more than description), boost phrase matches, and then blend in signals with a function_score query: average rating weighted by review count, sales in the last 30 days, in-stock status, and the predicted category from query understanding. Use log or saturating functions so a bestseller doesn't bury every relevant newcomer, and keep weights in config so merchandisers can tune them without a deploy.

Great: two-stage ranking with learning to rank. Retrieval stays cheap (BM25 plus filters over millions of candidates). Then re-rank only the top few hundred per shard with a richer model, using Elasticsearch's rescore with a window_size (its docs give 100-500 as a typical range) or a separate ranking service. A learning-to-rank model is trained on clicks, add-to-carts and purchases per query, corrected for position bias (top results get clicked partly for being on top). Slack describes the same two-stage idea: cheap retrieval in Solr, then a learned re-ranker in the application. This is the senior mention; you don't need to design the model.

How do you know it's better? Offline, score labelled query-product pairs with a metric such as NDCG; online, A/B test on conversion per search. My default: BM25 plus boosts plus function_score on day one, log every impression and click, and add learning to rank once there's enough data.

4. Deep pagination

The naive API is page=N, which becomes Elasticsearch from = (N-1) × 24, size = 24. Here's the catch: with scatter-gather, every shard must collect from + size hits and send them to the coordinator, which sorts all of them and throws away everything before from.

Bad: unbounded from/size. Page 400 means each of 8 shards collects about 9,600 hits so the coordinator can keep 24. Elasticsearch blocks this by default: from + size can't exceed 10,000 (the index.max_result_window setting), because deep pages eat memory and CPU on every shard. Raising the limit only hides the problem.

Good: cap the pages. Nobody usefully browses 10,000 shoes one page at a time. Show numbered pages up to a limit (say 50 pages of 24, which stays under 10,000) and nudge people to filter instead. It's a product decision as much as a technical one, so propose it to the interviewer.

Great: cursor with search_after. For infinite scroll, sort by _score plus a unique tie-breaker such as product_id, then pass the sort values of the last hit as search_after for the next page. Each shard only needs results after that point, so page 400 costs about the same as page 2. The API wraps those sort values in the opaque nextCursor. Two subtleties: you can't jump to an arbitrary page, and if the index refreshes between requests the order can shift, so pair search_after with a point in time (PIT) if you need a frozen view, for example in an export job.

I'd default to search_after cursors for the scrolling grid and a capped page count where the design calls for page numbers.

06What interviewers look for

Level What a good answer shows
Mid-level A clear split between the product DB and the search index, a working API with filters and sort, BM25 and fuzzy matching, facets via aggregations, and a reason to add a cache.
Senior Outbox or CDC instead of dual writes, with the consistency lag explained and checkout reading from the source of truth. Shard and replica sizing from numbers, search_after over deep offsets, a business re-rank on top of BM25.
Staff Zero-downtime reindexing with aliases, a freshness strategy for high-churn fields, a relevance loop (logging, offline metrics, A/B tests, learning to rank), and knowing when Postgres full text is enough.

Green flags: you ask about catalogue size and write churn early; you separate retrieval from ranking; you put numbers on shards and replicas; you state the staleness the design accepts.

Red flags: "write to both", the index as the source of truth, page=500 with offsets, "add more shards" as the answer to every latency question, ranking by popularity alone.

07Follow-up questions

What if prices change thousands of times a second during a flash sale?

Coalesce per product in the indexer so only the latest value is written, and keep checkout on the live price. If that still can't keep up, stop indexing the exact price: filter on a price band in Elasticsearch and fetch live prices for the 24 shown products from a price cache.

What if a query returns zero results?

Retry with a looser query: drop the least important filter, or let "most terms" match instead of all. Say so in the UI, and log zero-result queries: they're the best list of missing synonyms and catalogue gaps you'll get.

What if the Elasticsearch cluster goes down?

Replicas across availability zones cover node and zone failures. In a full outage, serve cached head queries and fall back to category browsing from Postgres. The index is a copy, so you rebuild it from Postgres plus Kafka; nothing is lost.

What if you need to add a new searchable field?

Create a new index with the new mapping, backfill from a Postgres snapshot, replay Kafka from that point, compare with shadow queries, then swap the alias. Keep the old index for a one-step rollback.

What if users type "red dress for a beach wedding"?

Keyword matching struggles with intent like this. The next step is hybrid search: add a vector field with text embeddings, run a k-nearest-neighbour query next to BM25, and merge the two rankings. eBay reports running a sharded vector similarity engine over about 1.7 billion listings with under 25 ms for 95% of responses, so this works at marketplace scale, but it's a follow-up, not the base design.

08How to say it in the interview

In the interview

"Postgres is the source of truth and search is a read-optimised copy. Changes are written with an outbox row in the same transaction, Debezium streams them into Kafka keyed by product ID, and an indexer bulk-writes versioned documents into Elasticsearch, so the index trails by a few seconds and checkout always reads live price and stock. The query service fixes spelling, expands synonyms and predicts a category, then sends one request with fuzzy BM25 retrieval, filters and facet aggregations. Ranking is BM25 with field boosts, then a function_score re-rank on rating, sales and stock, with learning to rank once we've logged enough clicks. For roughly 200 GB I'd use 8 primary shards of about 25 GB, and size replicas from a load test, because every search hits every shard. Head queries are cached in Redis for under a minute. Paging uses search_after cursors, since deep from plus size is costly and stops at 10,000 hits. With only a few million products, I'd start on Postgres full-text search."

On the whiteboard

  • Postgres = truth; ES = rebuildable copy
  • Write: DB + outbox (1 txn) → Debezium → Kafka (key product_id) → indexer → ES bulk, external version
  • Lag ≈ seconds (batch + 1 s refresh); checkout reads DB
  • Read: query svc → normalise, spell, synonyms, category → ES
  • ES request: multi_match fuzzy + bool filter + terms/range aggs
  • Rank: BM25 + boosts → function_score → (LTR rescore top N)
  • ~200 GB → 8 primaries × 25 GB; replicas = peak QPS ÷ per-copy QPS
  • Redis cache head queries, TTL 30-60 s
  • Paging: search_after cursor; cap numbered pages under 10k
  • Small catalogue? Postgres tsvector + GIN + pg_trgm

Concepts used in this design

  • CachingSoon
  • Sharding and partitioningSoon
  • ReplicationSoon
  • Queues and event streamsSoon
  • KafkaSoon
  • PostgreSQLSoon
  • Elasticsearch

Go deeper in Articles

Optional long reads behind this design, for when you want the full story.

Further reading (22)