projects~/ deep-dive/candidate-search
~/ cat projects/candidate-search/DEEP_DIVE.md

The shards were the right count and the wrong size.

A read-heavy candidate index: ~500M documents, ~2 TB of primary store, serving job-to-candidate matching. Five primary shards held ~400 GB each — comfortably inside Elastic's document-count guidance and roughly 8× over its size guidance. Almost every latency and cost symptom traced back to that one number.

~500M documents · ~2 TB primary400 GB → 30 GB per shard5 → ~67 primaries8–10 data nodes−60% API latency

00The one-pager

Everything below in a single screen.

the system

A read-heavy candidate search platform. ~500M candidate documents of ~20 mixed fields each, queried constantly for job-to-candidate matching on location, experience, salary fit, profile preferences and activity signals.

the diagnosis
  • 5 primaries → ~400 GB per shard.
  • 100M docs/shard was inside the ~200M guidance.
  • 400 GB was ~8× over the 10–50 GB guidance.
  • Doc count looked fine, so nobody looked at bytes.
the derivation

2 TB primary store ÷ a 30 GB target ≈ 67 primary shards, ×1 replica ≈ 120–140 shard copies. Not a benchmark guess — a capacity calculation from bytes, then validated.

six decisions
  • Shard count from primary-store size.
  • Node count from the disk:RAM ratio.
  • New index, not an in-place change.
  • Explicit mappings, source filtering.
  • Hard constraints into filter context.
  • Coordinating-only nodes.

→ 8–10 data + 3 master + 2–3 coordinating ≈ 13–16 nodes.

why it worked

Lighter shard-level search, more parallelism, a hot working set that could actually stay in filesystem cache, less scoring on irrelevant documents, and data nodes no longer doing coordination as a second job.

then, and only then

Cost. Once query CPU, heap pressure and latency had stabilised, the cluster could be right-sized — node count, node type, replica count, index bloat. Cost was the consequence, not the lever.

~2 TBprimary store
~4 TBwith one replica
13×more primary shards
−92%shard size
−60%API latency
~−50%infra cost

01The high-level design

Start here. Everything after this is a zoom into one box.

read pathjob / recruiterrequestjob profile lookuprole · city · exp · salaryquery builderhard filters + scoring signalscoordinating-only nodesfan out · merge top-Ndata nodescandidates_current ≈ 500M docsprimary + replica shardstop-N, source-filteredonly the fields the API needsranked candidatesAPI responsewrite pathprofile updatesKafka / workersbulk indexingbatched · refresh tunedalias candidates_currentv1 → v2 cutover, reversiblepoints the read pathat either indexthe source of truth stays in the operational stores — Elasticsearch holds a denormalised copy shaped for searchlevers: shard sizing · mappings · filter vs. score · node roles · bulk indexing · replicas
One read path, one write path, and an alias in between so the index underneath can be replaced.

Elasticsearch is not the source of truth — the operational stores are. The index holds a denormalised copy shaped entirely for retrieval: every field the query filters on, scores on, or returns, and nothing else. That is what removes runtime joins from the hot path.

02The two-minute answer

spoken version

“We had a read-heavy candidate search platform — around 500 million candidate documents, roughly 20 fields each. Job-to-candidate matching was slow at p95 and p99, the data nodes were under heap and CPU pressure, and the cluster was expensive.”

“The first thing was to stop reasoning about document count. 500 million documents across five shards is 100 million per shard, which is comfortably inside Elastic's ~200 million guidance — so on paper it looked fine. But the index was about 2 TB of primary store, which meant each shard was roughly 400 GB. Elastic's size guidance is 10 to 50 GB. We were eight times over a limit nobody had checked, because the other limit looked healthy.”

“So shard sizing became a capacity calculation rather than a guess: 2 TB divided by a 30 GB target gives about 67 primary shards, and with one replica that's 120 to 140 shard copies. Then node capacity came from disk — 32 GB RAM nodes at a 1:24 RAM-to-disk ratio is about 768 GB per node, of which maybe 550 GB is safely usable, so a 4 TB footprint needs around 8 to 10 data nodes.”

“The other half was work the cluster should never have been doing. Hard constraints — active, city, experience band, profile IDs — were in scoring clauses, so Elasticsearch was computing relevance across an enormous set before discarding most of it. Moving those into filter context means they answer yes/no, skip scoring, and become cacheable. Alongside that: explicit mappings instead of broad ones, source filtering so we return a handful of fields rather than whole profiles, and coordinating-only nodes so data nodes stop doing fanout and merge as a second job.”

“Shard count can't be changed in place, so all of it shipped as a new index — reindexed, replayed with production-like traffic, compared on latency, CPU, heap and disk, then cut over by alias with the old index kept for rollback. The cost reduction came last and deliberately so: once the waste was gone and latency was stable, the cluster could be right-sized.”

03Ownership — read this before the interview

A large Elasticsearch cluster is touched by application engineers, platform engineers and infra. Claiming all of it is both untrue and easy to catch — one follow-up about JVM flags or node provisioning finds the edge.

the rule

Claim a specific action only if it happened. Where work was shared, the honest verb is identified, validated, recommended, contributed to, or the team implemented. Precision here buys credibility.

Only if trueOtherwise say
“I redesigned the shard strategy.”I did the shard-sizing analysis from primary-store size and validated the target with production-like queries.
“I changed the node topology.”I analysed cluster pressure and recommended separating master, coordinating and data roles; the infra team executed it.
“I rewrote all the queries.”I optimised the job-to-candidate query path — hard constraints to filters, source filtering, validated scoring.
“I cut infra cost by 50%.”The combined effort reduced it by about half; my part was the shard, mapping and query work that made right-sizing possible.
“I owned the Elasticsearch platform.”I owned the candidate-search performance work on it.
decide this in advance, per area

For each of: shard sizing, node capacity, mappings, query rewrite, topology, replicas, indexing — write down whether you implemented, recommended, or reviewed it.

04Context & the storage model

Everything downstream depends on getting this estimate right, and it is the step most people skip — they reason about document count because it is the number they happen to know.

500M documents × ~2 KB each · ~20 mixed fieldsraw JSON≈1 TBwhat the documents weighprimary store≈2 TB+ inverted index, doc values, term dicts, segmentscluster total≈4 TB× 1 replicathe number that drives every later decision is 2 TB, not 500Mshard count comes from primary-store size; node count comes from total cluster storage
Bars to scale. Sizing starts here — not at the document count.
500M candidate documents
average raw JSON              ≈ 2 KB per document
raw JSON size                 ≈ 1 TB
Elasticsearch primary store   ≈ 2 TB     ← _source + inverted index + doc values
                                            + term dictionaries + segments + metadata
with 1 replica                ≈ 4 TB total cluster storage

That ~2× expansion from raw JSON to primary store is the part worth internalising. Elasticsearch is not storing your documents — it is storing your documents plus everything that makes them searchable. Sizing from raw payload underestimates by roughly half.

The existing setup

node type        32 GB RAM data nodes
JVM heap         ~16 GB
OS / off-heap    ~16 GB filesystem cache
index            ~2 TB primary, 5 primary shards
replica          1
cluster total    ~4 TB

→ 2 TB / 5 shards  ≈ 400 GB per primary shard
→ 500M / 5 shards  ≈ 100M documents per primary shard

05Why it was slow

A shard is a Lucene index. The coordinating node fans a query out to the relevant shard copies, each executes locally, and results merge. With few very large shards, every one of those local searches is doing a lot of work.

CauseMechanism
Large shard sizeHundreds of GB per shard means heavy shard-level search on every query.
Low parallelismFive primaries means only five independent shard-level tasks to spread.
Overloaded data nodesSame nodes doing Lucene search and request coordination and merge.
Filesystem cache too small16 GB of OS cache against hundreds of GB of shard data — the hot set could never stay resident.
Slow recoveryVery large shards take a long time to relocate or recover after a node failure.
Cost pressureBecause each query was expensive, the cluster needed more and bigger nodes to hold latency.
the line that makes this a good answer

“Search performance depends on query cost, the number of parallel searches, how many shards are involved, the sharding strategy and shard size. We had a problem in three of those five, and they all pointed at the same root cause.”

06Decision 1 — shard sizing

gigabytes per primary shard — the 10–50 GB band is Elastic's guidance50 GB ceilingbefore · 5 shards≈400 GB8× over the ceilingafter · ~67 shards≈30 GBinside the bandwhy doc count misled us100M docs/shard was under Elastic's ~200M guidanceso the count looked fine while the size was 8× overthe derivation, not a benchmark guess2 TB primary ÷ 30 GB target ≈ 67 shards× 1 replica → 120–140 total shard copiestwo separate Elastic guidelines: ≤ ~200M documents per shard, and 10–50 GB per shardyou have to clear both. This cluster cleared one and failed the other.and shard count cannot be changed in place — which is why this ships as a new index
The document count was never the problem. Shard size was, by roughly 8×.
Elastic guidance
  target shard size    10–50 GB
  target document count  below ~200M per shard

we were
  100M docs/shard   ✓  inside the count guidance
  400 GB/shard      ✗  ~8× over the size guidance

pick a middle target for a read-heavy workload
  30 GB per shard

derive
  2 TB / 30 GB  ≈  67 primary shards
  × 1 replica   →  120–140 total shard copies
the insight worth leading with

There are two Elastic guidelines and you have to clear both. This cluster cleared the document-count one and failed the size one — which is exactly why it went unnoticed. Anyone checking “are we under 200M docs per shard?” would have concluded the sharding was fine.

Smaller shards buy three things at once: lighter per-shard search work, more shard-level parallelism, and dramatically faster recovery and relocation when a node is lost. The third one is invisible until an incident, and then it is the one that matters.

There is an upper bound too — more shards means more fanout per query, more cluster state, and more segment overhead. 67 shards across 8–10 nodes lands at 13–17 shard copies per node, which is a reasonable place to start measuring rather than a number to defend to the death.

07Decision 2 — node capacity

32 GB RAM node16 GB JVM heap16 GB OS filesystem cache× 24the 1:24 RAM-to-diskplanning ratio≈ 768 GB disk× 70–75% safenever plan to fill a disk≈ 540–576 GB usable4 TB ÷ ~550 GB≈ 8 data nodespractical range: 8–10 data nodes134 copies ÷ 8 → 16–17 per node134 copies ÷ 10 → 13–14 per nodewhy filesystem cache is the whole game for read-heavy searchLucene reads segment files through the OS page cache. With 400 GB shards and 16 GB of cache per node,the hot working set could never stay resident — so every query went to disk.smaller shards spread across more nodes gave the hot set a real chance of being served from memoryheap is capped near 32 GB for compressed object pointers — past that, add nodes, not RAMthese are planning ratios for a starting topology, to be confirmed by load test — not guarantees
Node count falls out of disk. Node size is chosen for filesystem cache, which is what read-heavy search lives on.
32 GB RAM node
  JVM heap        ~16 GB
  OS cache        ~16 GB      ← what read-heavy search actually runs on

1:24 RAM-to-disk planning ratio
  32 GB × 24    ≈ 768 GB disk per node
  × 70–75% safe ≈ 540–576 GB usable per node

4 TB total / ~550 GB per node  ≈  8 data nodes

practical topology
  8–10 data nodes · 32 GB RAM
  60–70 primary shards · 1 replica
  120–140 shard copies → 13–17 per node

The 32 GB node size is not arbitrary. Elasticsearch leans heavily on the OS filesystem cache for search, and heap is capped near 32 GB because beyond that the JVM loses compressed object pointers. So the sensible shape is ~half heap, ~half cache — and when you need more capacity you add nodes rather than growing them.

the connection back to shard size

“These two decisions are the same decision. 16 GB of filesystem cache against a 400 GB shard means the hot working set can never stay resident, so every query pays disk. Shrinking shards and spreading copies across more nodes is what gives the cache a chance to do its job.”

08What “1 replica” actually means

Worth its own section because it is the most commonly misread number in a topology, and getting it wrong in an interview is an easy own goal. number_of_replicas: 1 means one replica copy per primary shard — not one replica node, and not one replica of the index.

number_of_replicas: 1  ≠  one replica node67 primary shards+67 replica shards=134 shard copiesa replica is always placed on a different node from its own primarydata-1P0R1data-2P1R2data-3P2R0P0 and R0 never share a node — otherwise losing that node loses the dataso the cluster is not 8–10 nodes — it is 8–10 data nodes8–10 data nodeshold all 134 copies+3 master-eligiblecluster state, quorum+2–3 coordinating-onlyfanout + merge=13–16 nodestotal8 data nodes → 16–17 copies each ≈ 480–510 GB · 10 data nodes → 13–14 copies each ≈ 390–420 GBboth sit inside the ~540–576 GB safely usable per 32 GB nodethree master-eligible nodes is about quorum, not capacity — it does not scale with data
“1 replica” is a per-shard setting. It is the single most misread number in an Elasticsearch topology.
67 primary shards
+ 67 replica shards          ← one per primary
= 134 total shard copies

placement rule: a replica never sits on the same node as its own primary
                (otherwise losing that node loses the data)

data per node
  8 data nodes  → 16–17 copies × ~30 GB ≈ 480–510 GB
  10 data nodes → 13–14 copies × ~30 GB ≈ 390–420 GB
  both inside the ~540–576 GB safely usable per node

So how many nodes is the cluster, actually?

RoleCountScales with
Data nodes8–10Storage and search load — the 4 TB footprint.
Master-eligible3Nothing. Three is about quorum, not capacity.
Coordinating-only2–3Read concurrency and merge work.
Total13–16—
say it exactly like this

“When I say one replica, I mean each primary shard had one replica shard. With about 67 primaries that is another 67 replicas — around 134 shard copies — spread across 8 to 10 data nodes, so each data node carried roughly 13 to 17 copies. On top of that, 3 master-eligible nodes and 2 to 3 coordinating-only nodes, so the cluster was around 13 to 16 nodes in total.”

replicas are a cost decision too

Each replica is a full second copy of the index — it doubles storage, and every write is applied twice. It also adds read capacity and is what lets you survive a node loss. So the right count is the maximum of what availability needs and what read QPS needs, never a number inherited from a default.

09Decision 3 — a new index, not an in-place change

Primary shard count is fixed for the life of an index. That single constraint dictates the entire rollout: this is not a settings change, it is a new index, a backfill, a validation pass and a cutover.

PUT candidates_v2
{
  "settings": {
    "number_of_shards":   67,
    "number_of_replicas": 1
  },
  "mappings": {
    "properties": {
      "candidate_id":     { "type": "keyword" },
      "city_id":          { "type": "integer" },
      "job_profile_ids":  { "type": "keyword" },
      "experience_years": { "type": "short"   },
      "expected_salary":  { "type": "integer" },
      "last_active_at":   { "type": "date"    },
      "skills_text":      { "type": "text"    },
      "profile_title":    { "type": "text"    }
    }
  }
}

Then reindex the existing candidate data into it and replay production-like matching queries against both. The fact that you must build a second index turns out to be an advantage: you get a free A/B comparison on identical data before anything is at risk.

10Decision 4 — mapping for read-heavy search

what is this field for?filter · sort · aggregate · relevance · noneexact match → keywordranges → integer / short / dateflags → booleancity_id · job_profile_ids · is_activeexperience_years · last_active_atrelevance → textwith a deliberate analyzerskills_text · profile_titleonly where scoring actually needs itnone of the above →do not index itevery indexed field costs storage,heap and merge time — forever“dynamic”: “strict” — so a new field in the source cannot silently add itself to the mapping
Three outcomes, and the third one is the one teams forget — which is where index bloat comes from.
exact filter fields        → keyword / numeric / date
text relevance fields      → text
unused fields              → do not index
large, rarely used fields  → keep out of the _source response

candidate_id               → keyword
city_id                    → integer / keyword
job_profile_ids            → keyword
experience_years           → short / integer
salary_min / salary_max    → integer
last_active_at             → date
skills_text                → text
profile_summary            → text
the reason, in one line each

Filters should be fast. Scoring should happen only on fields that genuinely carry relevance. And every unnecessary indexed field costs storage, segment metadata, heap pressure and merge time — forever, on a 500M-document index.

11Decision 5 — filter first, score second

candidates_current — ≈ 500,000,000 documentsbool.filter — yes/no, no scoring, cacheableis_active · city_cluster_ids · experience range · job_profile_ids→ a few thousand candidates survivebool.should — scoring, on the reduced set onlyprimary profile boost · skills_text · titlerelevance rankingtop 100, source-filteredfilter contextcosts no scoring,and the filter cachecan serve it againquery contextruns on thousands,not hundredsof millions
The single most important rule in this system: the scorer must never see the whole corpus.
Hard filtersScoring signals
city / locationpreferred job profile priority
job profile eligibilityskills / profile text match
experience rangecandidate activity and freshness
salary fitprofile quality
active candidatelocation proximity
candidate availability—
{
  "query": {
    "bool": {
      "filter": [
        { "terms": { "city_id": [10, 11] }},
        { "terms": { "job_profile_ids": ["field_sales", "retail_sales"] }},
        { "range": { "experience_years": { "gte": 1, "lte": 3 }}},
        { "term":  { "active": true }}
      ],
      "should": [
        { "term":  { "primary_job_profile_id": { "value": "field_sales", "boost": 5 }}},
        { "match": { "skills_text": { "query": "sales customer handling", "boost": 2 }}}
      ],
      "minimum_should_match": 1
    }
  },
  "size": 100
}

Source filtering is the other half. Returning a full candidate profile when the API needs an ID and four fields costs fetch-phase work, serialisation, network and heap — multiplied by 100 hits, multiplied by every query. It is among the cheapest changes available and the most commonly skipped.

12Decision 6 — coordinating-only nodes

Before, application traffic went straight to data nodes, which meant the same machines were doing four jobs: coordinating query fanout and merge, executing shard-level Lucene search, storing shard data, and absorbing indexing load. For a read-heavy workload that is one job too many.

API layercandidate-search-api-1candidate-search-api-2candidate-search-api-ncoordinatingonlycoord-1fanout + mergecoord-2fanout + mergecoord-3fanout + mergedata nodesdata-1shards + query execdata-2shards + query execdata-3shards + query execdata-nshards + query execdedicated mastersmaster-1 · 2 · 3cluster state onlyno search trafficno shard datawhen every node does every role, a query spike can destabilise cluster management and take the whole thing downseparating roles means a search burst costs latency, not availability
Masters are drawn off the query path on purpose — that is the whole point of dedicating them.

A coordinating-only node stores no shard data — in Elasticsearch, a node with an empty node.roles list. It receives the query, fans it out to shard copies, gathers and merges the results, and returns the response. The effect usually shows up at p99 first, because merge work is exactly what was competing with shard execution under load.

13The experiment

Nothing here was changed blind in production. The sequence was the same every time.

  • Capture the baseline. p95/p99 latency, CPU per node, heap usage, GC pressure, search thread-pool queue and rejections, disk usage, shard sizes, slow query logs.
  • Create the new index. ~60–70 primaries, replica 1, optimised mappings, source filtering.
  • Reindex the candidate data from old to new.
  • Replay production-like read traffic. Job-to-candidate search, candidate filters, profile-based scoring, top-N retrieval.
  • Compare old vs new on latency, CPU, heap, disk, query throughput and recovery behaviour.
  • Cut over safely. Switch the alias or application config; keep the old index available for rollback.
the metric people forget to capture

Recovery behaviour. It is the one that only matters during an incident, and the one where 400 GB shards hurt most. If you only measure latency you will never see the improvement that saves you at 3am.

14Alias cutover

the API only ever queriescandidates_currentbeforeaftercandidates_v15 primaries · ~400 GB eachcandidates_v2explicit mappings · ~67 primariesreindex + backfilldual-write / catch-uprecent updatesshadow queriesresult parity + latencyPOST /_aliases — switchone atomic operationrollback is the same operation in reverse — keep v1 for a safe window
The application never learns the index changed. That is what makes a shard-count change survivable.
POST /_aliases
{
  "actions": [
    { "remove": { "index": "candidates_v1", "alias": "candidates_current" }},
    { "add":    { "index": "candidates_v2", "alias": "candidates_current" }}
  ]
}

The step that gets skipped and should not: result parity. Latency parity is easy to check. A mapping change that quietly alters what matches — an analysed field that should have been a keyword, a boost that reorders the top 10 — produces no error and moves no graph. Shadow queries comparing document counts and top-N quality are the only defence.

15How it got cheaper

shard count matched to data volumeexplicit mappings, fewer indexed fieldshard constraints moved to filter contextsource filtering on the responsecoordinating nodes off the data nodesbulk indexing + tuned refresh intervalless wasted worklower query CPUlower heap + GC pressuresmaller index on diskverify firstp95/p99 latency stableno search rejectionsrecovery time acceptableonly now: right-sizenode count · instance size · replicasinfrastructure cost downdoing this stepfirst is how youdestabilise a cluster
Cost is the last step, never the first. Remove nodes before removing work and you get an outage.
BeforeAfter
Large shards, heavy shard-level queriesRight-sized shards, lighter per-query work
Higher CPU and heap pressureLower CPU per query, stabler heap
Poor filesystem-cache behaviourHot working set can stay cached
Expensive data nodes, heavy over-provisioningLess over-provisioning needed

The tuning knobs, specifically

KnobSettingWhy
JVM heap~50% of RAM, under ~32 GBLeaves the rest for filesystem cache, and stays inside compressed object pointers.
Disk headroomPlan to ~70–75% usageElastic's low watermark defaults to 85% — past it the allocator stops placing shards on that node.
Query contextHard constraints in filterNo scoring, and the filter cache can reuse the result.
Result sizeBounded, with source filteringCuts fetch-phase work, serialisation and network.
Replica countReviewed, not inheritedEach replica is a full storage copy and another write destination.
Slow logsOn, and actually readFinds the queries that are slow rather than the ones that look slow.
Thread poolsWatch queue depth and rejectionsA rejection is a dropped request, not a slow one.
the watermark detail worth knowing

Elasticsearch's disk allocator works off watermarks — the low watermark defaults to 85%, and once a node crosses it the allocator stops assigning new shards there. That is why node capacity was planned at 70–75% of disk rather than to the edge: the last 15% is not usable space, it is the margin the allocator needs to keep working.

Once latency and node pressure improved, cost came down by:

  • Right-sizing node count and node type.
  • Avoiding oversized machines and unnecessary replicas.
  • Removing over-indexed fields, and with them disk and index bloat.
  • Reducing the search CPU each query required in the first place.
the sequencing argument

“The cost reduction did not come from one change. It came from removing wasted work, verifying stability, and only then re-sizing. Removing capacity before removing the work makes a cluster less stable, not cheaper.”

16The comparison brief

Four dimensions, before and after. If you only remember one screen of this page, make it this one.

beforeaftershard sizing5 primaries · ~400 GB each~67 primaries · ~30 GB eachnode topologydata nodes do search + coordination8–10 data nodes + coordinating-only tierindexing strategybroad mappings · full _sourceexplicit types · strict · source-filteredcluster tuningsized to survive the loadsized to the work after the waste was removedthe sequencing is the point: shards → mappings → queries → topology → and only then, capacity
Four levers, one order: fix the work first, then re-size the cluster around what is left.
AreaBeforeWhat we changedWhy it helped
Shard sizingFew large shards — ~2 TB primary across 5 shards ≈ 400 GB each, 100M docs eachNew index at ~60–70 primaries targeting ~30 GB per shardSmaller shards cut per-shard search cost, raised parallelism, and made recovery and rebalancing far faster. Elastic recommends 10–50 GB and under ~200M docs per shard — we were passing one test and failing the other.
Node topology32 GB data nodes storing data, running Lucene search, and coordinating and merging results8–10 data nodes · 1 replica · ~120–140 shard copies · 13–17 copies per node · coordinating-only tier addedData nodes could focus on shard-level search while coordinating nodes absorbed fanout and merge. Elastic notes coordinating-only nodes help large clusters by offloading that work from data and master nodes.
Indexing strategyUnnecessary indexed fields and mixed mapping choices; large documents inflating index size and memory pressurekeyword/numeric/date for filters, text only where relevance matters, unused fields unindexed, _source trimmedLess index bloat, segment metadata, heap pressure, disk and query work — which fed directly into both latency and infrastructure cost.
Cluster tuningLarge shards, limited filesystem cache, overloaded data nodes, expensive queriesHeap ~50% of RAM, disk kept under watermarks, filters in filter context, bounded result size, replica count reviewed, slow logs and thread-pool pressure watchedBetter p95/p99, lower CPU and heap pressure, no disk-watermark allocation problems — and once usage stabilised, room to right-size.

The cluster, stated precisely

before                              after
  5 primary shards                    ~60–70 primary shards
  ~400 GB per shard                   ~30 GB per shard
  100M docs per shard                 ~7–8M docs per shard
  data nodes do everything            8–10 data nodes (search only)
                                      + 3 master-eligible
                                      + 2–3 coordinating-only
                                      = 13–16 nodes total
the crisp version, said out loud

“The old system had around 500M candidate documents — roughly 1 TB of raw JSON and about 2 TB of Elasticsearch primary indexed data. The earlier layout used fewer, larger shards on 32 GB RAM data nodes, so each shard was hundreds of gigabytes. That caused high p95/p99 latency because shard-level searches were heavy, filesystem-cache locality was poor, and data nodes were handling both Lucene search and coordination work.”

“We changed four things. Shard sizing — used Elastic's 10–50 GB guidance and moved to roughly 60–70 primaries targeting about 30 GB each. Node topology — mapped total primary plus replica copies onto 8–10 data nodes and added coordinating-only nodes for read traffic. Indexing strategy — cleaned mappings so filter fields were keyword, numeric or date, relevance fields were text, and unused fields were not indexed at all. Cluster tuning — kept heap around 50% of RAM, preserved filesystem cache, watched disk watermarks, optimised query filters, and validated with slow logs and thread-pool metrics.”

“After experimenting with production-like candidate-search queries, the new layout improved latency and stability because searches were spread across right-sized shards, data nodes were under less pressure, and the index and query model did less unnecessary work. That let us right-size the infrastructure and cut cost.”

17Anti-patterns avoided

  • Sizing shards by document count alone — the mistake that hid this problem for as long as it did.
  • Changing shard count in place — not possible; it requires a reindex and alias strategy.
  • Scoring before filtering — the single largest source of wasted query CPU.
  • Returning the full _source — fetch, serialise and transfer cost for data the API discards.
  • High-cardinality dynamic fields in the mapping — unbounded growth on an index you cannot easily change.
  • Script scoring where a precomputed field would do — per-document work on every query.
  • Deep pagination with from/size — cost grows with offset; search_after exists.
  • Treating replicas as HA only — they are a storage and write-cost decision too.
  • Scaling the cluster down before reducing query cost — that is how you turn a cost exercise into an outage.
  • Tuning heap and CPU before reading the slow logs — optimising what you can see, not what is slow.

18Alternatives, argued fairly

  • Throw hardware at it. Fastest fix, and sometimes correct. Rejected because profiling showed the work itself was wasteful, and scaling waste just costs more. The order matters: reduce work, verify, then resize.
  • Bigger nodes instead of more. Tempting, but heap is capped near 32 GB for compressed object pointers, and past that extra RAM only helps filesystem cache. More nodes also buys parallelism and faster recovery, which a bigger box does not.
  • Route by city or job profile. Would remove fanout. Rejected because a candidate can hold several job profiles and the largest metros would become hot shards — you would trade a uniform cost for a skewed one.
  • A vector / semantic search layer. Better fuzzy relevance. Rejected for this problem because the dominant constraints — city, active, experience band, profile eligibility — are hard filters. Semantic ranking would sit after the filter step, and the filter step was the bottleneck.
  • Precompute candidate lists per job. Fastest possible read, rejected on cardinality and freshness. The partial version — caching top-N for hot jobs — is worth it, and that is what the cache in the architecture is for.
  • Hot/warm tiering by candidate activity. Real money, since inactive candidates are rarely searched. Sequenced after the changes with no correctness risk, because “inactive” needed a definition product had to own — getting it wrong silently excludes people from results.

19Question drill

Read the question, answer it out loud, then open the card and compare.

Sizing

500M documents across 5 shards is 100M each, which is within Elastic's guidance. So what was wrong?

That is exactly the trap. There are two guidelines — ≤ ~200M documents per shard and 10–50 GB per shard — and you have to clear both. At ~2 TB of primary store, five shards meant ~400 GB each, about 8× over the size ceiling. The count looked healthy, so nobody checked the bytes.

How did you get to 67 shards?

By derivation, not benchmark. ~2 TB of primary store divided by a 30 GB target — a middle value in the 10–50 GB band, chosen for a read-heavy workload — gives about 67 primaries. With one replica that is 120–140 shard copies. Then it was validated with production-like queries rather than trusted on arithmetic alone.

Where does 2 TB come from if the raw data is 1 TB?

Indexing overhead. The primary store holds _source plus the inverted index, doc values, term dictionaries, segment files and metadata. Roughly a 2× expansion over raw JSON, so sizing from raw payload underestimates by about half.

How many data nodes, and how did you decide?

From disk. A 32 GB RAM node at a 1:24 RAM-to-disk planning ratio is ~768 GB, of which 70–75% is safely usable — around 550 GB. A 4 TB cluster footprint divided by that is roughly 8 data nodes, so 8–10 is the practical range. That puts 13–17 shard copies on each node.

Why 32 GB nodes rather than something larger?

Heap is capped near 32 GB because beyond that the JVM loses compressed object pointers, so a 32 GB machine gives ~16 GB heap and ~16 GB filesystem cache. Since read-heavy search runs on the OS page cache, the sensible shape is roughly half and half — and when you need more capacity you add nodes, which also buys parallelism and faster recovery.

Is more shards always better, then?

No. Every shard adds query fanout, cluster state and segment overhead, and too many small shards is its own pathology. 67 across 8–10 nodes is 13–17 copies per node, which is a reasonable starting point to measure from — not a number to defend.

Mechanism

Why did large shards hurt the filesystem cache specifically?

A 32 GB node has ~16 GB of OS cache. With hundreds of GB of shard data on it, the hot working set simply cannot stay resident, so queries fall through to disk. Lucene reads segment files through the page cache, so cache locality is read performance. Smaller shards spread across more nodes give the hot set a chance of being served from memory.

Why is filter context so much cheaper than query context?

Filter clauses answer yes/no — no relevance score is computed, and results are cacheable and reusable. Query-context clauses score every document they consider. Putting active or a city constraint in scoring context means paying score computation on documents that were never eligible.

What do coordinating-only nodes actually buy you?

They take fanout and result-merge work off the nodes holding shards. Before, data nodes were doing four jobs at once: coordination, shard-level search, storage and indexing. A coordinating-only node stores no shard data — an empty node.roles — and the improvement typically shows at p99 first.

Can't you just change the shard count?

No — primary shard count is fixed for the life of an index. That is why this shipped as a new index plus a reindex plus an alias switch. It also turns out to be an advantage: you get a like- for-like comparison on identical data before anything is at risk.

Rollout and cost

How did you validate before cutting over?

Baseline everything first — p95/p99, CPU, heap, GC, thread-pool queues and rejections, disk, shard sizes, slow logs. Build the new index, reindex, replay production-like read traffic, and compare old versus new on latency, CPU, heap, disk, throughput and recovery behaviour. Then switch the alias, keeping the old index for rollback.

What was the riskiest part of the cutover?

Silent relevance drift. Latency regressions are obvious; a mapping or analyser change that quietly reorders the top 10 candidates is not — nothing errors and no graph moves. Result-parity checks on shadow queries are the only defence.

Walk me through how latency work became a cost cut.

In strict order: reduce wasted work (smaller shards, fewer scored documents, smaller responses, leaner index) → CPU, heap and storage pressure drop → verify p95/p99, rejections and recovery are stable → only then right-size node count, node type and replica count. Reversing that order makes the cluster less stable rather than cheaper.

Aren't replicas purely a high-availability setting?

They are both. Each replica is a full copy of the index — storage cost — and another destination for every write, while also adding read capacity. The right count is the maximum of what availability requires and what read QPS requires, not a number inherited from a default.

What would you do next?
  • Hot/warm tiering once product defines “inactive” safely.
  • Cache top-N for the hottest jobs rather than recomputing identical searches.
  • Replace from/size with search_after wherever deep paging remains.
  • Continuous shard-size monitoring, so the next reindex is triggered by evidence rather than a hunch.
  • Revisit routing only if fanout is measured to be the bottleneck.

20What not to say

Do not saySay instead
“We had too many documents per shard.”Document count was within guidance. Shard size was ~8× over.
“I optimised Elasticsearch.”Name the six decisions: shard sizing, node capacity, new index, mappings, query structure, coordinating nodes.
“We added shards so it got faster.”We re-derived shard count from primary-store size against Elastic's 10–50 GB guidance.
“I owned the Elasticsearch platform.”I owned the candidate-search performance work on it.
“We scaled the cluster down to save money.”We removed the wasted work first, verified stability, then right-sized.
“More shards is always better.”There is a ceiling — fanout, cluster state and segment overhead all grow with shard count.

21Gaps to fill before the interview

these numbers are a reconstruction

The storage model, shard target, node counts and ratios here are a capacity model — sound arithmetic against Elastic's published guidance, not measurements from your cluster. Before quoting any specific figure, confirm it is one you actually observed.

  • Ownership, per area. Implemented / recommended / reviewed, for each of the six decisions.
  • Real shard numbers. Original and final primary count, and the actual average shard size in GB.
  • Real node counts. Data, master and coordinating nodes before and after, and the instance type.
  • Real latency. Measured p95 and p99 before and after, by query type.
  • The specific clauses that moved from scoring to filter context.
  • Cost composition. Whether the ~50% came from node count, instance type, replicas, storage — or a mix.
  • Mentorship story. One engineer from the five: gap, coaching, what they owned afterwards.
closing line

“The lesson was that we had been measuring the wrong dimension. Documents per shard was inside guidance and gigabytes per shard was eight times over, and every symptom — latency, heap, cache misses, slow recovery, cost — came from the number nobody was checking.”

That's the search platform. Three more deep dives sit alongside it — the allocation platform, mtptrace and the terabyte pipeline — and the notes cover the underlying topics.

← back to projects