BENCHMARKS· XERJ V1.0.0-RC.1 VS ELASTICSEARCH 8.13.4· 2026-07-06

EVERY CELL.
BOTH DIRECTIONS.

One harness, two engines, one machine, identical workload — 91 measured dimensions across ingest, every query, aggregation and pipeline family, mixed read-under-write, kNN and on-disk size. The honest score is 81 WIN · 8 LOSE · 2 N/A: XERJ wins 81 of the 91 cells. Every loss is published below, next to the wins, and each one fails our CI. Reproduce the whole thing on your own box with four commands.

81 / 91
XERJ wins
Lower latency · higher throughput · smaller on disk
8
Losses
5 mixed read-under-write + 3 sub-ms ties · each fails CI
2
Not scored
Queries ES 8.13 itself rejects (400)

XERJ v1.0.0-rc.1 vs Elasticsearch 8.13.4 — one machine, one client, identical workload. Across repeated runs the total lands 77–84 as a cluster of sub-millisecond aggregation rows flips inside client jitter; the 5 mixed read-under-write losses are the only constant. We publish a single run verbatim, not the best draw.

COVERAGE·HOW MUCH OF ELASTICSEARCH XERJ IMPLEMENTS
ES-wire conformance 1326 / 1329 99.8%
REST API endpoints 152 / 162 93.8%
Aggregation families 61 / 73 84%
Query DSL types 40 / 58 69%

The 91 benchmarked dimensions measure performance; these bars measure functional coverage. The 10 endpoint gaps are cross-cluster replication and ML _cat shims a single node can't back; XERJ adds knn / semantic / hybrid beyond ES. Eight specialized families (geo, ip, nested, span, percolate, semantic) are implemented but await purpose-built benchmark corpora.

BENCHMARK COVERAGE·HOW MUCH OF ELASTICSEARCH THE 91 CELLS MEASURE
Query DSL types 26 / 58 45%
Aggregation types 42 / 73 58%
Query + aggregation surface 68 / 131 52%

Distinct from the bars above: this is how much of Elasticsearch's feature surface the 91-cell matrix actually perf-measures — about half (~52%), not 80% or 100%. It's dense where the flat LLM-telemetry corpus allows (all compound + term-level + full-text queries, every metric and pipeline aggregation family) and zero where a purpose-built corpus is required: geo_point, ip, nested / join, span, significant_text, percolate. Those families are largely implemented (see the bars above) — "not benchmarked" means not perf-measured, not "not built." The matrix additionally measures 7 search features, kNN latency + recall, ingest throughput, mixed read-under-write, and on-disk size, which aren't part of the 131-type denominator.

01·SETUP

SAME BOX.
SAME WORK.

Both engines run as a single node on the same machine, security off, over localhost — same hardware, same client, byte-for-byte the same request bodies.

MACHINE
AMD Ryzen AI Max+ 395 · 32 hardware threads · 119 GiB RAM · Linux
XERJ
v1.0.0-rc.1 · release build · --insecure, fresh data dir · port :9200
ELASTICSEARCH
8.13.4 official tarball · security off, single-node · 4 GB heap · port :9201
CORPUS
Real LLM-telemetry events (model, status, latency, cost, tokens, tenant, timestamps) — not synthetic filler. Reads run against 1M docs; ingest is measured at 100k and 1M docs with 1 and 8 concurrent clients.
TOPOLOGY
1 node vs 1 node · same machine · localhost · identical request bodies through an identical client
02·METHODOLOGY

DESIGNED TO BE
HARD TO GAME.

The harness is demo/playbooks/bench-matrix.mjs — one file, Node builtins only, checked into the repo. Its rules:

READ TRANSPORT
Read / mixed / kNN latencies are timed through a lean keep-alive HTTP client (Node core http + a shared per-host http.Agent), applied identically to both engines — we avoid Node's global fetch/undici because it adds ~1.5 ms of client overhead per request that would swamp both engines' sub-millisecond server times. Ingest / kNN bulk load uses curl, identically for both.
OPEN-LOOP LOAD
Read requests fire on a fixed 200 req/s cadence, independent of when earlier responses return — a slow engine can't slow the clock down and flatter its own tail.
SAMPLING
Per family: 15 untimed warmups, then p50 over 120 timed iterations, with a 15 s per-request bound. An engine that hangs is recorded and scored, not silently dropped.
CORRECTNESS GATE
Every family is probed first; a 4xx marks it unsupported for that engine. If the two engines return materially different results, the row is scored N/Aan engine that returns wrong or empty results can't win on latency. track_total_hits: true is forced on both sides so neither wins by short-circuiting the count.
VERDICTS
Every row is scored from XERJ's point of view and shown in plain terms — N× faster (gold) or N× slower (red) versus ES on that metric. Any LOSE row makes the runner exit non-zero — the scorecard is a CI gate, not a brochure.
03·FULL RESULTS · NO CHERRY-PICKING

THE WHOLE
SCORECARD.

All 91 rows from demo/playbooks/SCORECARD.md, grouped by family. Every family is collapsed — the tally beside each (wins · losses) tells you where to look; open any family to see every row. Each family names its metric and unit; the VS ES column reads in plain terms — 1.9× faster when XERJ wins, 28× slower when it loses — so you never have to decode a raw ratio.

Total · 91 dimensions 81 W · 8 L · 2 N/A
Ingestbulk throughput · higher is better 4 W
DIMENSION
XERJ docs/s
ES docs/s
VS ES
VERDICT
ingest 100k × c1
113,384
72,272
1.6× faster
WIN
ingest 100k × c8
382,301
253,961
1.5× faster
WIN
ingest 1m × c1
119,031
70,320
1.7× faster
WIN
ingest 1m × c8
387,678
379,506
1.0× faster
WIN
Full-text & phrase queriesp50 query latency · lower is better 9 W · 2 N/A
DIMENSION
XERJ ms
ES ms
VS ES
VERDICT
match_all
0.88
1.72
1.9× faster
WIN
match_none
0.48
1.08
2.3× faster
WIN
match(model)
0.57
1.45
2.5× faster
WIN
match_phrase(top_doc)
1.46
2.48
1.7× faster
WIN
match_phrase_prefix
1.33
not supported
N/A
match_bool_prefix
1.33
5.02
3.8× faster
WIN
multi_match
1.44
5.89
4.1× faster
WIN
combined_fields
1.38
not supported
N/A
query_string
1.35
9.35
6.9× faster
WIN
simple_query_string
1.44
2.29
1.6× faster
WIN
more_like_this
1.39
2.43
1.8× faster
WIN
Term, range & filter queriesp50 query latency · lower is better 12 W
DIMENSION
XERJ ms
ES ms
VS ES
VERDICT
term(status)
1.36
2.15
1.6× faster
WIN
terms(model)
1.40
4.49
3.2× faster
WIN
range(latency_ms)
1.34
4.66
3.5× faster
WIN
range(@timestamp)
1.33
2.15
1.6× faster
WIN
range(cost_usd)
1.37
6.90
5.0× faster
WIN
prefix(model)
1.36
8.78
6.5× faster
WIN
wildcard(model)
1.35
9.10
6.8× faster
WIN
regexp(model)
1.35
9.11
6.8× faster
WIN
fuzzy(model)
1.35
2.67
2.0× faster
WIN
exists(cost_usd)
1.32
2.19
1.7× faster
WIN
ids
1.13
1.95
1.7× faster
WIN
term(cache_hit)
1.36
2.16
1.6× faster
WIN
Compound & relevance queriesp50 query latency · lower is better 6 W
DIMENSION
XERJ ms
ES ms
VS ES
VERDICT
bool must+filter+should+must_not
1.38
15.90
12× faster
WIN
constant_score
1.37
2.20
1.6× faster
WIN
boosting
1.34
34.42
26× faster
WIN
dis_max
1.35
5.51
4.1× faster
WIN
function_score
1.35
39.25
29× faster
WIN
pinned
1.35
14.49
11× faster
WIN
Metric aggregationsp50 latency · lower is better 14 W
DIMENSION
XERJ ms
ES ms
VS ES
VERDICT
avg
1.16
2.23
1.9× faster
WIN
sum
1.16
2.10
1.8× faster
WIN
min
1.16
2.03
1.8× faster
WIN
max
1.16
1.96
1.7× faster
WIN
stats
1.16
1.99
1.7× faster
WIN
extended_stats
1.17
2.25
1.9× faster
WIN
value_count
1.16
1.91
1.6× faster
WIN
cardinality
1.15
1.92
1.7× faster
WIN
percentiles
0.36
0.81
2.3× faster
WIN
percentile_ranks
1.17
1.99
1.7× faster
WIN
median_absolute_deviation
1.10
2.04
1.8× faster
WIN
matrix_stats
1.34
1.95
1.5× faster
WIN
scripted_metric
1.29
1.91
1.5× faster
WIN
top_hits (sub)
1.07
1.69
1.6× faster
WIN
Bucket aggregationsp50 latency · lower is better 16 W · 1 L
DIMENSION
XERJ ms
ES ms
VS ES
VERDICT
terms
1.34
1.54
1.1× faster
WIN
rare_terms
1.27
1.55
1.2× faster
WIN
significant_terms
1.13
1.21
1.1× faster
WIN
histogram
0.49
0.74
1.5× faster
WIN
date_histogram
0.37
0.88
2.4× faster
WIN
auto_date_histogram
0.48
0.71
1.5× faster
WIN
variable_width_histogram
0.48
1.57
3.2× faster
WIN
range
0.42
0.65
1.6× faster
WIN
date_range
0.40
0.90
2.3× faster
WIN
filter
0.93
0.71
1.3× slower
LOSE
filters
0.38
0.69
1.8× faster
WIN
missing
0.30
0.64
2.1× faster
WIN
global
0.52
1.28
2.5× faster
WIN
adjacency_matrix
0.42
0.67
1.6× faster
WIN
composite
0.36
1.47
4.1× faster
WIN
random_sampler
1.17
5.65
4.8× faster
WIN
terms+avg(cost)
0.38
1.16
3.1× faster
WIN
Pipeline aggregationsp50 latency · lower is better 10 W · 2 L
DIMENSION
XERJ ms
ES ms
VS ES
VERDICT
sum_bucket
1.19
1.12
1.1× slower
LOSE
avg_bucket
0.36
0.77
2.1× faster
WIN
max_bucket
1.19
2.08
1.8× faster
WIN
stats_bucket
0.36
0.73
2.0× faster
WIN
percentiles_bucket
0.41
2.16
5.3× faster
WIN
derivative
1.13
0.67
1.7× slower
LOSE
cumulative_sum
0.38
1.77
4.7× faster
WIN
moving_fn
1.18
1.39
1.2× faster
WIN
serial_diff
0.37
0.96
2.6× faster
WIN
bucket_script
1.20
2.23
1.9× faster
WIN
bucket_selector
0.39
0.74
1.9× faster
WIN
bucket_sort
0.66
2.19
3.3× faster
WIN
Query featuresp50 latency · lower is better 7 W
DIMENSION
XERJ ms
ES ms
VS ES
VERDICT
sort-heavy
1.89
17.39
9.2× faster
WIN
deep from+size (from 500)
1.39
2.20
1.6× faster
WIN
search_after
4.34
18.61
4.3× faster
WIN
highlight
0.74
2.12
2.8× faster
WIN
_count
0.92
1.38
1.5× faster
WIN
_msearch
0.96
1.24
1.3× faster
WIN
_mget
0.28
1.49
5.3× faster
WIN
Mixed — read under concurrent writep99 read latency while a bulk writer ingests · lower is better 5 L
DIMENSION
XERJ ms
ES ms
VS ES
VERDICT
match_all
65.48
2.30
28× slower
LOSE
bool
63.07
9.55
6.6× slower
LOSE
range
151.89
5.11
30× slower
LOSE
terms
61.84
2.70
23× slower
LOSE
cardinality
98.92
18.84
5.3× slower
LOSE
Vector & kNNquery latency & recall 2 W
DIMENSION
XERJ
ES
VS ES
VERDICT
kNN k=10
0.78 ms
2.64 ms
3.4× faster
WIN
kNN recall@10
100.0%
100.0%
on par
WIN
Storageon-disk index size · smaller is better 1 W
DIMENSION
XERJ
ES
VS ES
VERDICT
index on-disk size
672.5 MB
806.7 MB
1.2× smaller
WIN
04·REPRODUCE IT

FOUR COMMANDS.
YOUR MACHINE.

Everything on this page regenerates from the repo — no hosted harness, no private dataset, no hand-tuned engine flags.

$ git clone https://github.com/xerj-org/xerj && cd xerj
$ cargo build --release --manifest-path engine/Cargo.toml
$ bash scratchpad/es_up.sh
$ bash scratchpad/run_scorecard.sh --docs 100k,1m --clients 1,8 --knn --mixed
es_up.sh
Downloads the official Elasticsearch 8.13.4 tarball (cached after the first run), configures a single node with security off on :9201, and boots it with a 4 GB heap. Idempotent.
run_scorecard.sh
Boots the release XERJ binary on :9200 with a fresh data dir, runs the matrix against both engines, and shuts XERJ down. Exits non-zero if any row is a LOSE.
HARNESS
demo/playbooks/bench-matrix.mjs — the runner and scorecard generator (Node 24, no dependencies). Output lands in demo/playbooks/SCORECARD.md, the exact file this page's table is rendered from.

Your absolute numbers will differ with hardware — the ratios and verdicts are the claim. If your run disagrees, file an issue with your SCORECARD.md; that is exactly what the harness is for.

05·KNOWN ISSUES & WHAT'S NEXT

THE RED CELLS
ARE THE ROADMAP.

Mixed read-under-write · 5 rows
The real, constant weakness — now precisely root-caused. With a concurrent bulk writer, read p99 spikes to ~50–120 ms vs ES's 2–19 ms. It is not a per-query cost: match_all — an O(1) counter that does no work per doc — stalls identically to range/terms, which proves the tail is a global flush stall, not an algorithmic loop. XERJ ingests ~5× faster than ES, so it flushes ~90× per run; each flush briefly freezes queries. The flush path is already deprioritized, storm-gated and warm-before-publish, and we've bounded the per-match allocation (range 152→~117 ms) and made counts incremental — but beating ES's steady-state here needs a rolling/incremental segment build with no discrete finalize stall. That flush-path re-architecture is the top open item; it is the honest price of 5×-faster ingest.
Sub-millisecond tie-band
A handful of aggregation rows where XERJ and ES land within tenths of a millisecond — which side "wins" flips run to run, so the total drifts inside 77–84. Run 10 caught agg filter, pipe sum_bucket / derivative; other runs catch percentiles or composite instead. Per-request overhead, not algorithmic gaps — under profiling, not counted as a real loss.
Hardening along the way
Durable mappings across restarts, a merge-race segment lease (601k queries under merge churn, zero mismatches), WAL/segment garbage collection, a merge data-loss fix, a dedicated deprioritised ingest pool, and cooperative query timeouts.
Not measured here
Skipped, not hidden — each needs a purpose-built index the flat corpus lacks: geo_*, ip_range / ip_prefix, nested / has_child / has_parent, span_*, significant_text, semantic / hybrid retrieval, and percolate. Purpose-built corpora are planned follow-ups.
The standing guardrail
Every performance change must hold ES-YAML REST conformance at 1326 passed / 0 failed. Speed bought with correctness is not a win, and it does not merge.
06·TESTING CHANGELOG

THE SCORE,
RUN BY RUN.

The honest trajectory — wins out of 91 for every published run. It is not a straight line up: twice we caught ourselves measuring the wrong thing and reset. Run 6 (hollow bar) is the one we retracted — a 77-win high we couldn't reproduce once a stale result cache was fixed. The gold line is where the number actually went.

43first · Jul 4
35r4 · Jul 4
45r5 · Jul 5
77r6 · retracted
47r7 · Jul 6
53r8 · Jul 6
75r9 · Jul 6
81r10 · now

Wins out of 91 per published run (0–91 scale). The gold line is the honest trajectory; run 6 (dashed) was retracted — a 77-win high that vanished once a stale result cache was fixed.

2026-07-06 (mixed-p99 root-cause)
We finally caught what the 5 mixed losses actually are. The tempting theory was an O(N) per-query loop that grows with data. We disproved it with one measurement: match_all — which counts hits with an O(1) counter and does zero per-doc work — stalls to ~55 ms identically to range and terms. If it were per-query cost, match_all would stay fast. So the tail is a global flush stall: XERJ ingests ~5× faster than ES and flushes ~90× per run, and each flush briefly freezes every in-flight query. We shipped two honest wins along the way — bounding a per-match allocation storm (range 152→~117 ms) and making memtable counts incremental — and confirmed the flush path is already deprioritized, storm-gated and warm-before-publish. Two things we refused: a thread-pool trick that quietly slowed the sub-millisecond aggregations, and inflating the flush buffer just to dodge flushes inside the 15 s test window (that's measuring the gap you left, not closing it). Real fix = a rolling segment build with no discrete finalize stall. Filed under: name the wall before you climb it.
2026-07-06 (runtime-worker fix)
Post-run-10 engine change. Shipped a tokio runtime over-provision that un-starves request-accept during flush/merge windows — roughly halving the mixed read-under-write p99 tail (match_all 65→~45 ms, range 152→~50 ms typical). Still short of ES's quiesced 2–19 ms, but no longer a cliff. Re-running the fixed binary a handful of times, the total lands 77–84 (median ~80) as the sub-millisecond aggregation tie-band flips run to run; the 5 mixed cells stay the only constant losses. We keep run 10's 81/8/2 as the published board — squarely mid-band — rather than cherry-pick the 84-win draw.
2026-07-06 (run 10)
81 WIN. Eight cells flipped LOSE→WIN over run 9 as the engine work landed — ingest 1m×c8 (now 1.02×), aggs global / composite / terms+avg / range, pipe serial_diff, simple_query_string, and deep from+size — while two former wins regressed to sub-millisecond near-ties (sum_bucket, derivative). Net 81 W / 8 L / 2 N/A: the 8 losses are the 5 mixed read-under-write p99 rows plus 3 sub-millisecond near-ties (agg filter, pipe sum_bucket / derivative). Supersedes run 9.
2026-07-06 (run 9)
75 WIN. Corrected the read-latency transport: earlier runs timed reads through Node's global fetch/undici (~1.5 ms of client overhead per request), which swamped both engines' sub-millisecond server times and pushed ~40 rows into a noise band. Re-ran on a lean keep-alive HTTP client, applied identically to both engines and cross-checked with curl (XERJ ≤ ES on every op). True server round-trips now show full-text / term / range / wildcard reads winning outright, kNN 5.28× at 100% recall parity, index 1.26× smaller, ingest 3 of 4 cells. 14 honest losses remain · 75 W / 14 L / 2 N/A. Supersedes run 8.
2026-07-06 (run 8 — SUPERSEDED)
Reported 53 WIN / 36 LOSE / 2 N/A. Reads were still timed through the undici client, whose fixed per-request overhead hid both engines' true sub-millisecond server times — roughly 40 rows sat in a client-jitter band. Transport fixed and re-run honestly as run 9.
2026-07-06 (run 7 — SUPERSEDED)
First honest read numbers after the query-cache fix, and first run with the harder mixed read-under-write cluster added — but still timed through undici · was 47 WIN / 42 LOSE / 2 N/A.
2026-07-05 (run 6 — RETRACTED)
Retracted as a measurement artifact: the 77-win headline leaned on post-forcemerge-quiesced disk/kNN figures and a result cache that wasn't being invalidated under test — steady-state it didn't hold. Fixed and re-run as runs 7–9 · was 77 WIN / 12 LOSE / 2 N/A.
2026-07-05 (run 5)
Agg-hang cluster eliminated, text queries fixed, board clean (no collapses / mismatches); mixed-tail + request-overhead + c8 flush fixes in flight · 45 WIN / 41 LOSE / 5 N/A.
2026-07-04 (late, run 4)
Clean rerun after the round-1 fixes. c1 ingest and search_after flip to WIN; agg-hang cluster isolated (~27 rows, fix in flight) · 35 WIN / 47 LOSE / 9 N/A.
2026-07-04
First public full-matrix publication. XERJ v1.0.0-rc.1 vs Elasticsearch 8.13.4 · 91 dimensions · 43 WIN / 33 LOSE / 15 N/A · run truncated by a search_after OOM defect, since fixed.
2026-07-01
The query-cache mirage — our favourite bug to have caught before anyone else did. An early head-to-head had XERJ winning reads by a suspiciously cheerful margin; turned out the harness was replaying the same query against a static index, and our result cache was happily serving every call after the first — so we were benchmarking the cache, not the engine. Uncached, a match_all size:10 actually took ~2.28 s, because hit materialization scanned every match instead of the top from+size. We fixed that path from O(N) to O(from+size), taught the harness to invalidate and to flag result-signal mismatches, and re-ran honestly. Filed under: if the numbers look too good, they are.
earlier · pre-public
Development runs. Many un-numbered iterations shaking out the harness and the engine before any of this was fit to publish — the usual loop of find a bug, fix it, and distrust the result until it holds up.