Elasticsearch Mentok 10.000 Hasil? Fix track_total_hits
Pagination Elasticsearch mentok di 10.000 itu bukan bug, tapi default track_total_hits. Ini cara baca hits.total dan milih fix yang pas buat UI lo di 2026.

Ada satu dashboard log yang gue maintain jalan di atas Elasticsearch, dan di bawah tiap tabel ada footer pagination. Page 1 of 500, gitu deh. Suatu sore ada yang bikin tiket bilang angkanya salah. Index-nya udah tumbuh banyak bulan itu, tapi footer-nya masih nampilin angka yang sama kayak beberapa minggu lalu. Total results, 10.000. Selalu pas 10.000. Gak pernah 10.001, gak pernah 47.382.
Reflek pertama gue ya nyalahin diri sendiri. Mungkin ada limit yang kehardcode, mungkin ada slice sisa development yang lupa dihapus. Gue habisin sejam baca query builder sama component tabelnya sebelum akhirnya gue lihat raw response dari Elasticsearch, dan ketemu satu field yang bertahun-tahun gue cuekin. Angkanya gak salah. Elasticsearch emang sengaja berhenti ngitung, dan dia udah ngasih tau gue dari awal.
Poin Penting
- Elasticsearch berhenti ngitung match di angka 10.000 secara default. Ini setting track_total_hits, dan perilakunya udah kayak gini sejak Elasticsearch 7.0.
- Response-nya sebenernya ngasih tau kalau hitungannya dipotong. Field hits.total.relation ngasih nilai gte, bukan eq, dan mayoritas code aplikasi gak pernah baca itu.
- Set track_total_hits ke true buat hitungan persis, ke angka kayak 100000 buat hitungan yang dibatasi, atau ke false biar query paling ngebut tanpa total sama sekali.
- Ada limit 10.000 kedua yang beda namanya index.max_result_window, dan itu yang ngeblok deep pagination pakai from dan size. Ngubah track_total_hits gak ngangkat limit ini.
- Buat paging lewat dari 10.000 baris, pakai search_after bareng Point in Time, jangan naikin limit.
Kenapa Elasticsearch Berhenti Ngitung di 10.000?
Elasticsearch berhenti ngitung di 10.000 karena ngitung semua match itu mahal dan mayoritas orang gak butuh angka persisnya. Sejak versi 7.0, parameter track_total_hits default-nya 10000. Begitu engine-nya udah mastiin ada minimal 10.000 dokumen yang cocok, dia berhenti nyatet dan lanjut ngumpulin top hits yang lo minta.
Ini trade-off performa yang disengaja. Ngitung semua match artinya engine gak bisa ngambil jalan pintas. Dia harus nyamperin tiap dokumen yang cocok, termasuk yang ranking-nya jauh di bawah dan gak bakal pernah lo tampilin. Ngeskip kerjaan itu bikin Elasticsearch bisa pakai optimasi yang berhenti lebih awal, dan itu alasan default-nya ada.
Yang penting dicatat, pemotongan ini gak diem-diem. Elasticsearch ngelaporinnya di response. Code lo tinggal baca field yang bener aja.
Also Read: Kubernetes Logging yang Bener: Fluent Bit ke Elasticsearch
Gimana Cara Baca hits.total yang Bener?
Field hits.total itu object dengan dua properti, value sama relation, dan lo harus baca dua-duanya. Properti relation ngasih tau apakah value itu angka pasti atau cuma batas bawah.
Kalau hitungannya persis, response-nya kayak gini.
{
"took": 4,
"timed_out": false,
"hits": {
"total": {
"value": 3271,
"relation": "eq"
},
"hits": []
}
}Kalau hitungannya dipotong, yang keluar kayak gini.
{
"took": 6,
"timed_out": false,
"hits": {
"total": {
"value": 10000,
"relation": "gte"
},
"hits": []
}
}Relation eq artinya equals, jadi value itu total beneran. Relation gte artinya greater than or equal, jadi total aslinya 10.000 atau lebih dan Elasticsearch gak bakal ngasih tau lo angkanya berapa. Segitu doang misterinya.
Nah ini bug yang hampir semua orang ship. Code aplikasi baca hits.total.value, bagi sama page size, terus render page count. Gak pernah ngelirik relation. Jadi query yang match dua juta dokumen nampilin footer yang sama persis kayak query yang match tepat sepuluh ribu, dan gak ada yang sadar sampai datanya tumbuh lewat threshold itu.
Fix minimalnya cuma tiga baris.
const { value, relation } = response.hits.total;
const isExact = relation === "eq";
const label = isExact ? `${value} results` : `${value}+ results`;Bahkan kalau lo mutusin pemotongannya gak masalah, nampilin tanda plus itu jujur. Itu ngasih tau user kalau angkanya batas bawah, bukan fakta.
Apa track_total_hits True Itu Lambat?
Iya, track_total_hits true emang lebih lambat, dan lambatnya ngikutin berapa banyak dokumen yang match, bukan berapa yang lo tampilin. Set ke true maksa Elasticsearch ngitung seluruh set yang cocok di tiap shard sebelum ngasih sepuluh baris ke lo.
Buat query yang match beberapa ribu dokumen, selisihnya biasanya gak berasa. Tapi buat query lebar di index time series yang gede, ini bisa jadi beda antara response ngebut sama request yang bikin user melototin spinner. Biayanya ngikutin jumlah match, jadi filter yang mempersempit hasil dengan bagus bikin hitungan persis tetep murah.
Makanya jawaban jujurnya bukan sekadar nyalain aja. Jawabannya milih setting yang cocok sama kebutuhan interface lo.
Nilai track_total_hits Mana yang Harus Dipilih?
Pilih berdasarkan pola pagination lo, bukan berdasarkan seberapa lo suka angka presisi. Ada tiga opsi dan tiap opsi ngasih hal yang beda.
| Setting | Yang lo dapet | Biaya | Paling cocok buat |
|---|---|---|---|
true | Hitungan persis, relation selalu eq | Latency naik seiring total match | Audit log, export compliance, “showing X of Y” yang harus bisa dipercaya |
Angka, misal 100000 | Persis sampai batas, gte di atasnya | Worst case-nya terkunci | Numbered pagination yang butuh total jujur tapi perlu plafon |
false | Gak ada field hits.total sama sekali | Paling ngebut, ngaktifin early termination | Infinite scroll, tombol load more, navigasi cursor |
Set ke true cuma ubah satu kata di request body.
{
"track_total_hits": true,
"size": 20,
"query": {
"bool": {
"filter": [{ "term": { "status.keyword": "failed" } }]
}
}
}Versi yang dibatasi tinggal ganti boolean-nya jadi integer. Elasticsearch ngitung persis sampai angka itu terus lapor gte di atasnya, jadi query yang parah gak bisa nyeret cluster lo ambruk.
{
"track_total_hits": 100000,
"size": 20,
"query": { "match_all": {} }
}Satu detail yang sering bikin kepleset. Pas lo set track_total_hits ke false, Elasticsearch gak ngirim hits.total sama sekali. Bukan nol, tapi emang gak ada. Code yang manggil response.hits.total.value bakal throw error, bukan nampilin angka salah, jadi kasih guard ya.
Also Read: Cara Handle 429 Rate Limit di Bulk API Request
Apa Bedanya track_total_hits sama max_result_window?
Ini dua limit beda yang sama-sama default 10.000, dan itu persis alasan orang ketuker. Parameter track_total_hits ngatur sejauh mana Elasticsearch ngitung. Setting index.max_result_window ngatur sedalam apa lo boleh paging.
Set track_total_hits ke true ngasih lo page count yang akurat. Tapi itu gak bikin lo bisa ngunjungin halamannya. Begitu from plus size lo lewat max_result_window, request-nya langsung gagal dengan error soal result window kegedean.
Result window is too large, from + size must be less than or equal to: [10000]Jadi user yang sekarang lihat 84.120 hasil dan klik ke halaman 600 bakal dapet error, bukan data. Lo benerin label-nya tapi malah ngebuka masalah yang lebih parah di belakangnya. Ini bug yang beneran gak enak buat di-ship, dan itu kenapa dua setting ini harus dipikirin bareng.
Lo bisa naikin max_result_window per index, tapi dokumentasi resmi Elasticsearch nyaranin jangan, soalnya deep offset paging maksa tiap shard bikin dan nyortir result set sebesar offset lo. Naikin plafonnya berarti naikin biaya memory-nya juga.
Gimana Cara Pagination Lewat dari 10.000 Hasil?
Pakai search_after bareng Point in Time, dan ini pendekatan yang direkomendasiin dokumentasi Elasticsearch buat deep pagination. Point in Time, biasa disingkat PIT, itu view beku yang ringan dari index lo dan bikin hasilnya konsisten selama lo paging.
Pertama, buka PIT-nya dan simpen id yang dibalikin.
POST /my-log-index-*/_pit?keep_alive=1mTerus jalanin search pertama ke PIT itu dengan sort yang ada tiebreaker-nya.
{
"size": 1000,
"query": { "match": { "level": "error" } },
"pit": {
"id": "46ToAwMDaWR5BXV1aWQyKwZub2RlXzMAAAAAAA==",
"keep_alive": "1m"
},
"sort": [{ "@timestamp": { "order": "asc" } }, { "_shard_doc": "asc" }],
"track_total_hits": false
}Tiap hit balik bawa array sort. Ambil array sort dari hit terakhir di halaman itu, terus kirim sebagai search_after di request berikutnya, bareng PIT id dari response sebelumnya.
{
"size": 1000,
"query": { "match": { "level": "error" } },
"pit": {
"id": "46ToAwMDaWR5BXV1aWQyKwZub2RlXzMAAAAAAA==",
"keep_alive": "1m"
},
"sort": [{ "@timestamp": { "order": "asc" } }, { "_shard_doc": "asc" }],
"search_after": ["2026-09-01T05:30:04.832Z", 4294967298],
"track_total_hits": false
}Ada tiga hal yang penting di sini. Sort-nya butuh tiebreaker biar dokumen dengan timestamp identik tetep punya urutan stabil, dan _shard_doc tersedia gratis pas lo pakai PIT. Keep_alive-nya diperpanjang tiap kali PIT dipakai, jadi gak perlu nutupin durasi crawl lo seluruhnya. Dan track_total_hits false itu pasangan normalnya, soalnya paging gaya cursor emang gak punya nomor halaman buat dihitung.
Konsekuensinya, search_after ngasih lo next sama previous, bukan lompat ke halaman 600. Kalau produk lo beneran butuh numbered deep pagination di atas jutaan baris, solusinya biasanya diskusi produk soal filter yang lebih bagus, bukan result window yang lebih gede.
Gimana Biar Bug Ini Gak Balik Lagi?
Set track_total_hits secara sadar di satu shared query builder, jangan diserahin ke tiap call site. Alasan bug jenis ini awet lama itu karena dia gak keliatan di code review. Query tanpa track_total_hits kelihatannya sama persis kayak query yang emang niat pakai default, jadi gak ada yang bisa bedain itu keputusan atau kelupaan.
Nyentralisasi artinya satu tempat yang ngatur perilaku semua query di codebase, dan reviewer yang ngubah file itu tau persis apa yang lagi dia tuker.
Habis itu, bikin test lo assert dua field, bukan cuma angkanya.
it("returns an exact total when tracking is enabled", async () => {
const result = await searchLogs({ level: "error" });
expect(result.total.value).toBe(3271);
expect(result.total.relation).toBe("eq");
});Test yang cuma ngecek value bakal lolos dengan senang hati walaupun hitungannya kepotong di angka pas 10.000, padahal itu justru satu-satunya kasus yang pengen lo tangkep. Assert ke relation itu yang bikin test-nya jadi berarti.
Pertanyaan yang Sering Diajukan
Apa track_total_hits ngaruh ke aggregation? Gak. Aggregation selalu dihitung di seluruh dokumen yang match tanpa peduli track_total_hits, jadi terms atau date histogram tetep akurat walaupun hits.total dipotong. Kalau lo cuma butuh angka persis doang, jalanin aggregation atau count API biasanya lebih murah daripada nyetel track_total_hits ke true.
Apa ini berlaku juga di OpenSearch? Iya. OpenSearch fork dari Elasticsearch 7.10 dan mewarisi default 10.000 yang sama buat track_total_hits, object hits.total yang sama dengan value dan relation, plus perilaku max_result_window yang sama. Semua isi artikel ini jalan di dua-duanya.
Bisa gak ngubah default track_total_hits secara cluster wide? Gak bisa. Gak ada cluster setting buat itu, jadi harus dikirim per request. Nah itu persis alasan naruh ini di shared query builder worth it banget. Tapi lo bisa ngubah index.max_result_window per index, soalnya yang itu setting level index.
Kenapa response-nya bilang gte, bukan langsung kasih angkanya? Karena gte itu Elasticsearch lagi jujur, bukan nebak. Dia berhenti ngitung di threshold, jadi satu-satunya pernyataan yang bener itu minimal segini dokumen yang match. Ngasih 10.000 dengan relation eq malah jadi bohong.
Apa count API kena limit yang sama? Gak. API _count ngasih total persis tanpa batas 10.000. Kalau interface lo cuma butuh angka dan bukan barisnya, manggil _count terpisah itu opsi yang bersih, walaupun konsekuensinya nambah satu request tiap load halaman.
Fix-nya sendiri cuma satu baris di shared query builder. Yang beneran makan waktu itu sejam gue nyariin bug di code sendiri sebelum ngecek apa yang sebenernya lagi diomongin sama search engine-nya. Elasticsearch gak nyembunyiin apa-apa. Ada field namanya relation nangkring di tiap response yang pernah gue log, dan gue gak pernah sekalipun baca itu. Kalau dashboard lo hari ini nampilin angka bulat yang mencurigakan, mulai dari situ deh.


