Designing High-Performance Search Systems
A search system can return correct results and still fail in production if latency grows under load, indexing falls behind, hot shards overload individual nodes, or expensive queries consume too much CPU and memory.
Designing a high-performance search system therefore requires more than choosing a search engine. Performance depends on index structure, document shape, shard strategy, query design, caching, ranking complexity, concurrency control, hardware utilization, and how the application interacts with the search cluster.
The goal is not simply the lowest possible average latency. A production search platform should deliver predictable latency at realistic concurrency while continuing to index data reliably.
Table of Contents
- Define Search Performance Goals
- Design the Index for the Read Path
- Keep Queries Efficient
- Choose a Sustainable Sharding Strategy
- Use Caching at the Right Layers
- Control Ranking Cost
- Balance Search and Indexing Throughput
- Capacity Planning and Load Testing
- Production Design Example
- Common Mistakes
- Production Checklist
- Conclusion
Define Search Performance Goals
Performance tuning should begin with a target rather than with configuration changes.
Useful service-level objectives may include:
p50 search latency: < 40 ms
p95 search latency: < 120 ms
p99 search latency: < 250 ms
search throughput: 5,000 queries/sec
indexing throughput: 20,000 documents/sec
availability: 99.95%
indexing lag: < 5 seconds
These numbers are workload-specific. Product search, log exploration, autocomplete, and internal document search may require very different latency and freshness guarantees.
Percentiles matter more than averages.
Requests:
40 ms
43 ms
44 ms
47 ms
51 ms
58 ms
70 ms
110 ms
410 ms
1800 ms
Average can look acceptable,
while tail latency is poor.
Search users experience individual requests, not the average across all requests. Tail latency should therefore be a first-class performance metric.
The performance budget should also include the complete request path:
Client
|
v
API Gateway
|
v
Search Service
|
v
Search Cluster
|
v
Result Enrichment
|
v
Serialization
|
v
Client
A search engine responding in 60 ms does not produce a 60 ms user experience when another 150 ms is spent in downstream enrichment.
Design the Index for the Read Path
Search performance starts with index design. A poorly shaped document can force expensive processing into every query, while a read-optimized document can make the hot path extremely simple.
Denormalize Search Documents
Relational databases are often normalized to reduce duplication and preserve consistency. Search indexes usually make the opposite trade-off.
A relational model might contain:
products
brands
categories
ratings
inventory
sellers
Search results may need information from all of them.
Resolving those relationships during every search request would create a fan-out pattern:
Search Results
|
+--> Product Service
+--> Brand Service
+--> Inventory Service
+--> Rating Service
+--> Seller Service
Instead, the search document can contain the fields needed for retrieval and rendering:
{
"product_id": 1042,
"title": "Wireless Noise Cancelling Headphones",
"brand": "SoundWave",
"category": "Audio",
"price": 199.99,
"rating": 4.7,
"review_count": 8421,
"in_stock": true
}
This duplicates data, but it allows the search engine to answer the request without distributed joins.
Search indexes should generally be viewed as denormalized read models. The broader data-sharing trade-offs are discussed in Strategies to Share Data Between Services.
Index Only What Search Needs
Every indexed field has a cost. It can increase index size, memory usage, indexing CPU, merge activity, network traffic, and recovery time.
Not every field needs to be searchable.
{
"product_id": 1042, # stored / exact match
"title": "...", # full-text indexed
"description": "...", # full-text indexed
"category_id": 12, # filter
"brand_id": 84, # filter
"price": 199.99, # filter / sort
"rating": 4.7, # ranking / sort
"internal_notes": "...", # do not index
"raw_supplier_payload": {...} # do not index
}
A useful field should have a clear purpose:
| Purpose | Examples |
|---|---|
| Full-text retrieval | title, description |
| Filtering | category, status, availability |
| Sorting | price, created_at |
| Ranking | popularity, rating |
| Display | thumbnail, short description |
Index design should optimize for known access patterns rather than attempting to make every source field searchable.
Keep Queries Efficient
A powerful query language makes it easy to create expensive queries. Search APIs should expose enough flexibility for product requirements without allowing arbitrary operations to consume unbounded cluster resources.
Separate Filtering from Scoring
Some conditions determine relevance. Others simply decide eligibility.
For example:
Query:
"wireless headphones"
Filters:
category = "audio"
price < 300
in_stock = true
The text query should contribute to the score. Availability and price usually should not.
Conceptually:
Candidate Set
|
+--> text relevance -> scoring
|
+--> category -> filter
+--> price -> filter
+--> availability -> filter
Keeping exact conditions in filter context avoids unnecessary scoring work and often makes those conditions easier to cache internally.
The query model should reflect the semantic difference between:
"How relevant is this document?"
and
"Is this document allowed into the result set?"
Avoid Expensive Query Patterns
Some queries can become expensive because they inspect large numbers of terms or documents.
Examples include:
leading wildcard:
"*database"
broad regex:
".*distributed.*"
very large terms lists:
product_id IN [hundreds of thousands of IDs]
large aggregations:
group all matching documents by high-cardinality field
complex scripts:
calculate custom score for every candidate
Allowing such operations through a public API can turn one user request into a large CPU or memory spike.
Search APIs should validate and constrain query complexity.
Typical safeguards include:
maximum query length
maximum number of filters
maximum aggregation count
maximum page size
timeout
maximum wildcard complexity
maximum result window
Protecting cluster capacity is part of API design, not only infrastructure configuration.
Limit Deep Pagination
Traditional page-number pagination can become increasingly expensive at large offsets.
Consider:
page = 10,000
size = 20
offset = 199,980
A distributed search engine may need each relevant shard to identify a large local result set before the coordinator can determine which 20 documents belong to the requested page.
Shard 1 -> top 200,000
Shard 2 -> top 200,000
Shard 3 -> top 200,000
Shard 4 -> top 200,000
|
v
Coordinator
|
v
return 20 rows
Cursor-based approaches are usually more efficient for deep traversal.
First request:
sort = [created_at DESC, id DESC]
Last result:
created_at = 2026-08-30T14:21:00
id = 92017
Next request:
search after that sort position
This avoids repeatedly rebuilding enormous intermediate result windows.
Choose a Sustainable Sharding Strategy
Sharding enables horizontal scale, but more shards do not automatically mean better performance.
Suppose an index is divided into 100 shards and every query touches all of them:
Search Query
|
+---------+---------+
| | |
v v v
... 100 shard requests ...
|
v
Merge Results
Every query now creates scheduling, network, CPU, and merge overhead across many shard-level operations.
Too few shards can create the opposite problem:
2 TB index
|
+--> Shard A: 1 TB
+--> Shard B: 1 TB
Large shards may take longer to move, recover, replicate, or rebalance after failures.
The goal is therefore not minimum or maximum shard count. It is a shard layout that produces manageable shard sizes and sufficient parallelism without excessive fan-out.
Routing can reduce query fan-out when requests naturally target a subset of data.
For example, a multi-tenant system may route documents using tenant_id:
tenant_101
|
v
Shard 3
tenant_205
|
v
Shard 8
A tenant-scoped search can then target the relevant shard instead of broadcasting across the entire cluster.
Shard sizing, replicas, rebalancing, and node roles are explored in more depth in Scaling Elasticsearch Clusters.
Use Caching at the Right Layers
Search workloads often contain repeated requests, but caching effectiveness depends heavily on query distribution.
A layered search architecture may have several cache opportunities:
Client
|
v
CDN / Edge Cache
|
v
Search API Cache
|
v
Search Engine Internal Cache
|
v
Index
Popular anonymous searches can benefit from application-level caching:
query = "iphone"
category = "phones"
sort = "popular"
cache key:
search:iphone:phones:popular
However, highly personalized queries may have almost no reuse:
query
+ user permissions
+ location
+ preferences
+ inventory region
+ experiment variant
The cache key becomes so specific that the hit rate may be poor.
Caching should therefore be driven by measurement rather than added automatically.
Useful cache metrics include:
hit rate
miss rate
eviction rate
cached query latency
uncached query latency
memory usage
stale-result rate
For broader caching architecture patterns, see Designing Multi-Level Caching Architectures.
Control Ranking Cost
Ranking can become one of the most CPU-intensive parts of a search system, especially when custom scripts, machine-learning models, personalization, or external signals are involved.
Applying expensive ranking to millions of matches is usually impractical.
A better architecture narrows the candidate set progressively:
20,000,000 documents
|
v
Lexical Retrieval
|
v
20,000 candidates
|
v
Fast Ranking
|
v
1,000 candidates
|
v
Advanced Reranking
|
v
100 candidates
|
v
Personalization
|
v
Top 20
Each stage can afford more computation because the candidate set is smaller.
A simple first-stage score might use text relevance:
score =
bm25(title, query) * 3 +
bm25(description, query)
A second stage may include business signals:
score =
text_score +
popularity_weight +
freshness_weight +
quality_weight
An expensive model should normally run only against a small top-K candidate set.
This avoids converting relevance improvements into unacceptable tail latency.
The ranking side of search architecture is covered in Search Relevance and Ranking Strategies.
Balance Search and Indexing Throughput
Search clusters often serve two competing workloads:
READ WORKLOAD
search
filters
aggregations
WRITE WORKLOAD
new documents
updates
deletes
segment creation
segment merging
A system optimized entirely for query latency may perform poorly under heavy indexing, while aggressive indexing can consume CPU, disk bandwidth, and memory needed by searches.
Bulk indexing usually improves throughput compared with sending one update at a time:
Bad:
doc
|
network request
|
doc
|
network request
|
doc
|
network request
Better:
[doc, doc, doc, doc, ...]
|
v
Bulk Request
Batch size should still be bounded. Extremely large batches can increase memory pressure and failure cost.
Index refresh frequency is another trade-off:
Frequent refresh
|
+--> fresher search results
+--> more indexing overhead
Less frequent refresh
|
+--> better indexing efficiency
+--> more visibility delay
The correct balance depends on freshness requirements.
A product catalog may tolerate several seconds of indexing delay. Security event search or operational troubleshooting may require much faster visibility.
Capacity Planning and Load Testing
Search capacity cannot be estimated reliably from document count alone.
Two indexes with 100 million documents may have dramatically different resource requirements because of differences in:
- average document size;
- number of indexed fields;
- token count;
- aggregation usage;
- query complexity;
- update rate;
- replica count;
- cache behavior.
A practical capacity model starts with production-like measurements:
documents: 80,000,000
primary index size: 1.8 TB
replicas: 1
total index data: ~3.6 TB
peak search QPS: 4,500
peak indexing rate: 12,000 docs/sec
p95 target: 120 ms
p99 target: 250 ms
Headroom is important because clusters must survive abnormal conditions:
Normal state:
8 nodes
~60% CPU
One node unavailable:
7 nodes
higher shard density
higher CPU
recovery traffic
A cluster running near 100% utilization during normal operation has little ability to absorb failures, traffic spikes, shard relocation, or reindexing.
Load tests should reproduce realistic query distributions rather than repeatedly executing one simple search.
A useful workload may include:
50% common text searches
20% filtered search
10% faceted search
10% autocomplete
5% expensive long-tail queries
5% administrative / analytical queries
Testing should run concurrently with realistic indexing traffic because read-only benchmarks can significantly overestimate production capacity.
Production Design Example
Consider a marketplace serving 100 million products with a peak workload of 8,000 searches per second.
The architecture could look like this:
WRITE SIDE
Product Services
|
v
Primary Databases
|
v
Change Stream
|
v
Indexing Workers
|
| bulk updates
v
Search Cluster
READ SIDE
Clients
|
v
API Gateway
|
v
Search Service
|
+--> Query validation
|
+--> Cache
|
+--> Search Cluster
| |
| +--> candidate retrieval
| +--> filtering
| +--> first-stage ranking
|
+--> Reranker
|
v
Top Results
The search document contains fields required for discovery:
{
"id": 1042,
"title": "Wireless Noise Cancelling Headphones",
"description": "...",
"brand_id": 88,
"category_id": 12,
"price": 199.99,
"rating": 4.7,
"review_count": 8421,
"popularity": 0.93,
"available": true
}
A search request arrives:
q = "wireless noise cancelling headphones"
category = 12
price_max = 250
page_size = 20
The Search Service validates the request and constructs a bounded query:
must score:
title
description
filters:
category_id = 12
price <= 250
available = true
limit:
20
The search cluster retrieves a relatively small candidate set:
100,000,000 documents
|
v
Term Retrieval
|
v
18,000 candidates
|
v
Filters
|
v
4,200 candidates
|
v
First-Stage Ranking
|
v
500 candidates
Only those 500 results are sent through more expensive ranking logic:
500 candidates
|
v
business score
quality score
freshness
personalization
|
v
Top 20
The final result documents already contain enough information to render the search page, avoiding another fan-out to multiple backend services.
Critical actions still validate against authoritative systems:
Search says:
available = true
Checkout:
Inventory Service must confirm availability
This architecture keeps the search path optimized for high-volume reads without confusing search freshness with transactional correctness.
Common Mistakes
Creating Too Many Shards
Small shards may appear attractive because they increase parallelism, but excessive shard counts create overhead in memory, metadata, scheduling, query fan-out, cluster state, and recovery.
1 query
|
v
200 shard-level searches
|
v
200 partial result sets
|
v
merge
Sharding should be based on expected index size, throughput, recovery behavior, and growth rather than a rule that more shards always improve scalability.
Returning Too Much Data
Search APIs often return complete documents even when result pages need only a handful of fields.
Needed:
id
title
price
thumbnail
Returned:
80 document fields
large description
metadata
internal attributes
audit history
This wastes disk reads, network bandwidth, serialization CPU, and client processing.
Result payloads should contain only what the current read path needs.
Allowing Unbounded Queries
An endpoint accepting arbitrary page sizes, aggregations, regex expressions, and scripting effectively allows clients to decide how much infrastructure each request may consume.
Instead, APIs should impose predictable limits:
MAX_PAGE_SIZE = 100
MAX_QUERY_LENGTH = 256
MAX_FILTERS = 20
MAX_AGGREGATIONS = 10
QUERY_TIMEOUT_MS = 1500
Resource limits are essential for protecting tail latency under concurrent load.
Watching Only Average Latency
An average of 70 ms can hide serious production problems.
p50 = 38 ms
p95 = 105 ms
p99 = 920 ms
Users receiving the slowest 1% of requests may experience nearly one second of search latency even though the average appears healthy.
Monitoring should therefore include latency percentiles, timeout rates, rejected requests, queue depth, CPU, heap pressure, disk latency, shard imbalance, cache efficiency, and indexing lag.
Production Checklist
- Define p50, p95, and p99 latency targets before tuning.
- Measure the complete request path, not only search-engine latency.
- Denormalize documents around real search read patterns.
- Index only fields required for retrieval, filtering, sorting, ranking, or display.
- Separate filtering from relevance scoring.
- Reject or limit expensive wildcard, regex, script, and aggregation queries.
- Use cursor-style pagination for deep traversal.
- Avoid excessive shard counts and unnecessary query fan-out.
- Cache only workloads with measurable reuse.
- Apply expensive ranking to a limited candidate set.
- Use bulk indexing and monitor indexing lag.
- Keep capacity headroom for failures, rebalancing, and traffic spikes.
- Load test with realistic query and indexing traffic together.
- Monitor tail latency, resource saturation, and rejected requests.
Conclusion
High-performance search is primarily an architecture problem. The search engine can only perform efficiently when documents, queries, shards, ranking stages, caches, and indexing pipelines are designed around realistic workloads.
The most effective systems keep the hot path small: retrieve candidates through efficient indexes, apply exact filters without unnecessary scoring, rank progressively, avoid deep result windows, minimize downstream calls, and bound every expensive operation.
At the infrastructure level, performance depends on sustainable shard sizes, balanced resource utilization, controlled indexing load, enough failure headroom, and continuous measurement of tail latency rather than averages alone.
Key Takeaway: Fast search comes from reducing the amount of work required per request. Good index design, bounded queries, controlled fan-out, staged ranking, efficient pagination, and realistic capacity planning matter more than isolated configuration tweaks.
Comments (0)