Skip to content
ExponaBLOG
ELASTICSEARCH & SEARCH ENGINEERING · DAY 041 / 100

Elasticsearch performance tuning: lessons from production

Faster queries and indexing.

Expona Admin7 min read

When your Elasticsearch cluster slows down under production load, the culprit isn't always what you expect. We've tuned clusters indexing millions of documents daily and cut query latency from seconds to milliseconds by focusing on a few fundamentals. This article walks through Elasticsearch performance tuning: lessons from production — the practical strategies that work when query times matter and infrastructure costs add up fast.

Filters over queries — Use filter context for exact conditions

The distinction between query and filter context is foundational and often misunderstood. A query calculates relevance scores for every document it evaluates, which is computationally expensive. A filter checks only whether a document matches a condition — it does no scoring and Elasticsearch caches the results. When you're looking for exact matches rather than ranked results, filters are dramatically faster.

Consider a ride-hailing platform tracking active drivers. You need to find all drivers in a specific city within a date range, then rank them by rating. Using a query for the city field wastes CPU scoring millions of matching documents when you only care about the exact match:

json

{
  "query": {
    "bool": {
      "must": [
        {
          "match": {
            "city": "San Francisco"
          }
        },
        {
          "range": {
            "last_active": {
              "gte": "2024-01-01"
            }
          }
        }
      ],
      "must": [
        {
          "match": {
            "rating": 4.5
          }
        }
      ]
    }
  }
}

Rewrite this with filters for exact conditions and query only for ranking:

json

{
  "query": {
    "bool": {
      "filter": [
        {
          "term": {
            "city.keyword": "San Francisco"
          }
        },
        {
          "range": {
            "last_active": {
              "gte": "2024-01-01"
            }
          }
        }
      ],
      "must": [
        {
          "match": {
          "rating": 4.5
          }
        }
      ]
    }
  }
}

Notice the shift: the term query on city.keyword uses filter context (via filter clause). Elasticsearch caches this filter bitmap in memory. Subsequent queries with the same filter execute almost instantly because no scoring happens and results come from the cache. We've seen this pattern reduce query latency by 40–60% on high-cardinality fields like user IDs, tenant IDs, and region codes.

The rule: if you're checking "does this field equal X" or "is this date in range Y", use a filter. Save queries for fields where relevance matters — full-text search, fuzzy matching, boosting.

Avoid wildcards — Leading wildcards are slow

Wildcard queries are tempting. They feel intuitive: search for documents where a field contains a substring. But Elasticsearch has to inspect nearly every term in the inverted index, defeating the purpose of an inverted index. A leading wildcard — *suffix — is especially punishing because Elasticsearch cannot use the index at all and must scan the entire term dictionary.

We once tuned a CRM platform where sales reps searched for account names with patterns like *Corp or Acme*` to find variants. Queries took 3–5 seconds on a 100M-document index. The team had written:

json

{
  "query": {
    "wildcard": {
      "account_name": "*corp*"
    }
  }
}

This query forced Elasticsearch to expand the wildcard against every term in the index, generating thousands of sub-queries. We addressed it two ways:

Option 1: Use n-gram tokenization. At index time, break account names into overlapping 3-character chunks. A document "Acme Corp" becomes tokens like "acm", "cme", "me ", " co", "cor", "orp". Now a trailing-wildcard or exact-match query runs fast:

json

{
  "settings": {
    "analysis": {
      "analyzer": {
        "ngram_analyzer": {
          "type": "custom",
          "tokenizer": "ngram_tokenizer",
          "filter": ["lowercase"]
        }
      },
      "tokenizer": {
        "ngram_tokenizer": {
          "type": "ngram",
          "min_gram": 3,
          "max_gram": 3
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "account_name": {
        "type": "text",
        "analyzer": "ngram_analyzer"
      }
    }
  }
}

Then queries become simple:

json

{
  "query": {
    "match": {
      "account_name": "corp"
    }
  }
}

Option 2: Use a keyword field with lowercase filter. For exact substring matching on short fields (status codes, region names), use a keyword field with a lowercase filter. Then use the prefix query or a match_phrase_prefix, both of which are optimized:

json

{
  "query": {
    "prefix": {
      "status.keyword": "active"
    }
  }
}

The prefix query uses the inverted index and runs in microseconds. In production, this change brought query times down from 3 seconds to 50 milliseconds. Lesson: if you find yourself writing wildcard queries, step back and ask whether n-grams, prefix queries, or application-level filtering would work instead.

Right shard count — Fewer, larger shards often perform better

Shard count is a lever many teams pull without understanding the tradeoff. More shards enable better parallelism and faster indexing when you have many nodes. But fewer, larger shards reduce overhead and are often faster for queries on smaller datasets or clusters.

A travel platform we worked with had a 20-node cluster with 100 shards per index (10 primary, 10 replicas) for a booking search index. Query latency was 2–3 seconds despite plenty of CPU headroom. The culprit: coordinating node overhead. With 100 shards, every query had to fan out to 10 nodes, collect results, and merge — a process that itself consumes network and CPU. Worse, many shards held only millions of documents each, so per-shard query time was fast, but the coordination cost dominated.

We calculated: the index was 500 GB. Resized to 5 primary shards (100 GB per shard), average query latency dropped to 200–400 ms. The math:

Metric Before After
Primary shards 10 5
Shard size (GB) 50 100
Query fan-out 10 nodes 5 nodes
P99 query latency (ms) 3000 350
Indexing throughput (docs/sec) 500k 480k

The indexing throughput barely changed because we still had replicas and the bottleneck was network, not shard count. Query latency improved dramatically.

The heuristic we use: aim for shards between 20–50 GB. Smaller shards are useful only if you need to scale horizontally beyond your current hardware or if you're doing time-based indices (e.g., daily logs where old indices can be deleted). For application data, larger shards with fewer of them usually win. Use the Cluster Stats API to validate:

json

GET /_cluster/stats

Look at indices.shards.total and indices.store.size_in_bytes. If total shards is growing much faster than data size, you have over-sharding.

Profile — The Profile API shows slow query parts

Guessing at bottlenecks is expensive. Elasticsearch provides the Profile API to show exactly where time is spent in a query. Use it whenever a query is slow or before optimizing:

json

GET /bookings/_search
{
  "profile": true,
  "query": {
    "bool": {
      "filter": [
        { "term": { "status.keyword": "completed" } }
      ],
      "must": [
        { "match": { "destination": "Paris" } }
      ]
    }
  }
}

The response includes a profile object with timing for each query clause and which shards were queried:

json

{
  "profile": {
    "shards": [
      {
        "shard": 0,
        "searches": [
          {
            "query": [
              {
                "type": "BooleanQuery",
                "description": "status:completed",
                "time_in_nanos": 50000,
                "breakdown": {
                  "score_calculation": 5000,
                  "other": 45000
                }
              },
              {
                "type": "TermQuery",
                "description": "destination:paris",
                "time_in_nanos": 2500000,
                "breakdown": {
                  "score_calculation": 2000000,
                  "other": 500000
                }
              }
            ]
          }
        ]
      }
    ]
  }
}

From this profile, you see the match query on destination took 2.5 ms (likely because it searched a large inverted index), while the term filter took 50 µs (cached). If the match query dominated, you'd consider n-grams, alias the field to a keyword, or narrow the query scope. Profile output guides optimization; it's the difference between shooting in the dark and aiming at the problem.

Common mistakes

  • Mixing queries and filters indiscriminately. New teams often use must queries for all conditions. Moving high-cardinality exact matches into filter context can cut latency in half with no code changes.
  • Wildcard queries on unanalyzed text. Applying wildcard queries to fields without n-grams or prefix optimization is a classic sign of a query design that needs rethinking.
  • Over-sharding for simplicity. It's tempting to create many small shards "just to be safe." Measure the impact with the Profile API before committing to a high-shard topology.
  • Profiling only in dev. Production data distributions and query patterns differ. Always profile production queries under realistic load before tuning.
  • Ignoring JVM heap and garbage collection. Performance tuning in Elasticsearch isn't just query-level. A full garbage collection pause can dwarf query latency. Monitor heap usage and tune the JVM heap (typically 31 GB for a single node).

Key takeaways

  • Filters cache results and skip scoring; use them for exact conditions like date ranges and exact field matches.
  • Avoid leading wildcards by adopting n-gram tokenization or prefix queries instead of wildcard queries.
  • Aim for 20–50 GB shards. Fewer, larger shards often outperform many small shards by reducing coordination overhead.
  • Always profile slow queries with the Profile API to see where time is actually spent before optimizing.
  • Monitor shard count relative to data size. Over-sharding is a silent killer of query performance in production.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles