The metrics engine

The Elasticsearch essays · June 2026

A metric data point is the smallest document imaginable: a metric name, a few identifying labels (which host, which service), a timestamp, a number. Perhaps twenty bytes of actual information, arriving millions of times per second, forever. Stored as an ordinary document, each point drags along machinery built for documents: an _id, sequence numbers for concurrency control, index entries per field. The wrapping weighs more than the gift; historically, about 25 bytes per point. This essay walks the rebuild that fixed it, and every figure is an experiment you run yourself. (The columnar foundations live in Numbers at scale.)

Elasticsearch's time-series mode (TSDS) attacks that overhead with five changes, and the figure below lets you apply them one at a time, in any order. Each colored square is one data point from one of three series; the bar underneath is where its bytes actually go:

points on disk, in arrival order
25.0
bytes per data point
per day at 1B points/day
0%
Each toggle is a real change shipped in the engine, with the blog's own byte attribution. Try skippers or codecs before sorting: they barely help, because interleaved series give the skippers mushy block summaries and the codecs jumpy deltas. Sort first and everything else lands. Then drag the jitter slider: the more regular the scrape interval, the closer timestamps get to free.

Walk the toggles and you've walked the engineering. Sort by series, then time, and route each series to a single shard, so every series is one contiguous, sequential run on disk; nothing else works without this. Then the big one: for timestamps and dimensions, lightweight per-block skip summaries replace the inverted index and BKD tree entirely, deleting the duplicated per-field index machinery, about ten of the twenty-five bytes, while the skip-driven queries later in this essay stay fast. The _id becomes synthetic, computed as _tsid plus timestamp instead of stored and indexed (a bloom filter keeps get-by-id working), five more bytes. That _tsid deserves a proper introduction, because the whole engine leans on it: it is the identity of one time series, a compact hash of the series' stable dimension set. Points share a _tsid while their timestamps and values change; alter a single identifying dimension and you are, by definition, in a different series. Sequence numbers are trimmed because metrics never see concurrent updates: four bytes of replication metadata gone. And the numeric codec blocks widened from 128 to 512 values, so repeating dimension patterns, IPs and MAC addresses cycling host by host, finally fit inside the compressor's window. Together: 25 bytes become 3.75, and a day of a billion points shrinks from 25 GB to under 4.

Reading it back: skip first, decode later

Queries got the mirror treatment. Because the data is sorted by series and time, each series is a run of compressed blocks, and each block carries a tiny label: which series, which time span. A metrics query almost always begins with where and when, "payments, last four hours", and both conditions are answered by reading labels, not data. Blocks that can't match are skipped before they are ever decompressed. Steer the query yourself:

–
blocks decompressed
–
disk actually read
Drag the orange window anywhere on the day. Every cell is a compressed block of one service's points; a block is decompressed only if its label row matches the service filter and its time span touches your window. Everything else costs one label-read. Pick a service, shrink the window to an incident's hour, and the day's telemetry collapses to a handful of blocks: the race bars below are the difference between "answer while you're still looking at the graph" and "answer after you've alt-tabbed away."

What happens to the surviving blocks is just as deliberate. They decode zero-copy, straight into the primitive-typed blocks the compute engine works on, no intermediate row objects, no allocator churn; a dimension that repeats for a thousand consecutive points isn't even expanded, it becomes a constant block, one value and a count. The work parallelizes by slicing the _tsid range by prefix, so every thread still reads its slice of disk in order. Even the humble act of rounding timestamps into buckets was rebuilt: when the bucketing is timezone-agnostic, it collapses to a modulo, worth a third of the response time on some queries. None of these is a headline; together they are the headline.

The hardest little function: rate()

The most important question you can ask of a counter, how fast is it growing?, hides a trap. Counters only go up, until the process restarts and the counter falls back to zero. Subtract naively across a restart and you compute a huge negative rate; a dashboard built on that lies to you at exactly the moments that matter, the restarts. Elasticsearch's rate() walks each series in time order (the sort again) and treats every drop as a reset, adding the lost baseline back:

2
40m
You're on call. Drag your finger along the night. Top: the raw counter, sawing back to zero at each restart. Bottom: the same data as a rate, computed both ways: the gray bars subtract naively, the green line walks each series in time order and treats every drop as a reset. The two cards follow your cursor, and wherever a restart falls they disagree violently: naive subtraction reports a huge negative spike at 3 a.m., at exactly the moment you'd be staring at the dashboard deciding whether to roll back. Add restarts, widen buckets: the green line never lies.

This is what the TS command in ES|QL is for: it computes functions like rate() inside each series first, and only then aggregates across series. That command, and the fact that PromQL itself now runs natively against this storage, deserve their own essay: The language of time.

Ingest without ceremony

None of this matters if the data can't get in fast enough, so the write path was rebuilt around the same shape. Toggle the five optimizations and watch the cost of accepting one million points per second:

☄️ 1,000,000 points per second arriving. Each square is a CPU core working full-time to swallow them.
–
cores needed
–
bytes written per point
The grid as you found it is the old world: metrics as ordinary JSON documents, forty-odd cores just to keep up. Now do the engineering yourself. Protobuf parses for a fraction of JSON's cost; the series hash is computed once per batch instead of once per point; the coordinator forwards _tsid so data nodes never repeat the work; the recovery copy of the source stops being written and gets re-synthesized on demand instead; and segments carry lightweight skippers instead of hot inverted structures, so the eternal background merging gets cheaper too. Every core you extinguish is a real machine somebody doesn't have to run.

Growing old: downsampling

Metrics age faster than any other signal: nobody needs ten-second resolution on last quarter's CPU. So aging metric indices get downsampled: raw points are replaced by one summary document per series per interval, keeping the min, max, sum and count exactly (for gauges; counters keep their last value), and letting everything else go. Choose what history becomes:

–
points kept
–
storage vs raw
raw · 10s
A day of CPU at ten-second resolution, with one three-minute incident spike. Slide the resolution coarser: the gray band is what downsampling keeps exactly, the min-to-max envelope plus sum and count, so averages stay perfect and the spike's peak survives all the way to one-hour rollups (watch the band, not the line). What dies is the fine wiggle and any percentile you didn't precompute. "Average CPU in March": exact. "The p99 that Tuesday": gone. That is why downsampling is a policy decision, not a default.

The cardinality bomb

Everything above assumed you know how many time series you have. Nobody does, because series count is a product, not a sum: every distinct combination of dimension values is its own series, its own _tsid, its own storage and memory. Arm the bomb yourself:

–
time series
–
memory for series metadata
A hundred hosts times eight services is eight hundred series: trivial. Now add one innocent label, say endpoint, with 200 values, and you didn't add 200 series, you multiplied by 200: 160,000 series, each one indexed, sorted and tracked. The classic accident is a label that was never meant to be a dimension at all, a request id, a container hash, and it is how monitoring systems die. The defense is choosing dimensions deliberately: dimensions are for things you group by; everything else belongs in the point, not the key.

And since this essay makes performance claims, the published benchmark numbers (Elasticsearch 9.x, default configurations, open workload generator, single node):

query typevs Prometheus/Mimirvs ClickHouse
gauge averageup to 30× fasterup to 8× faster
counter rateup to 30× fasterup to 3.5× faster
storage per point2.5× smaller2× smaller

One more number floats around this work, and it deserves its own baseline, kept deliberately separate from the table: up to 160× faster than the same metrics stored the old way, as ordinary documents in Elasticsearch's own document store. That comparison isolates the architectural value of the purpose-built path itself, engine against engine on the same data, rather than system against system; collapse the two baselines into one claim and both numbers stop meaning anything.

One last elegance: PromQL, the language most of the world's dashboards already speak, runs against these very same column files. Same storage, two dialects; nobody rewrites their dashboards to switch engines.