r/RedditEng • u/sassyshalimar • 2d ago
Materialized metrics in Prometheus
Written by Aleksandr Krivoshchekov and Walther Lee.
Prometheus has a simple idea at its core: metric_name + labels = time_series. Any two samples with the same metric name and labels map to the same series, but adding even a single extra label initiates a completely new series.
In Kubernetes, that extra label is usually pod. It’s noisy and always changing. It spams our system with more and more series, slowing down queries and draining memory. It’s mostly pointless for our users. But it’s also crucial for Prometheus to evaluate queries correctly.
All these problems with pod are partially solved in Prometheus, but they are still hard to avoid entirely. We could add periodic preaggregations known as recording rules to improve latency. We could sample and keep only a fraction of our metrics to get back some memory. We could even start all over again and try a different database. Each of these options would help for a while, but each of them also create new bottlenecks. What we wanted was an option that kept our existing model with minimal impact for our users.
This post is about that option, which dropped our total number of series used in production queries by 88%, reduced latency by 80%, and achieved 99.85% fidelity over 1M metrics per second in a single instance.
The problem
As the observability team at Reddit, we ran into the challenge of controlling our series count a while ago. If we instrument an application with counter series and send it to Kubernetes, those counters will show up in a pod. And since deployments can have multiple replicas, these counters have to be stored separately to be able to identify them by their own labels. Otherwise, a pod that measures 5 counts in a metric will conflict with another one that counts only 3.
# The label "pod" is necessary to keep track of counts from different targets
http_client_requests_total{pod="pod-a"} 5
http_client_requests_total{pod="pod-b"} 3
In its simplest form, this way of storing counters should scale with our deployments. If we have three replicas, Prometheus should store three counters that we can then add up at query time to get the total number. The reality, however, is not that simple.
Pods have very active lifecycles; they can be restarted after a rollout, shuffled by a node rotation, or killed by Kubernetes because they failed a check. A broad range of infrastructure events can make our pods come and go, and since each will start exposing its own metrics, Prometheus has to keep up with the growth at least until old entries are cleared out. Suddenly, a rollout can double our number of counters just by deploying a whole fleet of new pods and then double them again if we rollback.

Services at Reddit expose billions of metrics at any given time, so these infra events can cause a big impact. A large deployment with thousands of pods often needs millions of counters to monitor values like the total number of errors seen by users. Letting that payload double with every rollout compromises our observability when teams need it more.
This scaling pattern with metrics in Prometheus is unfortunate because applications in Kubernetes are actually designed to be unchanged by these events. A well designed deployment should be able to scale up and down, and to handle unhealthy pods seamlessly, without any impact in user experience. It comes as a surprise to product teams that events that don’t affect their applications do affect their ability to monitor them.
On top of that, product teams don’t even care for these separation of metrics. They want to see the overall state of their deployments: the total number of requests and errors, the total latency. Single pods are treated as ephemeral and fungible, so the fact that they can influence their observability is an unexpected drawback.

Separation of metrics is an implementation detail that teams don’t care about, so we wanted to find a new solution that could match that expectation. We decided to start with a simpler goal in mind:
- Find an efficient way of aggregating all counters in real-time to drop only the labels added by our infrastructure layer, like pod.
Aggregate at query time
The trivial solution would be just letting it grow and aggregate at query time. For example, with the expression:
sum without (pod)(rate(http_client_requests_total[1m]).
This is basically what we had before doing anything, and the main problem is still the unexpected changes in the number of counters that the query will load. While unexpected, this growth wouldn’t be so bad if it was intuitive. We think it’s reasonable to accept that the number of metrics will go up if we scale up a deployment. But teams don’t expect their query latency to go up just because they rolled out a change.
In practice, aggregations at query time are not consistent in latency or throughput, and the standard way to work around that in Prometheus is using recording rules.
Just drop the labels in recording rules
The single most common request we get in observability from other teams is if we can just drop the pod label. Unfortunately, that takes us back to the original problem: if two pods record different counts for the same metric, there’s no way to know which one we should keep. Probably both.
What teams really mean is to add up the counts without this label that split them in the first place. For that purpose, Prometheus uses recording rules to aggregate common queries periodically in the background. However, this has multiple downsides:
- At a big scale, evaluating a recording rule takes almost as much resources as evaluating the query, so using recording rules helps with latency, but it shifts the heavy payload from query time into a continuous load in the background.
- It generates toil, because teams have to define their rules in advance, and it’s hard to predict what information they will need, especially for incidents.
- And finally, they can still timeout and fail if the number of series spikes after multiple infra events.
In our experience, recording rules were a good option for shifting latency, but they were not a solution for our high volume of aggregations.
Aggregating at scrape time
The main bottleneck that nudged us to move away from recording rules was redundancy. If we use rules to drop labels, those evaluations will run periodically and load data that was scraped just a few seconds ago. We figured that we could do better than that by just aggregating the samples right when we receive them at scrape time.
Another good reason was the fact that scrapes are performed by Prometheus concurrently, as opposed to rule evaluations that compute operations in sequence across the different counters. Concurrent aggregations meant a more efficient use of our resources, so we decided to start by giving that a try.
However, the first problem we found is that counters in Prometheus are cumulative; their values can only go up or reset to 0, so when a query sees a sequence like 10, 14, 5, 8, it assumes that the pod restarted after 14 and then counted up to 5:
http_client_requests_total 10 14 (reset) 5 8
# Prometheus assumes the actual deltas were
http_client_requests_total +10 +4 (reset) +5 +3 = 22
If we just add up cumulative counters, a single restart will show up as a huge increment right after the reset:
http_client_requests_total{pod="pod-a"} 5 10 15 20 # +5 +5 +5 +5 = 20
http_client_requests_total{pod="pod-b"} 7 12 17 22 # +7 +5 +5 +5 = 22
http_client_requests_total{pod="pod-c"} 9 14 2 7 # +9 +5 +2 +5 = 21
# in a query with increase(...) we would expect the deltas to be
increase(http_client_requests_total[1m]) +21 +15 +12 +15 = 63
# however if we simply add them up, the series turns into
http_client_requests_total{} 21 36 34 49
increase(http_client_requests_total[1m]) +21 +15 +34 +15 = 85
A change in pod-c that was only 2 counts after the restart becomes 34 after the sum. The change becomes even more catastrophic when adding up counters during a rollout, as shown in the panels below:

In the end, we realized that adding up cumulative counters is an inherent but well-known limitation in Prometheus. But it’s fixable. By turning cumulative values into a sequence of increments or deltas we can solve the problem upfront, exactly how recording rules handle it.
Instead of adding up the raw values, recording rules use functions like rate and increase to account for resets and return only the delta over a step interval, and as long as all deltas are calculated over the same window, they can be safely added up.
For example, in the panels below, a pod restart shows up as a gap and a reset in the cumulative counter on the left side, but on the right side, the deltas just keep the gap. Working with deltas, aggregations don’t need special checks to add them up, even between restarts.

Switching to deltas completely solved the problem of aggregating counters at scrape time. We just had to keep track of resets and the last value seen for each counter to compute the deltas after every scrape!
However, this approach introduces a major catch: converting to deltas requires a fixed time interval to calculate them, but our teams query vastly different time windows throughout the day. While a 1-minute delta works perfectly for short few-hours-long graphs, it breaks down on a longer view. Ultimately, we would either have to lock everyone into a single interval or find a way to convert these deltas back into cumulative counters.
This delta-to-cumulative challenge isn’t new. Open-source projects like OpenTelemetry and VictoriaMetrics already offer processors to convert metrics between these formats. However, cumulative counters are stateful. This means that all aggregations must happen inside a single collector. If you split the load across two replicas that started at different times, their cumulative totals won't match, making it impossible for one to seamlessly fill in gaps if the other fails.
So our goal was no longer how we can aggregate our counters at scrape time. The new goal was:
- How can we turn deltas back into counters in a way so that multiple replicas can agree on the value without requiring consensus? And how can we use the counters in one to deduplicate and fill gaps in another?
The ZigZag
We asked other engineers and read some posts and white papers that gave us an anchor of the error margin that other approaches were achieving. Some were using streaming in external components, probability algorithms, proxies, remote-write and processors. Overall, most of them expected an error margin around 9%.
But before trying any of that, which would require more time and changes and deployments, we decided to try something simpler. From studying how other projects convert cumulative counters to deltas it’s clear that the goal is to shorten the span of time a counter keeps a total count, so what’s the shortest span we can have while still being considered cumulative? The scrape interval!
If we use the scrape interval as the span window for our deltas, and then we force the counter to reset immediately after, then the counter kind of resembles a delta. It’s still cumulative, but now it looks like a zigzag.
Keeping in mind that we were just trying to get close to the 9% error margin, and since that sounds pretty small, we decided to give it a try. We implemented the aggregation step at scrape time using deltas in Prometheus, and then added them up into a zigzag counter and ran some queries.
last = 0
for every 15s:
delta = delta_since_last_flush()
if delta < last:
last = delta
else:
last = delta + last
flush(last)
And it worked! The panels below show the delta below and its generated zigzag cumulative counter above.

Deduplication for zigzag counters
Zigzag counters let us store the aggregated data back in Prometheus again, but this isn’t too different from what we mentioned before with OpenTelemetry processors. The main difference is that now our data looks kind of the same between replicas.
The zigzags on each replica oscillate at different frequencies and are definitely not identical, but now the scale and values are somehow comparable. We can easily use one to fix gaps in the other, and the rates they return are very accurate! They have an average error margin of 0.15% with just 5 samples, exceeding our expectations by far.
We let this run for a few months in a test instance and it worked great. Aggregating a little over 4M series, driving query load and throughput down to just 3% of our old value for the metrics names with more series, and it didn’t even seem to use more resources. The operations are really light: add up or set a new number. And the new allocations are also minimal. It’s equivalent to adding just one more pod value to the label.
Zigzagging worked well for requests querying recent data, mostly because that data usually comes from a single replica in our system, but after a few months, we noticed an issue with queries spanning multiple hours.
Since Prometheus stores data in 2-hour blocks, long queries connecting multiple blocks had issues jumping between zigzags from different replicas. And we had the same problem deduplicating samples. For example:
25.31 @ 1775649538
50.48 @ 1775649568
25.49 @ 1775649598
25.42 @ 1775649607 // Note how the value goes down again
52.39 @ 1775649628
23.89 @ 1775649658
The spikes happen because, even though both samples [email protected] and 25.42 @...607 recorded almost the same value at the same time, the second one is slightly lower, triggering the queriers to treat it as a restart. When a rate or increase is calculated, it reads as the following deltas:
50.48 @ 1775649568
25.49 @ 1775649598 // a change of ~25 in 30s
25.42 @ 1775649607 // a change of ~25 in 9s!
52.39 @ 1775649628 // again, ~25 in only 21s
The extra sample ends up doubling the rate of 25 every 30s, and the inconsistency will last until the rate interval passes that point.
We fixed that by adding an extra step in our function to build zigzagging counters. Technically, counters following the algorithm above don’t have to be zig-zags. A counter whose rate is slowing down would record lower and lower deltas, resulting in a counter that goes continuously down, signaling a reset in every single scrape.
We decided to block that option and force our counters to go down only once in a row, essentially forcing a real zigzag pattern.
With that constraint, now we can walk through our deduplicated samples and explicitly exclude the ones that break the pattern. These values couldn’t have come from the same replica, so we know that a switch was made. The switch loses some accuracy, but this isn’t different from a normal deduplication algorithm that breaks samples’ resolution.
Let's say a change in replicas while deduplicating causes a second drop, resulting in the following two streams:
Replica 1: 22 50 21
Replica 2: 20 48 19 52 20
Replica 1: 22 50 21 50
Replica 2: 48 19 52 20
First, we tried discarding the last drop: 20 in the first stream and 19 in the second. This was a small change because samples are streamed for deduplication, so we just had to watch for two consecutive drops and remove the second. However, that doesn’t work for the second stream. After the drop we have:
Samples: 22 50 21 48 19 52 20
Delta: 28 21 27 19 34 20
Samples: 22 50 21 50 48 52 20
Delta: 28 21 29 48 4 20 # 48 and 4 break the steady rate!
Instead, we can fix both inconsistencies by keeping a look forward and discarding the first drop instead:
Samples: 22 50 20 48 19 52 20
Delta: 28 20 28 19 34 20
Samples: 22 50 21 50 19 52 20
Delta: 28 21 29 19 34 20 # Seamless transition
Production results
This algorithm was implemented and tested in a Prometheus fork and deployed to some of our instances in production.
For our largest metric, it shrank the number of series to only 2% its previous value, and the average throughput and latency across all queries went down to 12% and 20%, respectively.
In terms of resources, we didn’t find any significant memory overhead other than the one added by the new zigzag series ingested. CPU usage increased by 6%, but can be easily offset by replacing previous recording rules to aggregate the same data.
Mean error margin between raw and aggregated data was 0.15% with a standard deviation of 0.13%.
This was measured in an instance scraping 1M+ samples per second, using 70Gi and 16 cores before the implementation. It had 17M series before aggregation, and resulted in 2M new zigzag series.

Limits and Tradeoffs
This algorithm doesn’t solve every metric problem. It is limited to counters and counter-like types like Prometheus classic histograms, but gauges, summaries, and native histograms need different aggregations.
It also doesn’t change the existing Prometheus expectation around counter resets. If a pod restarts between scrapes and the new process reaches a value higher than the previous sample, Prometheus cannot know that a reset happened:
# Scrape #1
http_client_requests_total{pod="pod-a"} 90
# "pod-a" restarts
http_client_requests_total{pod="pod-a"} 0
# Scrape #2
http_client_requests_total{pod="pod-a"} 140
# After the second scrape, Prometheus will assume the delta is +50, not +140
That’s the same behaviour we see today with regular counters.
There is also a replica-local visibility limitation. Our aggregations run independently within each Prometheus replica using only the samples that the replica scraped. If only one of two replicas loses connection to a target, it will miss samples that were successfully aggregated in the other. Resulting in different values that we can’t compare or use to fill gaps between them.
This wasn’t a big tradeoff for us because it is exactly how recording rules work already. So we can think of it as a lightweight implicit recording rule:
# record: cluster:http_client_requests_total:sum
# expr: sum without (pod, instance) (http_client_requests_total)
cluster:http_client_requests_total:sum
==
# An internal series produced by our algorithm.
http_client_requests_total:materialized
Finally, in our implementation we decided to keep the raw metrics. The algorithm only adds the new series as an optimization path for common aggregation queries, while raw ones remain available for debugging or per-pod queries. In the future, we could drop them after aggregation to reduce memory, which is an option we never had with rules.
What’s next?
Aggregating metrics during scraping is an idea that we had been chasing for a long time. What surprised us most was that we didn't need a massive rewrite or a fancy new storage format to achieve that. We already had all the necessary pieces of the puzzle, we just lacked a small but clever way to put them together.
The production results exceed what we thought was possible without switching databases, or at least without adding proxy-layers. Our algorithm cut query latency by 80% and dropped the total number of series loaded by 88%. And the best part is that it requires no changes for our users: all the same metrics, the same PromQL expression, and the same mental model.
That makes the next step pretty clear. We want to take this feature out of our fork and bring it to open source Prometheus, where our design can be discussed, tested, and improved by the community. By doing so, we hope that these changes can help others and spare them the scaling troubles we faced.

















































































