Performance & the storage/caching model
|
This section documents the current Elasticsearch 9.x line (with 8.19 as the final 8.x release) as published at the Elasticsearch documentation, which is the reference these pages are written and verified against. No specific patch version is pinned. Some capabilities (Kibana-only UIs, the ML/NLP model-management workflow, cross-cluster replication, and parts of the paid / serverless-only surface) are linked, not documented in depth. This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production, as Elasticsearch iterates quickly. This section’s bibliography lists the reference material consulted while preparing these pages. |
Elasticsearch stores each shard as a growing set of immutable Lucene segments, buffers writes in memory, and serves reads from several caches layered on top. Tuning is mostly a matter of understanding that model: how often buffered writes become searchable, how segments are persisted and merged, and which query shapes and mapping choices let a cache do the work. This page groups the knobs by the goal they serve — indexing speed, search speed, and disk usage.
Indexing speed
Size your _bulk requests
Index through the _bulk API, not
single-document requests: one round trip carries many actions and amortises coordination cost. There
is no universally correct batch size — start around 5—15 MB of raw data per request, measure
indexing throughput, and increase until it plateaus or you see es_rejected_execution_exception
from a full write queue. Send bulk requests from several client threads in parallel to keep every
data node busy.
# One request, many documents. Each action is two lines: metadata, then the doc.
POST /logs-2024.05/_bulk
{ "index": {} }
{ "@timestamp": "2024-05-01T10:00:00Z", "level": "INFO", "msg": "started" }
{ "index": {} }
{ "@timestamp": "2024-05-01T10:00:01Z", "level": "ERROR", "msg": "boom" }
# https://www.elastic.co/guide/en/elasticsearch/reference/current/tune-for-indexing-speed.html
Let Elasticsearch generate the _id
Omitting _id (the empty index action line above) lets Elasticsearch assign a UID it knows is
new, so it skips the "does this id already exist?" lookup that every explicit-id index or upsert
must do against existing segments. That check gets more expensive as the shard grows, so
auto-generated ids keep bulk-load speed flat. Use an explicit id only when the source system’s key
is the natural identity and you need idempotent re-indexing.
Raise refresh_interval and drop replicas during a bulk load
A refresh turns the in-memory indexing buffer into a new searchable segment. It happens automatically about once per second on an index that is being searched, which is what makes Elasticsearch near real-time rather than real-time — see Near real-time search. Each refresh has a fixed cost, so during a large one-off load, lengthen the interval (or disable it) and force one refresh at the end.
# Before a bulk backfill into a fresh index: no periodic refresh, no replicas.
PUT /events/_settings
{
"index": {
"refresh_interval": "-1",
"number_of_replicas": 0
}
}
# ... run the parallel _bulk load ...
# Restore normal behaviour; setting replicas back triggers a fast segment copy
# rather than replaying every indexing operation on the replica.
PUT /events/_settings
{
"index": {
"refresh_interval": "1s",
"number_of_replicas": 1
}
}
POST /events/_refresh
Indexing into events with one primary and zero replicas means each document is indexed once
instead of twice; adding the replica afterwards copies finished segments over the network, which is
cheaper than concurrent indexing on both copies. Keep at least one replica for anything you cannot
afford to rebuild.
The translog and index.translog.durability
A refresh makes writes visible; it does not make them durable. Durability comes from the
translog, an append-only log each shard writes every operation to. By default
(index.translog.durability: request) the translog is fsync-ed before every indexing request is
acknowledged, so an acknowledged write survives a node crash. Setting it to async fsyncs only
every index.translog.sync_interval (default 5s), which raises indexing throughput at the cost of
losing the last few seconds of acknowledged writes on a crash — acceptable for data you can replay
from an upstream source.
PUT /metrics/_settings
{
"index": {
"translog.durability": "async",
"translog.sync_interval": "30s"
}
}
# https://www.elastic.co/guide/en/elasticsearch/reference/current/index-modules-translog.html
A flush writes the current in-memory segments to disk via a Lucene commit and then trims the
translog. It runs automatically when the translog grows past index.translog.flush_threshold_size
(default 512mb); you rarely trigger it by hand.
Segments: refresh, flush, merge
Because segments are immutable, every refresh creates another one, a delete only marks a document as
gone in a .liv file, and an update is a delete plus a re-index. Left alone, a busy shard would
accumulate thousands of small segments, each an extra file to open and search. Lucene continuously
merges smaller segments into fewer larger ones in the background, physically dropping deleted
documents as it goes. Merging is I/O- and CPU-heavy; indices.merge.scheduler.max_thread_count
bounds it per shard.
_forcemerge for indices that stop changing
Once a time-based index (yesterday’s logs, last month’s orders) will receive no more writes, merge it down to one segment. This removes all deleted-document overhead and gives the fastest possible searches, but it is expensive and must run only on indices that are truly read-only — a force-merged segment is never picked for automatic merging again, so if writes resume you can be left with one huge segment full of deletes.
POST /logs-2024.04.30/_forcemerge?max_num_segments=1
# https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-forcemerge.html
Index lifecycle & scaling automates the rollover-then-forcemerge pattern with ILM.
Search speed
Full guidance is in Tune for search speed; the highest-leverage points follow.
Put binary conditions in filter context
A clause in filter context answers yes/no, computes no _score, and its result bitset is cached
in the node query cache for reuse across queries on the same shard. A clause in query context
computes relevance and is not cached. Move every exact condition — terms, ranges, dates, status
flags — into filter (or must_not), and keep only the clauses whose ranking you actually use in
must / should.
GET /products/_search
{
"query": {
"bool": {
"must": [ { "match": { "title": "wireless headphones" } } ],
"filter": [
{ "term": { "in_stock": true } },
{ "range": { "price": { "lte": 200 } } },
{ "terms": { "brand": [ "acme", "globex" ] } }
]
}
}
}
# https://www.elastic.co/guide/en/elasticsearch/reference/current/query-filter-context.html
Search API & pagination covers the two contexts in more detail.
The node query cache and the shard request cache
Two independent caches sit in front of shard-level work:
-
Node query cache — caches the document bitset produced by a filter-context clause, per segment, shared by all shards on the node. Governed by
indices.queries.cache.size(default 10% of heap). It kicks in only for segments large enough to be worth caching and for filters reused often enough. -
Shard request cache — caches the whole response of a search per shard, keyed by the exact request body. By default it caches only requests with
size: 0(that is, pure aggregation and hit-count requests), which is why dashboard panels benefit most. It is invalidated on the next refresh of the shard.
# Force the shard request cache on for a request that returns hits too.
GET /logs-*/_search?request_cache=true
{
"size": 0,
"aggs": { "by_level": { "terms": { "field": "level" } } }
}
GET /logs-*/_stats/request_cache,query_cache
# https://www.elastic.co/guide/en/elasticsearch/reference/current/shard-request-cache.html
preference for cache locality
By default Elasticsearch spreads successive searches across shard copies (adaptive replica
selection). Passing a stable preference string — a user id, a session id — routes that user’s
repeated searches to the same copies, so they hit warm filesystem cache, node query cache, and (for
size: 0) shard request cache.
GET /products/_search?preference=session_4f21c9
{ "query": { "match": { "title": "laptop stand" } } }
# https://www.elastic.co/guide/en/elasticsearch/reference/current/search-shard-routing.html
search_after instead of deep from / size
from / size paging makes every shard build from + size hits and is capped at
index.max_result_window (10000). For anything beyond the first few pages use search_after with a
point-in-time, which costs the same per page no matter how deep you are. See
Paginate search results
and the worked example in
Search API & pagination.
Map for the query you run
-
Index identifiers, enums, and anything you filter or aggregate on as
keyword, nottext:keywordis a single un-analysed term withdoc_values, sotermfilters and aggregations are fast and exact. Reservetextfor fields you runmatch/match_phraserelevance queries against. -
eager_global_ordinals: trueon akeywordfield you runtermsaggregations orsignificant_termson moves the global-ordinal build from first-query time to refresh time, so the first aggregation after each refresh is not slow. It costs a little refresh time, so set it only where it pays off. -
index: falseon a field you store and return but never filter by (an opaque payload, a pre-computed URL) removes it from the inverted index — smaller index, faster indexing. It stays in_sourceand, for most types, indoc_valuesfor sorting and aggregation.
PUT /orders
{
"mappings": {
"properties": {
"status": { "type": "keyword", "eager_global_ordinals": true },
"description": { "type": "text" },
"receipt_url": { "type": "keyword", "index": false }
}
}
}
# https://www.elastic.co/guide/en/elasticsearch/reference/current/eager-global-ordinals.html
Mapping & field types is the full reference for these parameters.
The fielddata trap
Sorting, aggregating, or scripting on a field needs its values in a columnar structure. For
keyword, numeric, date, boolean, and geo_point fields that structure is doc_values:
built at index time, stored on disk, memory-mapped, essentially free. For analysed text fields
there are no doc_values; the only way to get per-document terms is fielddata, an on-heap
structure built by un-inverting the index on first use. It can consume a large fraction of the heap
and cause circuit-breaker trips or OutOfMemoryError, which is why fielddata is disabled by
default on text.
# Fails: "Fielddata is disabled on [title] in [books]. Set fielddata=true ..."
GET /books/_search
{
"size": 0,
"aggs": { "titles": { "terms": { "field": "title" } } }
}
# Right fix: aggregate on the keyword multi-field.
GET /books/_search
{
"size": 0,
"aggs": { "titles": { "terms": { "field": "title.keyword" } } }
}
# https://www.elastic.co/guide/en/elasticsearch/reference/current/text.html#fielddata-mapping-param
A standard text mapping from dynamic mapping already gives you a .keyword sub-field (up to
ignore_above: 256). Sort and aggregate on field.keyword; enable "fielddata": true only for the
rare case of aggregating on analysed tokens, and then bound it with fielddata_frequency_filter.
Going the other way: set "doc_values": false on a keyword or numeric field you are certain will
never be sorted, aggregated, or scripted on. That reclaims the disk the column would use. You cannot
add doc_values back without reindexing.
PUT /events
{
"mappings": {
"properties": {
"trace_id": { "type": "keyword", "doc_values": false }
}
}
}
# https://www.elastic.co/guide/en/elasticsearch/reference/current/doc-values.html
Disk usage
Full list in Tune for disk usage.
Better compression
index.codec: best_compression switches stored fields and _source from LZ4 to DEFLATE, typically
cutting on-disk size 15—25% in exchange for slightly slower fetches and merges. Set it at creation
time; it applies to segments written afterwards, so pair it with _forcemerge on existing read-only
indices.
PUT /archive-2023
{
"settings": { "index.codec": "best_compression" }
}
# https://www.elastic.co/guide/en/elasticsearch/reference/current/index-modules.html#index-codec
_source: the cost of turning it off
_source is the stored original JSON. Disabling it saves disk, but you lose everything that reads
the document back: reindex, update and
update-by-query, the highlight feature, and the ability to reindex into a new mapping at all.
Prefer _source filtering on the way out, or synthetic _source, over disabling it. If you still
must:
PUT /telemetry
{
"mappings": { "_source": { "enabled": false } }
}
# https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-source-field.html
Synthetic _source
Synthetic _source stops storing the raw JSON and instead reconstructs it on demand from
doc_values and the inverted index. You keep reindex, update, and highlight while paying roughly
the storage of the fields themselves and no more. The reconstructed document is normalised (fields
sorted, values de-duplicated, some formatting lost), which is fine for most uses.
PUT /logs-synthetic
{
"mappings": {
"_source": { "mode": "synthetic" },
"properties": {
"@timestamp": { "type": "date" },
"level": { "type": "keyword" },
"message": { "type": "text" }
}
}
}
# https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-source-field.html#synthetic-source
Turn off norms and doc_values you do not use
-
"norms": falseon akeyword-heavytextfield, or on any field you match but never rank by, removes the per-document length factor Elasticsearch keeps for scoring — a small per-field saving that adds up across many fields. -
"doc_values": false(above) on fields never sorted or aggregated. -
"index": false(above) on fields never filtered.
PUT /catalog
{
"mappings": {
"properties": {
"sku": { "type": "keyword", "doc_values": false },
"notes": { "type": "text", "norms": false },
"vendor_blob": { "type": "keyword", "index": false }
}
}
}
# https://www.elastic.co/guide/en/elasticsearch/reference/current/norms.html
See also
-
The distributed model — shards, replicas, and how a search fans out across them.
-
Search API & pagination — query vs. filter context and
search_afterin full. -
Mapping & field types — every mapping parameter named on this page.