⚑ Elasticsearch

Complete Topic-Wise Guide with Node.js Implementation

Deep behind-the-scenes explanations β€’ Every argument explained β€’ Interview-ready

PART 1 β€” Elasticsearch Concepts

Everything you need to understand before writing a single line of code

1. What is Elasticsearch?

Elasticsearch is a distributed, open-source, RESTful search and analytics engine built on top of Apache Lucene. It stores data as JSON documents and provides near real-time search β€” meaning data you index becomes searchable within ~1 second.

"Elasticsearch is a distributed search engine built on Apache Lucene. It stores JSON documents, builds an inverted index on them, and lets you search millions of records in milliseconds via a REST API. It's schema-free, horizontally scalable, and designed for full-text search, log analytics, and real-time monitoring."

What actually happens when ES starts:
1. ES launches a JVM process (it's written in Java)
2. It reads elasticsearch.yml for cluster name, node name, ports, paths
3. It binds to port 9200 (REST API for your app) and port 9300 (transport protocol for node-to-node communication)
4. It joins or forms a cluster by discovering other nodes via seed hosts
5. A master node is elected using a quorum-based algorithm
6. The master manages the cluster state β€” a metadata map of every index, shard, and which node holds what
7. Each data node initializes its Lucene instances (one per shard) and loads the inverted index into memory

PropertyValue
Built OnApache Lucene (Java library for full-text indexing)
ProtocolRESTful HTTP + JSON
Written InJava
LicenseSSPL / Elastic License 2.0 (was Apache 2.0 before v7.11)
Search SpeedNear real-time (~1 second after indexing)
Data FormatJSON Documents (schema-free)
Default Ports9200 (REST API), 9300 (Transport/Node-to-Node)

2. Why Use Elasticsearch?

Traditional databases (MySQL, PostgreSQL) use B-tree indexes which are great for exact lookups and range queries, but terrible for full-text search. Searching "best noise cancelling headphones" across millions of product descriptions would require a full table scan or a slow LIKE query.

Elasticsearch solves this with an inverted index β€” the same data structure used by Google, Wikipedia search, and every search engine.

"We use Elasticsearch when we need blazing-fast full-text search, complex filtering, or real-time analytics. Unlike SQL databases that scan rows sequentially, ES pre-builds an inverted index β€” mapping every word to the documents containing it. This gives us O(1) term lookups instead of O(n) scans. It's commonly used alongside a primary database β€” Postgres as source of truth, ES for search."

Use CaseExampleWhy ES Wins
Full-text SearchE-commerce product searchRelevance scoring, fuzzy match, synonyms, stemming
Log AnalyticsServer/application logsAggregate millions of log lines in seconds
AutocompleteSearch-as-you-typeEdge n-gram tokenizer returns results in <50ms
GeospatialFind nearby restaurantsBuilt-in geo_point, geo_shape queries
Metrics/APMInfrastructure monitoringTime-series aggregations, dashboards
Security/SIEMThreat detectionReal-time correlation across event streams

3. Inverted Index β€” Deep Dive

This is the single most important concept in Elasticsearch. Every feature β€” search speed, relevance scoring, fuzzy matching β€” exists because of the inverted index.

What is a Forward Index?

A traditional database stores data like this (forward index):

// Forward Index β€” how a normal DB stores data
// To find "fast", you must scan EVERY row

Doc1 β†’ "Elasticsearch is fast and scalable"
Doc2 β†’ "Redis is fast for caching"
Doc3 β†’ "MongoDB is a NoSQL database"

To find all documents containing "fast", the DB must scan every single row β€” this is O(n) and gets slower as data grows.

What is an Inverted Index?

Elasticsearch flips this around. At index time, it breaks every text into words (tokens) and builds a map from each word to its documents:

// Inverted Index β€” how Elasticsearch stores data
// To find "fast", just look up the word β†’ instant O(1)

Term              β†’ Documents (Postings List)
─────────────────────────────────────────────
"elasticsearch"   β†’ [Doc1]
"is"              β†’ [Doc1, Doc2, Doc3]
"fast"            β†’ [Doc1, Doc2]          ← instant lookup!
"and"             β†’ [Doc1]
"scalable"        β†’ [Doc1]
"redis"           β†’ [Doc2]
"caching"         β†’ [Doc2]
"mongodb"         β†’ [Doc3]
"nosql"           β†’ [Doc3]
"database"        β†’ [Doc3]

What ES actually stores in the inverted index:

Each entry in the postings list is NOT just a document ID. It contains:

1. Document ID β€” which doc contains this term
2. Term Frequency (TF) β€” how many times this term appears in that doc (more = more relevant)
3. Position β€” at which word position the term appears (needed for phrase queries like "noise cancelling")
4. Offsets β€” character start/end positions (needed for highlighting the matched text)
5. Field Length β€” how many total words the field has (shorter fields with the term rank higher)

So the real entry looks like:
"fast" β†’ [{doc:1, tf:1, pos:[2], offset:[21-25]}, {doc:2, tf:1, pos:[2], offset:[9-13]}]

This is how ES calculates relevance scores β€” using BM25 algorithm which considers TF, document length, and inverse document frequency (how rare the term is across all docs).

How Multi-Word Search Works Behind the Scenes

When you search for "fast scalable":

Query: "fast scalable"
↓ Analyzer breaks query into tokens
["fast", "scalable"]
↓ Look up each term in inverted index
"fast" β†’ [Doc1, Doc2]    "scalable" β†’ [Doc1]
↓ Merge results (OR by default)
Result: [Doc1 (score: 2.4), Doc2 (score: 1.1)]
Doc1 ranks higher because it matches BOTH terms

4. Core Concepts

ES TermRDBMS EquivalentDefinition (say this in interview)
ClusterDatabase ServerA group of one or more nodes working together. Identified by a unique name. Holds ALL your data.
NodeSingle DB InstanceA single server (JVM process) in the cluster. Each node has a unique ID and stores data on disk.
IndexTableA collection of documents with similar structure. You search within an index. Like a "table" but flexible.
DocumentRowA single JSON object stored in an index. The smallest unit of data in ES. Has a unique _id.
FieldColumnA key-value pair in a document. Each field has a type (text, keyword, integer, date, etc).
MappingSchemaDefines field types and how they're indexed. Dynamic (auto-detect) or explicit (you define).
ShardPartitionAn index is split into shards for horizontal distribution. Each shard is a complete Lucene index with its own inverted index.
ReplicaRead ReplicaA copy of a primary shard on a different node. Provides fault tolerance + read throughput.

The hierarchy behind the scenes:

Cluster β†’ Nodes β†’ Indices β†’ Shards β†’ Segments β†’ Documents

The part most people miss is Segments. Each shard is made of multiple immutable segments. When you index a document:
1. It first goes to an in-memory buffer
2. Every 1 second (the refresh interval), the buffer is written to a new segment on disk
3. That segment becomes searchable (this is why ES is "near real-time" β€” 1 sec delay)
4. Segments are immutable β€” they never change. Updates create new segments; deletes just mark docs as deleted
5. Periodically, small segments are merged into larger ones (background merge process) to keep things efficient

This immutable segment design is WHY ES is so fast β€” no locks needed for reads, and the OS can cache segments aggressively.

"A Cluster has Nodes. Each Node holds Indices. Each Index is split into Shards for horizontal distribution. Each Shard is a full Lucene index made of immutable Segments. Replicas are copies of shards on different nodes for fault tolerance. Documents are JSON objects stored within an Index, and Fields are the key-value pairs inside each document."

5. Elasticsearch vs Traditional Database

Elasticsearch

βœ… Full-text search (milliseconds on millions of docs)

βœ… Built-in relevance scoring (BM25)

βœ… Horizontal scaling (just add nodes)

βœ… Schema-flexible (dynamic mapping)

βœ… Real-time analytics & aggregations

βœ… Fuzzy search, autocomplete, synonyms built-in

❌ No ACID transactions

❌ Not ideal as primary data store

❌ Eventual consistency (not strong)

❌ No joins between indices

PostgreSQL / MySQL

βœ… ACID transactions (strong consistency)

βœ… Complex JOINs & relationships

βœ… Referential integrity (foreign keys)

βœ… Mature, proven, huge ecosystem

❌ Full-text search is very slow at scale

❌ Vertical scaling primarily

❌ No relevance scoring

❌ Schema changes = migrations

❌ LIKE '%query%' cannot use index = full scan

Why SQL LIKE is slow vs ES:

SELECT * FROM products WHERE description LIKE '%noise cancelling%'

This forces PostgreSQL to do a sequential scan β€” reading every single row and checking if the string contains "noise cancelling". With 10 million rows, this takes seconds.

In ES, "noise cancelling" was already broken into tokens ["noise", "cancelling"] at index time, and each token points to matching doc IDs in the inverted index. The lookup is O(1) per term, then ES merges the posting lists. Result: milliseconds.

"Elasticsearch is NOT a replacement for your primary database. The best architecture is: PostgreSQL/MongoDB as the source of truth for writes and transactions, and Elasticsearch as a read-optimized search layer. You sync data from your DB to ES using change data capture, application-level dual writes, or tools like Logstash/Debezium."

6. How a Write (Index) Works β€” Behind the Scenes

When you send a document to Elasticsearch, here's every single step that happens internally:

Client sends POST /products/_doc/1
↓
Step 1: Coordinating Node receives the request
Any node can be a coordinating node. It determines which shard owns this doc.
↓
Step 2: Routing β€” shard = hash(_id) % number_of_shards
The doc ID is hashed to determine which primary shard gets it.
This is why you CANNOT change shard count after index creation.
↓
Step 3: Primary Shard receives the document
The doc is written to the Translog (write-ahead log) for crash recovery.
Then it goes into the In-Memory Buffer.
↓
Step 4: Refresh (every 1 second by default)
The in-memory buffer is written to a new Segment (immutable).
The segment is now SEARCHABLE. This is the "near real-time" delay.
↓
Step 5: Replicate to Replica Shards
The primary forwards the write to all replica shards in parallel.
Once all replicas confirm, the client gets a success response.
↓
Step 6: Flush (every 30 min or when translog gets big)
Segments are fsync'd to disk. Translog is cleared.
Data is now durable even if power goes out.
↓
Step 7: Merge (background)
Many small segments are merged into fewer large segments.
Deleted docs are permanently removed during merge.

"When you index a document, the coordinating node routes it to the correct primary shard using hash(id) % num_shards. The primary writes it to the translog for durability, then to an in-memory buffer. Every 1 second, the buffer is flushed to a new immutable Lucene segment β€” that's when it becomes searchable. The write is then replicated to all replica shards. Periodically, segments are merged in the background to optimize read performance."

7. How a Search Works β€” Behind the Scenes

Search is a two-phase process: Query phase and Fetch phase.

Client sends GET /products/_search { "query": { "match": { "name": "iPhone" } } }

═══ QUERY PHASE ═══
↓
Step 1: Coordinating node broadcasts query to ALL shards
If index has 5 shards, query goes to all 5 (primary or replica)
↓
Step 2: Each shard searches its LOCAL inverted index
Looks up "iphone" in inverted index β†’ gets matching doc IDs + scores
Each shard returns only doc IDs + scores (lightweight)
↓
Step 3: Coordinating node MERGES results from all shards
Sorts by score, applies from/size pagination
Now knows the TOP N document IDs

═══ FETCH PHASE ═══
↓
Step 4: Coordinating node fetches actual documents
Sends multi-get to only the shards that have the top N docs
Each shard returns full _source JSON for requested docs
↓
Step 5: Return results to client
{ hits: { total: 42, hits: [ {_source: {...}}, ... ] } }

Why deep pagination is expensive:

If you request from: 10000, size: 10, EVERY shard must return its top 10,010 results to the coordinating node. With 5 shards, that's 50,050 results to merge β€” just to return 10 docs. This is why from + size is capped at 10,000 by default, and why you should use search_after for deep pagination.

"ES search is a scatter-gather pattern with two phases. In the Query phase, the coordinating node broadcasts the query to all shards, each shard searches its local inverted index and returns just doc IDs + scores. The coordinator merges and ranks these. In the Fetch phase, it retrieves the actual document bodies only for the top N results. This two-phase design minimizes network transfer."

8. Analyzers & Tokenizers

An analyzer is the text processing pipeline that runs BOTH at index time (when you store data) and at search time (when you query). It determines how text is broken into searchable terms.

The 3-Stage Pipeline

Input: "The Quick-Brown FOX jumped! <b>twice</b>"
↓ Stage 1: Character Filters
Strip HTML, replace characters, pattern replace
"The Quick-Brown FOX jumped! twice"
↓ Stage 2: Tokenizer
Split text into individual tokens (words)
["The", "Quick", "Brown", "FOX", "jumped", "twice"]
↓ Stage 3: Token Filters
lowercase β†’ remove stop words β†’ stemming
["quick", "brown", "fox", "jump", "twice"]
↑ These tokens go into the inverted index

Why "Running" matches "run" β€” Stemming explained:

The stemmer token filter reduces words to their root form:
β€’ "running" β†’ "run"
β€’ "jumped" β†’ "jump"
β€’ "happier" β†’ "happi"
β€’ "universities" β†’ "univers"

Both the indexed text AND the search query go through the same analyzer. So when you index "I was running" it stores "run". When you search "runners", it becomes "run". They match!

Common mistake: Using a term query on an analyzed text field. The field stores "run" but you're searching for "Running" (not analyzed) β€” no match. Always use match for text fields.

Built-in Analyzers

AnalyzerWhat It DoesInput β†’ Output
standard (default)Unicode tokenizer + lowercase"Quick Brown" β†’ ["quick", "brown"]
simpleSplits on non-letters + lowercase"2-Fast cars!" β†’ ["fast", "cars"]
whitespaceSplits on whitespace only"Quick Brown" β†’ ["Quick", "Brown"]
keywordNo tokenization β€” entire string as one token"New York" β†’ ["New York"]
englishStandard + stop words + stemming"the runners are running" β†’ ["runner", "run"]

Edge N-Gram β€” For Autocomplete

Breaks a word into progressive prefixes:

// edge_ngram with min_gram:2, max_gram:5
// Input: "iPhone"
// Tokens: ["iP", "iPh", "iPho", "iPhon"]
// (after lowercase): ["ip", "iph", "ipho", "iphon"]
// Now searching "iph" matches because "iph" is a stored token!

"An analyzer has 3 stages: character filter (strip HTML, replace chars), tokenizer (split into tokens), and token filters (lowercase, stemming, stop words). The same analyzer runs at both index and search time to ensure terms match. For autocomplete, we use edge_ngram tokenizer at index time but standard analyzer at search time β€” so 'ip' typed by user matches the pre-built prefix tokens."

9. Mappings (Schema Definition)

Mapping defines how each field is stored and indexed. It's like a database schema, but more powerful because the same field can be indexed multiple ways simultaneously.

Field Types

TypeUse ForIndexed AsSupports
textFull-text (descriptions, titles)Analyzed β†’ inverted indexmatch, match_phrase, fuzzy
keywordExact values (IDs, status, tags)Not analyzed β†’ exact termterm, terms, sort, aggs
integer/long/floatNumbersBKD tree (numeric index)range, sort, aggs
dateDates/timestampsInternally as epoch millisrange, date_histogram
booleantrue/falseTerm indexterm filter
nestedArray of objectsSeparate hidden documentsnested query (preserves object boundaries)
geo_pointLat/lon coordinatesGeohash + quad treegeo_distance, geo_bounding_box

text vs keyword β€” the #1 source of confusion:

When you store "Apple iPhone":
β€’ As text: Analyzer runs β†’ stored as tokens ["apple", "iphone"] in inverted index. You can search with match and it'll find "apple" or "iphone". But you CANNOT sort or aggregate on it (tokens are scattered).
β€’ As keyword: Stored as-is: "Apple iPhone" as one single term. You CAN sort, aggregate, and do exact match with term. But searching "apple" alone won't find it.

Best practice β€” Multi-field mapping: Map the field as BOTH:
"name": { "type": "text", "fields": { "keyword": { "type": "keyword" } } }
Now use name for full-text search and name.keyword for sorting/aggregation. This is actually what ES does by default with dynamic mapping!

Dynamic vs Explicit Mapping

Dynamic Mapping (default)Explicit Mapping (recommended)
ES auto-detects types from first documentYou define every field and its type
"42" might become text instead of integerYou control exactly how data is indexed
Fine for development/prototypingRequired for production
Can lead to "mapping explosion" with dynamic keysUse dynamic: 'strict' to reject unmapped fields

"Mapping defines field types and indexing behavior. The key distinction is text (analyzed, for full-text search) vs keyword (not analyzed, for exact match/sort/aggs). In production, always use explicit mapping with dynamic:strict to prevent type-guessing issues. Multi-field mapping lets you index the same data both ways β€” text for search, keyword for sorting."

10. Query DSL β€” Complete Guide

Query DSL (Domain Specific Language) is ES's JSON-based query language. Two categories: Leaf queries (search a single field) and Compound queries (combine multiple queries).

Query Context vs Filter Context

This distinction is critical for performance:

Query context ("How WELL does this match?")
β€’ Calculates a relevance _score for each document
β€’ Score calculation (BM25) is CPU-intensive
β€’ Results are NOT cached
β€’ Use for: full-text search where ranking matters

Filter context ("Does this match YES or NO?")
β€’ No scoring β€” just binary match/no-match
β€’ Results ARE cached in a bitset cache (extremely fast on repeated queries)
β€’ Much faster than query context
β€’ Use for: exact values, ranges, boolean conditions

Rule: If you don't need relevance ranking, use filter. It's faster AND cached.

10.1 match β€” Full-Text Search

// match: Analyzes the query, then searches the inverted index
// "titanium chip" β†’ ["titanium", "chip"] β†’ OR search by default
{
  "query": {
    "match": {
      "description": {
        "query": "titanium chip",
        "operator": "and",          // "or" (default) = any term, "and" = all terms must match
        "fuzziness": "AUTO",         // Typo tolerance: AUTO = 0 edits for 1-2 chars, 1 for 3-5, 2 for 6+
        "minimum_should_match": "75%" // At least 75% of terms must match
      }
    }
  }
}

10.2 match_phrase β€” Exact Phrase (Order Matters)

// All terms must appear in EXACT order
{
  "query": {
    "match_phrase": {
      "description": {
        "query": "A17 Pro chip",
        "slop": 1    // Allow 1 word between terms ("A17 powerful Pro chip" = still matches)
      }
    }
  }
}

How match_phrase works behind the scenes:
ES doesn't just check if all terms exist β€” it checks their positions in the inverted index. Remember, the inverted index stores position data for each term occurrence. "A17" must be at position N, "Pro" at N+1, "chip" at N+2. Slop allows gaps: with slop:1, positions can differ by 1 extra.

10.3 multi_match β€” Search Multiple Fields

{
  "query": {
    "multi_match": {
      "query": "Apple premium",
      "fields": ["name^3", "brand^2", "description"],  // ^N = boost that field's score by N
      "type": "best_fields",  // Score from best matching field (default)
      "fuzziness": "AUTO"     // "most_fields" = sum of all fields, "cross_fields" = treat as one field
    }
  }
}

10.4 term β€” Exact Value (No Analysis)

// term: Does NOT analyze the query β€” searches for exact token
// Use for: keywords, IDs, booleans, enums, numbers
// NEVER use term on text fields!
{ "query": { "term": { "brand.keyword": "Apple" } } }

// terms: Match any value (like SQL IN)
{ "query": { "terms": { "category.keyword": ["smartphones", "laptops", "tablets"] } } }

10.5 range β€” Number/Date Ranges

{ "query": { "range": { "price": { "gte": 500, "lte": 1500 } } } }

// Date range with relative math
{ "query": { "range": { "createdAt": {
  "gte": "now-30d/d",   // 30 days ago, rounded to start of day
  "lte": "now/d",        // today, rounded to end of day
  "time_zone": "+05:30"  // Adjust for timezone before comparison
} } } }

10.6 bool β€” Combine Queries (MOST IMPORTANT)

{
  "query": {
    "bool": {
      "must": [                // AND + scored β€” all must match, affects ranking
        { "match": { "description": "premium quality" } }
      ],
      "should": [              // OR + scored β€” boosts score if matched
        { "match": { "tags": "5g" } },
        { "match": { "tags": "camera" } }
      ],
      "minimum_should_match": 1, // At least 1 should clause must match
      "filter": [              // AND + NOT scored + CACHED β€” fastest for exact conditions
        { "term": { "brand.keyword": "Apple" } },
        { "range": { "price": { "gte": 500, "lte": 2000 } } },
        { "term": { "inStock": true } }
      ],
      "must_not": [            // NOT + NOT scored + CACHED β€” exclude results
        { "term": { "category.keyword": "accessories" } }
      ]
    }
  }
}

"Bool query has 4 clauses: must (AND + scored), should (OR + scored), filter (AND + not scored + cached), must_not (NOT + not scored + cached). Use must/should for text search where ranking matters. Use filter for exact matches and ranges β€” it's faster because there's no scoring overhead and results are cached in a bitset."

10.7 Other Useful Queries

// wildcard: Pattern matching (* = any, ? = single char)
{ "query": { "wildcard": { "name.keyword": "*Phone*" } } }

// prefix: Starts with
{ "query": { "prefix": { "name.keyword": "Mac" } } }

// exists: Field exists and is not null
{ "query": { "exists": { "field": "ratings" } } }

// ids: Match specific document IDs
{ "query": { "ids": { "values": ["1", "2", "3"] } } }

11. Aggregations (Analytics Engine)

Aggregations are ES's analytics engine β€” like SQL GROUP BY, COUNT, AVG, SUM on steroids. You can nest them infinitely and combine with any query.

Three Types

TypeWhat It DoesSQL EquivalentExamples
BucketGroups documents into bucketsGROUP BYterms, range, date_histogram, filters
MetricCalculates values from grouped docsAVG, SUM, COUNTavg, sum, min, max, cardinality, percentiles
PipelineAggregates on other aggregation resultsSubqueries on aggregatescumulative_sum, derivative, bucket_sort

How aggregations work behind the scenes:

Aggregations use doc_values — a column-oriented data structure stored on disk alongside the inverted index. While the inverted index maps terms→docs (good for search), doc_values map docs→values (good for sorting and aggregation).

When you ask "group by brand", ES reads the brand doc_values column for all matching documents, which is extremely cache-friendly because values for the same field are stored contiguously.

keyword fields have doc_values enabled by default. text fields do NOT β€” that's why you must use brand.keyword for aggregations, not brand.

"Aggregations have 3 types: Bucket (grouping like GROUP BY), Metric (calculations like AVG/SUM), and Pipeline (aggregations on aggregation results). They use doc_values β€” a columnar data structure β€” which is why aggregations only work on keyword and numeric fields, not analyzed text fields. You can nest aggs infinitely: group by brand β†’ within each brand, calculate avg price."

12. Pagination Strategies

MethodLimitStateful?Use Case
from/size10,000 results maxNoUI pagination (page 1, 2, 3...)
search_afterUnlimitedNoInfinite scroll, deep pagination
PIT + search_afterUnlimitedYes (snapshot)Data exports, consistent reads
scroll (deprecated)UnlimitedYesLegacy β€” use PIT instead

Why from/size is capped at 10,000:

Requesting from:9990, size:10 means EVERY shard returns its top 10,000 results to the coordinating node. With 5 shards, the coordinator merges 50,000 results just to return 10. Memory and CPU cost grows linearly with from.

search_after is efficient because it uses the sort values of the last document as a cursor. Each shard only needs to find documents AFTER that point β€” no wasted work on documents you've already seen.

"from/size is simple but capped at 10K. For deep pagination, use search_after β€” it's cursor-based using the last document's sort values, so each shard only processes documents after the cursor. For consistent exports (no changes during pagination), combine search_after with Point in Time (PIT), which creates a frozen snapshot of the index."

13. Cluster Architecture

Node Roles

RoleResponsibilityHardware Needs
MasterManages cluster state, shard allocation, index creation/deletionLow CPU/RAM, reliable network
DataStores data, executes searches and aggregationsHigh CPU, RAM, fast SSD
CoordinatingRoutes requests, merges results from shardsMedium CPU/RAM
IngestPre-processes docs before indexing (transform, enrich)Medium CPU
MLRuns machine learning jobsHigh CPU/RAM

Cluster state and master election:

The cluster state is a metadata object containing: all index mappings, shard routing table (which shard is on which node), node membership. Every node has a copy but only the master can modify it.

Master election uses a quorum: you need a majority of master-eligible nodes to agree. That's why you run an ODD number (3 or 5) of master-eligible nodes β€” to avoid split-brain. In a split-brain scenario, two groups of nodes think they're each the cluster, leading to data corruption.

Cluster Health

StatusMeaningAction
GREENAll primary + replica shards assignedAll good!
YELLOWAll primaries OK, some replicas unassignedAdd nodes or reduce replica count
REDSome PRIMARY shards unassigned β€” data loss riskUrgent! Check disk, node health

14. Sharding

How sharding works internally:

An index with 3 shards is actually 3 completely separate Lucene indexes, potentially on 3 different nodes. Each shard has its own inverted index, its own segments, its own doc_values.

Routing formula: shard_number = hash(_routing) % number_of_primary_shards
By default, _routing is the document _id. This is WHY you cannot change shard count after creation β€” the hash would route existing documents to wrong shards.

Sizing rules:
β€’ Target 10-50 GB per shard
β€’ Each shard uses ~50MB heap memory overhead
β€’ Max ~1000 shards per node
β€’ Formula: num_shards = ceil(expected_data_size / 30GB)
β€’ Too few shards = can't distribute across nodes
β€’ Too many shards = excessive overhead, slow cluster state updates

"Shards are how ES distributes an index across nodes. Each shard is a full Lucene index. Shard count is fixed at index creation because routing uses hash(id) % num_shards. Target 10-50GB per shard. Too few = poor distribution, too many = heap overhead per shard and slow master operations."

15. Replication

Every primary shard can have 0+ replica shards β€” exact copies on different nodes.

How replication works internally:

1. Writes ALWAYS go to the primary shard first
2. After writing locally, the primary forwards the operation to all replicas in parallel
3. The client gets a response only after all in-sync replicas confirm (configurable via wait_for_active_shards)
4. Reads (searches) can be served by either primary or replica β€” ES round-robins between them
5. Replicas are NEVER on the same node as their primary β€” this is enforced by the shard allocator

Two benefits:
β€’ Fault tolerance: If a node dies, replicas on other nodes get promoted to primary. Zero data loss.
β€’ Read scaling: More replicas = more shards that can serve search requests in parallel

"Replicas serve two purposes: high availability (if a node dies, replica promotes to primary) and read throughput (searches can hit any replica). Writes always go to primary first, then replicate to all replicas in parallel. Replicas are never co-located with their primary. Default is 1 replica β€” meaning 2 copies of your data."

16. Performance Tuning

  • Use filter over query for non-scoring conditions β€” filters are cached in bitsets
  • Reduce _source β€” only fetch fields you need
  • Bulk API for writes β€” 500-5000 docs per batch instead of one-by-one
  • Refresh interval β€” set to 30s or -1 during bulk indexing, back to 1s after
  • Shard sizing β€” 10-50GB per shard, not too many, not too few
  • Index aliases β€” for zero-downtime reindexing
  • Disable replicas during bulk load β€” set to 0, then back to 1 after
  • Force merge read-only indices β€” merge to 1 segment for max read speed
  • Use doc_values: false on fields you never sort/aggregate β€” saves disk
  • Avoid wildcard queries with leading wildcards β€” *phone requires full scan

"Key tuning levers: use filter context for non-scoring queries (cached), batch writes with Bulk API, temporarily increase refresh_interval during ingestion, right-size shards to 10-50GB, use index aliases for zero-downtime mapping changes, and force-merge read-only indices to a single segment."

17. Security

ES security has 5 layers:

  1. Authentication β€” Who are you? (API keys, SAML, LDAP, native users)
  2. Authorization β€” What can you do? (RBAC β€” role-based access control)
  3. Encryption β€” TLS for transport (node-to-node) and HTTP (client-to-node)
  4. Audit logging β€” Who did what and when?
  5. Field/document-level security β€” Restrict WHICH data specific roles can see

"Elasticsearch security covers authentication (API keys, SAML), authorization (RBAC with index-level and field-level permissions), encryption (TLS everywhere), and audit logging. API keys are preferred over username/password because they can be scoped to specific indices and operations and are easily revokable."

18. ELK Stack (Elastic Stack)

ComponentRoleWhen to Use
BeatsLightweight data shippers (Filebeat, Metricbeat)Collect from servers/containers
LogstashHeavy ETL pipeline (collect, transform, output)Complex transformations, multiple outputs
Ingest PipelineTransform within ES itselfSimple transforms (grok, geoip, date parsing)
ElasticsearchStore, index, search, analyzeAlways β€” the core engine
KibanaVisualize, dashboard, manage, Dev ToolsDashboards, alerts, monitoring

"The Elastic Stack is: Beats (lightweight collection) β†’ Logstash (heavy transform) β†’ Elasticsearch (store and search) β†’ Kibana (visualize). The modern alternative to Logstash for simple transforms is Ingest Pipelines built into ES. Elastic Agent with Fleet is replacing individual Beats for centralized management."

19. Scaling & Index Lifecycle Management

Hot-Warm-Cold Architecture

TierHardwareData AgePurpose
HotFast NVMe SSDs, high CPU/RAM0-7 daysActive indexing + frequent search
WarmLarger, cheaper SSDs7-30 daysInfrequent search, read-only
ColdHDD or shared storage30-90 daysRare search, compliance retention
FrozenS3 / blob storage90+ daysArchive, searchable snapshots

ILM (Index Lifecycle Management) automates data tiering:

You define a policy: hot (7d, rollover at 50GB) β†’ warm (shrink shards, force merge) β†’ cold (remove replicas) β†’ delete (after 90d)

ES automatically moves indices through these phases based on age or size. This reduces cost by 40-70% because old, rarely-searched data lives on cheap storage.

"At scale, use Hot-Warm-Cold architecture with ILM policies. Hot tier has NVMe SSDs for active data, warm tier for read-only searchable data, cold for compliance. ILM automates rollover, shrink, merge, and deletion. Combined with searchable snapshots on S3, you can achieve 60-80% cost savings on archive data."

20. Cost Optimization

StrategySavingsEffort
ILM + Tiered Storage40-60%Medium
Right-size Shards (avoid oversharding)20-30%Low
Searchable Snapshots (frozen tier)60-80% on archiveLow
Remove replicas on warm/cold50% storageLow
_source excludes (prune unused fields)10-30%Low
best_compression codec10-15%Low

"Cost optimization: ILM for automated tiering (biggest win), right-size shards to avoid overhead, searchable snapshots on S3 for archive, reduce replicas on cold data, prune _source fields, use best_compression. Also evaluate managed (Elastic Cloud) vs self-hosted annually based on team size and usage."

PART 2 β€” Node.js Implementation

Every method, every argument, what it does, what happens behind the scenes

21. Setup & Connection

// Install: npm install @elastic/elasticsearch

const { Client } = require('@elastic/elasticsearch');

const client = new Client({
    node: 'http://localhost:9200',   // ES REST endpoint (port 9200)
    maxRetries: 5,                   // Retry failed requests 5 times
    requestTimeout: 60000,           // 60s timeout per request
    sniffOnStart: false,              // true = discover all nodes on connect
    // For Elastic Cloud:
    // cloud: { id: 'deployment:base64...' },
    // auth: { apiKey: 'your-key' }
});

// Test connection
async function ping() {
    const info = await client.info();
    console.log('Connected:', info.version.number);
}
ping();
node
What: The ES REST API URL. Can be a string or array of URLs for multi-node load balancing.
Behind the scenes: The client uses HTTP keep-alive connections. With an array, it round-robins requests across nodes.
maxRetries
What: How many times to retry a failed request before throwing.
Behind the scenes: On connection errors or 502/503/504, the client waits with exponential backoff, then retries on a different node if available.
requestTimeout
What: Max ms to wait for a response. Default 30000.
Behind the scenes: Sets the socket timeout. If ES is doing a heavy aggregation that takes 45s, a 30s timeout kills it prematurely.
sniffOnStart
What: If true, calls GET _nodes/_all/http on startup to discover all cluster nodes.
Behind the scenes: Builds a full node pool for optimal request distribution. Essential for multi-node clusters. Don't use with Elastic Cloud (it handles routing).
auth
What: Authentication β€” apiKey (recommended), username/password, or bearer token.
Behind the scenes: API keys are Base64-encoded id:key pairs sent in the Authorization header. They can be scoped per-index and revoked without changing passwords.

22. Create Index with Mapping

async function createProductIndex() {
    await client.indices.create({
        index: 'products',
        settings: {
            number_of_shards: 1,        // 1 shard (small dataset). CANNOT change later!
            number_of_replicas: 1,      // 1 replica (2 total copies). CAN change later.
            refresh_interval: '1s',     // New data becomes searchable every 1 second
            analysis: {
                analyzer: {
                    autocomplete_analyzer: {
                        type: 'custom',
                        tokenizer: 'autocomplete_tokenizer',
                        filter: ['lowercase']
                    }
                },
                tokenizer: {
                    autocomplete_tokenizer: {
                        type: 'edge_ngram',
                        min_gram: 2,   // Minimum 2-char prefix: "iP"
                        max_gram: 15,  // Maximum 15-char prefix
                        token_chars: ['letter', 'digit']
                    }
                }
            }
        },
        mappings: {
            dynamic: 'strict',  // Reject docs with unmapped fields
            properties: {
                name: {
                    type: 'text',
                    fields: {
                        keyword: { type: 'keyword', ignore_above: 256 },
                        autocomplete: { type: 'text', analyzer: 'autocomplete_analyzer', search_analyzer: 'standard' }
                    }
                },
                brand:       { type: 'keyword' },
                category:    { type: 'keyword' },
                price:       { type: 'float' },
                description: { type: 'text' },
                inStock:     { type: 'boolean' },
                ratings:     { type: 'float' },
                tags:        { type: 'keyword' },
                createdAt:   { type: 'date' }
            }
        }
    });
    console.log('Index created');
}

What happens when you create an index:
1. Master node validates the mapping and settings
2. Master updates the cluster state with the new index metadata
3. Shard allocation kicks in β€” master decides which nodes get which shards
4. Each assigned node creates a Lucene index directory on disk for its shards
5. Each shard initializes its translog (write-ahead log) and empty segment
6. Replica shards are allocated to different nodes and start syncing

number_of_shards
What: How many primary shards for this index. FIXED at creation.
Interview: "Cannot be changed after creation because the routing formula hash(id)%shards would send existing docs to wrong shards. To change, you must reindex."
number_of_replicas
What: How many copies of each primary shard. Can be changed anytime.
Interview: "Set to 0 during bulk import for speed, then back to 1+. More replicas = better fault tolerance + read throughput but more disk."
dynamic: 'strict'
What: Reject documents with fields not defined in mapping.
Interview: "Prevents accidental mapping pollution. Without strict, a typo like 'prce' creates a new field forever."
search_analyzer
What: Use a DIFFERENT analyzer at search time than index time.
Interview: "For autocomplete: index with edge_ngram (creates prefix tokens), search with standard (search the exact typed prefix). Without this, searching 'ip' would also be edge_ngrammed into 'i','ip' causing too-broad matches."

23. CRUD Operations

CREATE β€” Index a Document

async function createDoc() {
    const result = await client.index({
        index: 'products',       // Target index
        id: '1',                  // Doc ID. Omit = ES auto-generates. Exists = REPLACES entire doc.
        document: {               // The JSON body to store
            name: 'iPhone 15 Pro',
            brand: 'Apple',
            price: 999,
            category: 'smartphones',
            description: 'Latest iPhone with A17 Pro chip and titanium design',
            inStock: true,
            ratings: 4.8,
            tags: ['premium', '5g', 'camera'],
            createdAt: new Date().toISOString()
        },
        refresh: 'wait_for',     // Wait until next refresh to make it searchable
    });
    console.log(result.result); // 'created' or 'updated'
}

Behind the scenes of client.index():
1. Client sends PUT /products/_doc/1 with JSON body
2. Coordinating node hashes ID "1" β†’ determines shard number
3. Routes to the primary shard on the correct node
4. Primary: writes to translog β†’ writes to in-memory buffer
5. On next refresh (1s): buffer β†’ new immutable segment β†’ now searchable
6. Primary replicates to replica shards
7. With refresh: 'wait_for': response waits until the refresh happens

id
What: Unique document ID. If omitted, ES generates a random 20-char base64 ID.
Critical: If ID already exists, the entire document is REPLACED (like PUT, not PATCH). Use update() for partial updates.
refresh
'true' = force immediate refresh (expensive, avoid in production).
'false' (default) = return immediately, doc searchable in ~1s.
'wait_for' = return after next scheduled refresh. Safe middle ground.

READ β€” Get Document

const doc = await client.get({
    index: 'products',
    id: '1',
    _source_includes: ['name', 'price'], // Only return these fields (reduces payload)
});
console.log(doc._source);    // { name: 'iPhone 15 Pro', price: 999 }
console.log(doc._version);   // Version number (increments on every update)

// Get multiple docs at once
const multi = await client.mget({ index: 'products', ids: ['1', '2', '3'] });

GET does NOT use the inverted index. It uses the document's _id to route directly to the correct shard (same hash formula), then reads from the _source stored field. This is O(1) β€” like a key-value lookup. It can hit either primary or replica.

UPDATE β€” Partial Update

await client.update({
    index: 'products',
    id: '1',
    doc: {                      // Only these fields change; rest stays untouched
        price: 899,
        inStock: false,
    },
    retry_on_conflict: 3,     // Retry 3 times if version conflict
});

// Update by query β€” update all docs matching a condition
await client.updateByQuery({
    index: 'products',
    query: { match: { brand: 'Apple' } },
    script: {
        source: "ctx._source.price = ctx._source.price * 0.9", // 10% discount
        lang: 'painless'
    },
    conflicts: 'proceed',    // Skip conflicts instead of aborting
});

Updates are NOT in-place. Because segments are immutable, an update actually: 1) reads the old document, 2) applies changes in memory, 3) indexes a NEW version as a new document in a new segment, 4) marks the old document as deleted. The old version is physically removed during the next segment merge. This is why retry_on_conflict exists β€” two concurrent updates might read the same version.

DELETE

await client.delete({ index: 'products', id: '1' });

// Delete by query
await client.deleteByQuery({
    index: 'products',
    query: { range: { price: { lt: 100 } } }
});

// Delete entire index
await client.indices.delete({ index: 'products' });

Deletes don't immediately free disk space. The document is just marked as deleted in a .del file. It's still on disk in its segment. It's filtered out from search results. The space is reclaimed only when that segment gets merged with others β€” the merge process skips deleted docs.

25. Aggregations

async function getDashboard() {
    const result = await client.search({
        index: 'products',
        size: 0,         // size:0 = only return agg results, no documents
        aggs: {
            // BUCKET: Group by brand
            brands: {
                terms: { field: 'brand', size: 20 },
                aggs: {  // Nested: avg price per brand
                    avg_price: { avg: { field: 'price' } }
                }
            },
            // BUCKET: Custom price ranges
            price_ranges: {
                range: { field: 'price', ranges: [
                    { key: 'Budget', to: 300 },
                    { key: 'Mid', from: 300, to: 1000 },
                    { key: 'Premium', from: 1000 },
                ]}
            },
            // METRIC: Overall stats
            price_stats: { stats: { field: 'price' } },
            // METRIC: Count unique brands
            unique_brands: { cardinality: { field: 'brand' } },
        }
    });
    console.log(result.aggregations.brands.buckets);
    // [{ key:'Apple', doc_count:4, avg_price:{value:1061} }, ...]
}
size: 0
What: Return zero documents, only aggregation results.
Behind the scenes: Skips the entire fetch phase. Only the query phase + aggregation computation runs. Much faster when you just need analytics.
terms.size
What: Top N buckets to return. Default 10.
Behind the scenes: Each shard returns its local top N terms. Coordinator merges them. This means counts can be approximate if a term is common on one shard but rare on another. Set shard_size higher for accuracy.
cardinality
What: Count distinct values (like SQL COUNT DISTINCT).
Behind the scenes: Uses the HyperLogLog++ algorithm β€” probabilistic, uses very little memory, but with ~0.5% error rate on large datasets. Exact counting would require loading all values in memory.

26. Pagination (search_after + PIT)

// Cursor-based deep pagination (unlimited, efficient)
async function cursorPaginate(lastSort = null) {
    const body = {
        index: 'products',
        size: 20,
        query: { match_all: {} },
        sort: [{ createdAt: 'desc' }, { _id: 'asc' }], // Tiebreaker required!
    };
    if (lastSort) body.search_after = lastSort; // Sort values of last doc from previous page

    const result = await client.search(body);
    const hits = result.hits.hits;
    return { hits, nextCursor: hits.length ? hits[hits.length-1].sort : null };
}

// PIT + search_after for consistent data export
async function exportAll() {
    const pit = await client.openPointInTime({ index: 'products', keep_alive: '5m' });
    let all = [], searchAfter;

    while (true) {
        const r = await client.search({
            size: 1000,
            pit: { id: pit.id, keep_alive: '5m' },
            sort: [{ _id: 'asc' }],
            ...(searchAfter && { search_after: searchAfter }),
        });
        if (!r.hits.hits.length) break;
        all.push(...r.hits.hits.map(h => h._source));
        searchAfter = r.hits.hits[r.hits.hits.length-1].sort;
    }
    await client.closePointInTime({ id: pit.id });
    return all;
}
search_after
What: The sort values array from the last document of the previous page.
Behind the scenes: Each shard starts scanning from AFTER this sort value β€” so it doesn't waste work on already-seen documents. Much more efficient than from/size for deep pages.
Point in Time (PIT)
What: Creates a frozen snapshot of the index state.
Behind the scenes: PIT prevents segment merges from deleting old segments, preserving a consistent view. Without PIT, a document could be updated between page requests, causing duplicates or missing results.

27. Autocomplete

async function autocomplete(query) {
    const result = await client.search({
        index: 'products',
        size: 5,
        query: { match: { 'name.autocomplete': query } },
        _source: ['name', 'brand'],
    });
    return result.hits.hits.map(h => h._source.name);
}
// autocomplete('mac') β†’ ['MacBook Pro M3 Max']

How this works:
At index time, "MacBook" was processed by edge_ngram: ["ma","mac","macb","macbo","macboo","macbook"]. These all live in the inverted index.
At search time, "mac" is processed by the standard analyzer (because we set search_analyzer: 'standard'), so it stays as ["mac"].
ES looks up "mac" in the inverted index β†’ finds the edge_ngram token β†’ matches the document. Instant.

28. Bulk Operations

async function bulkIndex(products) {
    const operations = products.flatMap((doc, i) => [
        { index: { _index: 'products', _id: String(i + 1) } },  // Action line
        doc                                                         // Document body
    ]);

    const result = await client.bulk({
        operations,
        refresh: true,     // Make all docs searchable after bulk completes
    });

    if (result.errors) {
        const failed = result.items.filter(i => i.index?.error);
        console.error('Failed items:', failed);
    }
    console.log(`Indexed ${result.items.length} docs`);
}

Why bulk is 10-100x faster than individual index calls:
1. One HTTP request instead of N β€” saves TCP overhead and round trips
2. ES batches writes to the translog and in-memory buffer together
3. Fewer refresh cycles β€” instead of N small segments, you get fewer larger ones
4. Internal thread pool handles the batch efficiently

Optimal batch size: 500-5000 docs or 5-15MB per request. Too large = heap pressure. Too small = not enough batching benefit.

29. Aliases & Zero-Downtime Reindex

// Step 1: Your app always uses the alias "products" (not the real index name)
await client.indices.putAlias({ index: 'products_v1', name: 'products' });

// Step 2: Need to change mapping? Create new index
await client.indices.create({ index: 'products_v2', /* new mapping */ });

// Step 3: Reindex data from v1 to v2
await client.reindex({
    source: { index: 'products_v1' },
    dest: { index: 'products_v2' },
});

// Step 4: Atomic alias swap β€” zero downtime!
await client.indices.updateAliases({
    actions: [
        { remove: { index: 'products_v1', alias: 'products' } },
        { add:    { index: 'products_v2', alias: 'products' } },
    ]
});
// Your app never noticed the switch!

Alias swap is atomic. Both actions (remove old + add new) happen in a single cluster state update. There is zero gap where the alias points to nothing. Your application keeps querying "products" and seamlessly switches to the new index.

30. Cluster Monitoring

// Cluster health
const health = await client.cluster.health({});
console.log(health.status);              // 'green' | 'yellow' | 'red'
console.log(health.unassigned_shards);   // > 0 means trouble

// Index stats
const stats = await client.indices.stats({
    index: 'products',
    metric: ['docs', 'store', 'search', 'indexing']
});
console.log(stats._all.primaries.docs.count);     // Total documents
console.log(stats._all.primaries.store.size);      // Disk usage

31. Complete Runnable Project

// ===== elasticsearch-demo.js =====
// Run: docker run -d -p 9200:9200 -e "discovery.type=single-node" -e "xpack.security.enabled=false" docker.elastic.co/elasticsearch/elasticsearch:8.12.0
// Then: npm install @elastic/elasticsearch && node elasticsearch-demo.js

const { Client } = require('@elastic/elasticsearch');
const client = new Client({ node: 'http://localhost:9200' });
const INDEX = 'products';

async function run() {
    // 1. Delete old index if exists
    try { await client.indices.delete({ index: INDEX }); } catch(e) {}

    // 2. Create index with mapping
    await client.indices.create({
        index: INDEX,
        settings: { number_of_shards: 1, number_of_replicas: 0,
            analysis: { analyzer: { ac: { type:'custom', tokenizer:'ac_tok', filter:['lowercase'] }},
                tokenizer: { ac_tok: { type:'edge_ngram', min_gram:2, max_gram:15, token_chars:['letter','digit'] }}}},
        mappings: { properties: {
            name: { type:'text', fields:{ keyword:{type:'keyword'}, ac:{type:'text',analyzer:'ac',search_analyzer:'standard'}}},
            brand:{type:'keyword'}, category:{type:'keyword'}, price:{type:'float'},
            description:{type:'text'}, inStock:{type:'boolean'}, ratings:{type:'float'},
            tags:{type:'keyword'}, createdAt:{type:'date'}
        }}
    });

    // 3. Bulk index sample data
    const data = [
        { name:'iPhone 15 Pro', brand:'Apple', price:999, category:'smartphones', description:'A17 Pro chip titanium design', inStock:true, ratings:4.8, tags:['premium','5g'], createdAt:'2024-09-22' },
        { name:'Samsung Galaxy S24', brand:'Samsung', price:799, category:'smartphones', description:'Galaxy AI powered with S Pen', inStock:true, ratings:4.7, tags:['ai'], createdAt:'2024-01-17' },
        { name:'MacBook Pro M3', brand:'Apple', price:2499, category:'laptops', description:'Professional laptop M3 Max chip', inStock:true, ratings:4.9, tags:['pro'], createdAt:'2023-11-07' },
        { name:'Sony WH-1000XM5', brand:'Sony', price:349, category:'headphones', description:'Industry leading noise cancelling', inStock:true, ratings:4.6, tags:['wireless','anc'], createdAt:'2023-05-18' },
        { name:'iPad Air M2', brand:'Apple', price:599, category:'tablets', description:'Lightweight tablet M2 creativity', inStock:false, ratings:4.5, tags:['portable'], createdAt:'2024-03-08' },
        { name:'Dell XPS 15', brand:'Dell', price:1499, category:'laptops', description:'Premium Windows OLED display', inStock:true, ratings:4.3, tags:['oled'], createdAt:'2024-02-20' },
        { name:'AirPods Pro 2', brand:'Apple', price:249, category:'headphones', description:'Active noise cancellation adaptive audio', inStock:true, ratings:4.7, tags:['wireless'], createdAt:'2023-09-12' },
        { name:'Pixel 8 Pro', brand:'Google', price:899, category:'smartphones', description:'AI-first best camera system', inStock:true, ratings:4.5, tags:['ai','camera'], createdAt:'2023-10-12' },
    ];
    await client.bulk({ operations: data.flatMap((d,i) => [{ index:{_index:INDEX,_id:String(i+1)}}, d]), refresh:true });
    console.log('Seeded', data.length, 'docs');

    // 4. Full-text search
    console.log('\n--- Search: "apple premium" ---');
    const s1 = await client.search({ index:INDEX, query:{ multi_match:{ query:'apple premium', fields:['name^3','brand^2','description'], fuzziness:'AUTO' }}, _source:['name','price'] });
    s1.hits.hits.forEach(h => console.log(`  ${h._source.name} β€” $${h._source.price} (score: ${h._score})`));

    // 5. Filtered search
    console.log('\n--- Filter: Laptops under $2000 ---');
    const s2 = await client.search({ index:INDEX, query:{ bool:{ filter:[{ term:{category:'laptops'}}, { range:{price:{lte:2000}}}]}}, _source:['name','price'] });
    s2.hits.hits.forEach(h => console.log(`  ${h._source.name} β€” $${h._source.price}`));

    // 6. Autocomplete
    console.log('\n--- Autocomplete: "mac" ---');
    const ac = await client.search({ index:INDEX, size:3, query:{ match:{ 'name.ac':'mac' }}, _source:['name'] });
    ac.hits.hits.forEach(h => console.log(`  ${h._source.name}`));

    // 7. Aggregations
    console.log('\n--- Aggregations ---');
    const agg = await client.search({ index:INDEX, size:0, aggs:{
        brands: { terms:{field:'brand'}, aggs:{ avg_price:{avg:{field:'price'}}} },
        price_stats: { stats:{field:'price'} },
    }});
    agg.aggregations.brands.buckets.forEach(b => console.log(`  ${b.key}: ${b.doc_count} products, avg $${b.avg_price.value.toFixed(0)}`));
    console.log('  Price stats:', agg.aggregations.price_stats);

    // 8. Cluster health
    console.log('\n--- Cluster Health ---');
    const h = await client.cluster.health({});
    console.log(`  Status: ${h.status}, Nodes: ${h.number_of_nodes}, Shards: ${h.active_shards}`);
}
run().catch(e => console.error(e.meta?.body?.error || e.message));

Interview Quick-Reference Cheatsheet

QuestionAnswer
What is Elasticsearch?Distributed search engine on Lucene. Stores JSON, builds inverted index, near real-time search via REST.
What is an inverted index?Maps each unique term β†’ list of documents + positions + frequency. O(1) term lookup.
How is it different from a DB?Uses inverted index (not B-tree). Complements your DB as a search layer. No ACID, eventual consistency.
match vs term?match = analyzes query + full-text search. term = exact value, no analysis. Never use term on text fields.
text vs keyword?text = analyzed into tokens for search. keyword = stored as-is for exact match, sort, aggs.
What is a shard?A partition of an index. Each shard = full Lucene index. Count fixed at creation. hash(id)%shards for routing.
Query vs Filter context?Query = scored (relevance). Filter = yes/no, cached in bitset, faster. Use filter for exact conditions.
What are analyzers?3-stage pipeline: char_filter β†’ tokenizer β†’ token_filter. Runs at index time AND search time.
How does a write work?Route to primary shard β†’ translog β†’ in-memory buffer β†’ refresh (1s) β†’ segment β†’ replicate.
How does a search work?Scatter-gather: query all shards β†’ each returns IDs+scores β†’ coordinator merges β†’ fetch top N docs.
Why is update not in-place?Segments are immutable. Update = read old doc + write new version + mark old deleted. Cleaned on merge.
Deep pagination?search_after (cursor-based, efficient) + PIT (consistent snapshot). from/size capped at 10K.
Cluster health colors?Green = all shards OK. Yellow = replicas missing. Red = primaries missing β€” data loss risk.
Hot-Warm-Cold?Tiered storage: hot (SSD, active), warm (slow SSD, read-only), cold (HDD, archive). ILM automates movement.
What is ELK?Elasticsearch + Logstash + Kibana + Beats. Ingest, store, search, visualize.
refresh parameter?'true' = immediate (expensive). 'false' = ~1s delay. 'wait_for' = waits for next refresh cycle.
Elasticsearch Complete Topic-Wise Guide
Concepts + Node.js Implementation + Behind the Scenes