Taming Elasticsearch at Scale

Oct 13, 2026

Taming Elasticsearch at Scale

Optimize Elasticsearch for millions of records with better pagination, leaner payloads, efficient filtering, faster ingestion, and frontend techniques that keep search responsive with increase in data.

Elasticsearch is useful for searching an increasing dataset that starts adding pressure on the database and the application. Instead of checking millions of records one by one, it builds an index that lets the system locate relevant data without scanning the entire dataset for every request. This makes it useful for full-text search, filtering, logs, product catalogs, reports, and other applications where users expect results as they type or filter. But adding Elasticsearch does not automatically make a search system fast. Query design, index mappings, response size, data ingestion, and frontend rendering can all affect how quickly a search feels to the person using it.

Here is a full-stack guide to searching millions of records without freezing the frontend.

It's a story most of us have lived through in some form.

Your database starts groaning under the weight of a few million records. Every LIKE %query% clause is a small act of violence against your CPU. So the team makes the obvious call: bring in Elasticsearch.

For a while, it's magic. Queries come back in 15 milliseconds. Full-text search just works. Everyone's happy.

Then, six months later, the index has grown into something with millions of dense, deeply nested documents, and the complaints start rolling in: "Every time I search the reports page, the screen freezes for four seconds, my fan spins up, and sometimes the tab just dies."

Which is confusing, because Elasticsearch is built to query petabytes in milliseconds. So why does it feel like the frontend is choking?

In practice, it almost never has anything to do with Elasticsearch's raw power. The real bottleneck is nearly always some combination of how the queries are written, how the data was indexed in the first place, how much JSON gets shipped over the wire, and what the browser is asked to do with it once it arrives.

This is a walk through the places that tend to go wrong — on the backend, at index time, and in the browser — and what to do instead.

Six Elasticsearch performance bottlenecks across pagination, payloads, filtering, indexing, ingestion, and frontend rendering
Six independent bottlenecks — backend, indexing, and frontend — each with a small, specific fix.

1. The deep pagination trap

Say you're building a paginated table. The natural instinct is page-number pagination:

// Page 100, 50 items per page 
{ 
"from": 5000, 
"size": 50, 
"query": { "match_all": {} } 
} 

Looks harmless enough. Here's what it actually does to your cluster.

If your index is spread across 5 shards and you ask for items 5,000–5,050, each shard has to hand its top 5,050 documents to the coordinator node — not just the 50 you want. That's 5 × 5,050 = 25,250 documents pulled into memory, sorted, and then almost entirely thrown away, just so the coordinator can hand you the 50 that were left over.

By the time users are clicking into page 200, your cluster is burning most of its CPU sorting documents it's about to discard. Push it far enough and Elasticsearch will simply refuse — from + size beyond 10,000 hits the index.max_result_window limit by default. (Plenty of teams just raise that limit instead of fixing the underlying issue, which only postpones the memory problem.)

There isn't one correct fix here — it depends on how people actually navigate your UI.

If you have clickable page numbers ("1, 2, 3 ... 25") and people genuinely jump around, from / size is fine. Just cap how deep it goes. Restricting navigation to roughly the first 200 pages ( from + size < 10,000 ) is safe for almost any real-world dataset.

If you have infinite scroll or a "Load more" button, reach for search_after instead:

{ 
"size": 50, 
"query": { "match_all": {} }, 
"sort": [ 
{ "created_at": "desc" }, 
{ "_id": "asc" } 
], 
"search_after": ["2026-09-20T14:30:00Z", "doc-10482"] 
} 

Instead of asking for "page 50," you're saying "give me whatever comes right after this document." Each shard jumps straight to that point in its sorted index — no sorting through thousands of discarded documents, no memory spike. Item 50 and item 5,000,000 cost roughly the same.

The catch: search_after only moves forward. There's no jumping to an arbitrary page — it's a cursor, not a page number, and your UI needs to reflect that.

Elasticsearch deep pagination with from and size compared with efficient search_after cursor pagination
from/size forces every shard to hand over documents it then throws away. search_after skips straight to where it left off.

If you're exporting thousands of rows to CSV or PDF, use a Point-in-Time (PIT) reader combined with search_after . PIT freezes a consistent snapshot of the index, so the export doesn't shift under you if documents get added mid-run.

2. Stop sending the whole warehouse back

Documents in a real system tend to accumulate weight. Customer profiles, nested inspection logs, historical audit trails — it's not unusual for a single document to sit at 50–200 KB.

Now return 50 of those without restricting fields, and you're shipping something like 5 MB of JSON for a single page load. And what actually happens with that 5 MB?

It takes over a second just to download.

The browser has to parse a multi-megabyte string, which locks up the main thread while it does.

The UI stutters, animations skip, and the user assumes something's broken.

Meanwhile, your table is probably showing five columns: ID, code, date, inspector, status.

The fix is boring but effective — tell Elasticsearch exactly which fields you actually need:

{ 
"size": 50, 
"_source": [ 
"id", 
"report_code", 
"start_date", 
"inspector_name", 
"status" 
], 
"query": { ... } 
} 

Strip out the nested arrays and raw submission blobs you're not displaying, and that 5 MB response often drops to something like 40 KB. At that size, parsing takes a couple of milliseconds and the UI never even notices.

3. Don't score things that can't be scored

Elasticsearch draws a real distinction between two kinds of query clauses:

Query context ( must , should ) — "how well does this match?" It runs BM25 scoring and produces a _score . Filter context ( filter , must_not ) — "does this match, yes or no?" No scoring. Purely binary.

A mistake that shows up constantly:

// Slow — forces scoring on things that don't have a "degree" of matching 
{ 
"query": { 
"bool": { 
"must": [ 
{ "match": { "status": "approved" } }, 
{ "range": { "start_date": { "gte": "2026-01-01" } } }, 
{ "match": { "project_name": "Hospital" } } 
] 
} 
} 
} 

Is one "approved" report more approved than another? Obviously not — it's binary. But by putting exact-match conditions like status and date inside must , you're asking Elasticsearch to spend CPU scoring things that were never going to have a meaningful score. Worse, clauses inside must skip the filter cache entirely.

Move anything binary into filter :

// Fast — only the full-text part gets scored 
{ 
"query": { 
"bool": { 
"must": [ 
{ "match": { "project_name": "Hospital" } } 
], 
"filter": [ 
{ "term": { "status.keyword": "approved" } }, 
{ "range": { "start_date": { "gte": "2026-01-01" } } } 
] 
} 
} 
} 

The payoff isn't just skipping scoring — Elasticsearch also caches filter clauses as bitmap structures in memory. The next time anyone filters by status: approved , that part of the query resolves in microseconds without touching the disk.

A decent rule of thumb: if a field is either "yes" or "no," with no concept of how well it matches, it belongs in the filter.

Elasticsearch query context with scored matches compared with cached filter context for exact conditions
Same conditions, two different execution paths. Binary conditions belong on the right.

4. The leading wildcard problem

When people want search-as-you-type or partial matching, the tempting shortcut looks like this:

{ 
"query": { 
"query_string": { 
"query": "*concrete*", 
"fields": ["notes", "sample_description"] 
} 
} 
} 

A leading wildcard — *concrete , not concrete* — essentially disables the inverted index. Think of the index like the back of a textbook: search for "concrete" and you flip straight to the right page. Search for *concrete* and the book is useless to you — you're back to reading every word on every page, because there's no way to know in advance what might precede "concrete."

Run that across a few million documents and you get exactly the CPU spikes and multi-second delays you'd expect.

A leading wildcard defeats the inverted index. A prefix built at index time turns the same lookup into one hop.
A leading wildcard defeats the inverted index. A prefix built at index time turns the same lookup into one hop.

The real fix happens at index time, not query time. The simplest route is Elasticsearch's built-in search_as_you_type field type. First, declare the field this way when you create (or update) your mapping:

PUT /reports 
{ 
"mappings": { 
"properties": { 
"sample_description": { 
"type": "search_as_you_type" 
} 
} 
} 
} 

Elasticsearch quietly builds sub-fields behind the scenes ( sample_description._2gram , ._3gram , and so on) that store prefix tokens. The word "concrete" ends up indexed as something like ["co", "con", "conc", "concr", "concre", "concret", "concrete"] . Then query with bool_prefix instead of a wildcard:

{ 
"query": { 
"multi_match": { 
"query": "conc", 
"type": "bool_prefix", 
"fields": [ 
"sample_description", 
"sample_description._2gram", 
"sample_description._3gram" 
] 
} 
} 
} 

Now typing "conc" resolves as a direct term lookup against pre-built tokens instead of a full scan of the cluster.

The tradeoff: n-gram indexing inflates the size of the affected fields, typically by something like 2–3x, since you're now storing every prefix variant on disk. For most applications that's a non-issue next to the speed gain — but if storage is tight, you can bound the range ( min_gram: 3, max_gram: 10 ) to keep it in check.

5. The part everyone forgets: how you write data in

Most of the attention in Elasticsearch tuning goes to queries, but a surprising share of real-world slowness starts at ingestion. This pattern shows up in almost every codebase the first time a team wires up Elasticsearch:

# One HTTP request per document — the classic mistake 
reports = get_all_pending_reports() 
for report in reports: 
document = build_document(report) 
elasticsearch.index(index="reports", body=document) 

5,000 reports means 5,000 separate HTTP round-trips, each one negotiating its own connection and potentially triggering a refresh. What should take a few seconds can easily stretch into 10–15 minutes.

The Bulk API exists precisely for this — batch hundreds or thousands of operations into a single request:

reports = get_all_pending_reports() 
batch = [] 
for report in reports: 
document = build_document(report) 
batch.append({ "index": { "_index": "reports" } }) 
batch.append(document) 
if len(batch) >= 1000: # 500 docs per flush (action + doc = 2 lines each) 
elasticsearch.bulk(body=batch) 
batch = [] 
if batch: 
elasticsearch.bulk(body=batch) # flush whatever's left 

Batching 500 documents per call turns 5,000 individual requests into 10. The difference is dramatic in practice — bulk indexing typically runs somewhere around 10–50x faster than indexing one document at a time.

Individual Elasticsearch indexing requests compared with Bulk API batching for 5,000 documents
Same 5,000 documents, radically fewer round trips

One request per document

Two smaller things worth doing alongside this:

Loosen the refresh interval during heavy writes. By default, Elasticsearch refreshes every index every second so new documents become searchable almost immediately. During a large backfill, that constant refreshing just burns CPU for no benefit. Turn it down while you're bulk-loading:

PUT /reports/_settings 
{ "index": { "refresh_interval": "30s" } } 

and set it back once you're done:

PUT /reports/_settings 
{ "index": { "refresh_interval": "1s" } } 

Give documents a stable ID. If a pipeline ever retries after a partial failure, it can easily index the same record twice. Use a consistent ID — your database primary key works well — so Elasticsearch overwrites the old copy instead of quietly duplicating it:

# Passing "id" turns this into an upsert — no duplicate rows 
elasticsearch.index(index="reports", id=report.id, body=document) 

6. Don't let the browser undo all of that work

Even a 20-millisecond Elasticsearch response can still end in a frozen tab if the frontend isn't careful. Three things worth having in place:

Debounce input. Typing "Bridge Inspection" is 17 keystrokes in about a second and a half. Fire a request per keystroke and you've just sent 17 overlapping searches. A 300ms debounce — wait until the user actually pauses — fixes this outright.

Cancel stale requests. If someone types "B" and then quickly finishes "Bridge," the request for "B" is now irrelevant. If it happens to resolve after the "Bridge" request, your table briefly shows the wrong results. AbortController handles this cleanly:

let currentController = null; 
function fetchSearchResults(query) { 
if (currentController) { 
currentController.abort(); // kill the previous request 
} 
currentController = new AbortController(); 
fetch(`/api/search?q=${encodeURIComponent(query)}`, { 
signal: currentController.signal 
}) 
.then(res => res.json()) 
.then(data => renderTable(data.items)) 
.catch(err => { 
if (err.name !== 'AbortError') console.error('Fetch error:', err); 
}); 
} 

Batch your DOM writes. Inserting 200 rows one at a time into a live table forces the browser to recalculate layout after each insertion. Build everything off-screen first, then insert it in one shot:

function renderTable(items) { 
const tableBody = document.getElementById('tableBody'); 
const fragment = document.createDocumentFragment(); 
items.forEach(item => { 
const row = document.createElement('tr'); 
row.innerHTML = ` 
<td>${item.id}</td> 
<td>${item.report_code}</td> 
<td>${item.inspector_name}</td> 
<td>${item.status}</td> 
`; 
fragment.appendChild(row); 
}); 
tableBody.innerHTML = ''; 
tableBody.appendChild(fragment); // single repaint 
} 

Worth flagging: building rows this way with innerHTML is only safe if none of those fields can contain arbitrary user text. If inspector_name or similar could ever hold something a user typed, that's an XSS opening — building rows with textContent instead closes it off.

Past 500 rows or so, consider a virtualized table that only renders the 15–20 rows actually visible in the viewport, rather than materializing the entire result set as DOM nodes.

What it looks like put together

None of these changes are individually dramatic. Together, they compound. For an application with roughly 800,000 indexed documents, applying all six optimizations could produce improvements in this range:

Metric

Before

After

Search response time

~3.2s

~45ms

JSON payload size

~4.8 MB

~38 KB

Frontend render time

~1,400ms

~12ms

Bulk reindex (5,000 docs)

~14 min

~8 sec

Cluster CPU during search

~78% sustained

Single-digit spikes

(These are illustrative figures based on the kind of improvement this set of changes typically produces — worth replacing with your own measured numbers once you've made the changes on your own system.)

Elasticsearch optimization results for response time, payload size, rendering, reindexing, and CPU usage
Every bar comes from the before/after table above — same numbers, easier to compare at a glance.

A 45ms response with a 38KB payload that renders in 12ms doesn't just feel fast — it feels instant, even on a phone over 4G.

Cheatsheet

Use this cheatsheet to quickly match common Elasticsearch performance issues with their likely cause and fix.

Symptom

What's Actually Happening

Fix

Deep pagination (from: 5000)

Coordinator sorts and discards thousands of docs per shard

search_after for infinite scroll; cap from/size for page-number UIs

Giant payloads (5MB JSON)

Full documents returned when only a few fields are shown

_source filtering

Slow filtering

Exact-match fields scored unnecessarily

Move to bool.filter for caching

Leading wildcards (*query*)

Inverted index can't be used, full scan instead

search_as_you_type + n-grams

Slow bulk imports

One HTTP request per document

Bulk API, batched

UI freezes while typing

No debounce, requests racing

300ms debounce + AbortController

Table rendering stutters

DOM reflow on every row insert

DocumentFragment or virtualization

The Underlying Idea

Every fix above is really the same move, applied in a different place: figure out where work is happening, and ask whether it's happening at the right time.

Push computation to index time (n-grams, explicit mappings) instead of query time (wildcards). Push filtering into the cache layer ( bool.filter ) instead of the scoring layer ( bool.must ). Push payload decisions onto the backend ( _source filtering) instead of asking the browser to parse everything and use a fraction of it. Push DOM construction into memory ( DocumentFragment ) instead of doing it row-by-row on the live page.

If you want a concrete place to start: open your slowest search endpoint right now and add _source filtering so it only returns the fields your UI actually shows. Check the response size before and after. That alone is usually a five-minute change that cuts payload by 90% and makes the page feel noticeably different.

Everything else in this piece can follow from there, one endpoint at a time.

Turn Elasticsearch Performance Into Product Performance

A fast Elasticsearch query means little if the API sends megabytes of JSON or the browser spends a second rendering it. Search performance has to be addressed across indexing, APIs, infrastructure, and the frontend. GeekyAnts helps teams optimize these systems through enterprise backend development and modernization, covering backend architecture, data pipelines, performance tuning, and systems built to handle growing data volumes. The goal is simple: keep search responsive as the dataset grows, without making every new record or user request more expensive than it needs to be.

Subscribe to Our Newsletter

More from the engineering frontline.

Dive deep into our research and insights on design, development, and the impact of various trends to businesses.
Insight
Deep Linking for Mobile Apps(React Native) with NestJs, AWS CloudFront, S3 & ALB: A Complete Guide
Oct 13, 2026

Deep Linking for Mobile Apps(React Native) with NestJs, AWS CloudFront, S3 & ALB: A Complete Guide

Build a self-hosted deep linking and referral system for React Native without a third-party SDK. This guide covers Android App Links, iOS Universal Links, an S3 and CloudFront setup, and a NestJS backend that routes users to the right app store.

Insight
How We Engineered a Three-Path Reward Processing System for a Real-Time Sports Platform
Oct 12, 2026

How We Engineered a Three-Path Reward Processing System for a Real-Time Sports Platform

This blog explains how a real-time sports platform uses synchronous, asynchronous, and scheduled processing paths to handle rewards without slowing down the player experience.

Insight
Building an MCP-Powered QA Agent
Oct 12, 2026

Building an MCP-Powered QA Agent

Build an MCP QA agent that runs Playwright tests, validates APIs, and analyzes failures through a controlled, auditable tool layer with human approval.

Insight
From Test Failure to Root Cause: Building an AI-Assisted Playwright Failure Analysis Pipeline
Oct 9, 2026

From Test Failure to Root Cause: Building an AI-Assisted Playwright Failure Analysis Pipeline

This blog explains how an AI-assisted Playwright pipeline can analyze test failure evidence, identify probable root causes, and support human-validated debugging.

Insight
The Model Context Protocol: From First Call to Production
Oct 8, 2026

The Model Context Protocol: From First Call to Production

This blog explains how Model Context Protocol (MCP) works, from tool discovery and execution to OAuth authorization, security controls, and production deployment.

Insight
Stop Automating Everything: A Balanced Quality Engineering Approach to Testing
Oct 8, 2026

Stop Automating Everything: A Balanced Quality Engineering Approach to Testing

Balanced quality engineering places automation, API testing, exploratory work, and AI where each gives the most value, so teams ship faster without trading away user-perceived quality.

Insight
From Rolling Deployments to Zero-Downtime Releases
Oct 6, 2026

From Rolling Deployments to Zero-Downtime Releases

This blog explains how Blue-Green deployment helps reduce downtime in online banking releases through traffic switching, pod readiness, static-resource versioning, and rapid rollback.

Footer

The Right Conversation Can

Save You Six Months.

Book a Call