An app emits a number that changes over time — a request count, a queue depth, a latency reading — once every few seconds, forever. Multiply that by a fleet of machines and millions of these little streams arrive all at once. Later, an operator opens a dashboard and asks a question over a time range: the tail latency over the last month, how error rate moved across an incident, whether memory is trending up this quarter. Build the system that swallows that firehose of numbers, keeps them for as long as they are useful, and answers those time-range questions fast — and that cannot afford to keep every raw number forever. This is a metrics and monitoring system: a store built for numeric time series, tuned so that writing is cheap, keeping is affordable, and reading a range is fast.
Predict ~1.5 h · Read ~50 min
The problem i
A metrics system looks like an ingest problem and turns out to be a storage-cost problem. The instinct is to worry about the firehose — millions of samples a second pouring in, how could one system possibly swallow that? Hold that instinct, because it is pointing at the easy half. Everything hard about this system turns on a different question, and it is not how fast you can write.
Here is the first fact. Ingesting the numbers fast is not the hard part. A sample is tiny — a series it belongs to, a timestamp, and a number — and samples arrive roughly in time order, so storing them is an append. Appending is a solved, named move: buffer recent samples in memory, write them to disk in batches, and spread the load across many machines when one runs out. The firehose is huge, but it is shardable in the most ordinary way, by splitting the series across more machines. If swallowing the firehose were the whole problem, this would not be a system worth designing.
Here is the second fact, and it is where the wall is. Keeping every raw sample, for as long as anyone might want it, is a cost that grows in two directions at once and quickly stops being affordable. It grows with time, because you keep samples for months or years. And it grows with something less obvious called cardinality. A series is not one metric — it is one metric for one specific combination of labels. A latency metric split by endpoint, by host, and by status code is not one series but one for every combination of an endpoint and a host and a status code. Add one more label, and you do not add a few series — you multiply. And here is the trap. Attach a label whose values are unbounded — a user ID, a request ID, an email address — and a single metric explodes into millions of series, one per distinct value, most of them seen once and never again. The storage cost is series count times bytes per sample times how long you keep it, and one careless label multiplies the first term by a thousand. No disk you can buy and no compression you can run closes a gap that multiplies like that.
Those two facts flip the problem. The scary-sounding part, the firehose, is the routine part — you shard it. The boring-sounding part, how much you keep and how you labelled it, is the wall. And it is a wall that adding hardware in the obvious way runs straight into, because the cost is multiplicative and disks are not free at the petabyte scale this reaches. Swallowing the firehose is the easy half. Affording to keep what you swallowed, and still being able to query it, is the whole game.
There is a third strand, quieter but real, and it is about reading. All of this storing is pointless if the operator's query is slow or takes a node down. Two things make the read path its own problem. Almost every query is about recent data — the last day or so — while the rare query that reaches back months has to scan a vastly larger span. And a single query that tries to pull too many series or too long a range can exhaust one machine's memory and fall over. So the read path has to be fast on the common recent query and safe on the rare enormous one, which is not automatic.
One idea frames why all of this is worth the trouble. The naive design — keep every raw sample forever, because disks are cheap — is not just expensive, it is impossible at fleet scale, and seeing exactly why is the whole point of this board. The fix is not one clever trick. It is a small set of levers that each attack the cost on a different axis: shrink what each sample costs to store, make old data cheaper to keep the longer it ages, and stop the series count from exploding in the first place. Every piece later in this article is one of those levers, introduced where the numbers force it, and each one exists because the capacity math says the naive design cannot survive.
Two timings are worth pinning now, because decisions later turn on them. Reading a recent sample from an in-memory hot tier is about a millisecond. Reading the same value off a durable on-disk block is about five, and fetching an aged block back from cheap cold storage is slower still, an object-storage round-trip. The gap between a millisecond from memory and a fetch from cold storage is exactly the gap the read path is built to keep the common query on the right side of.
Functional requirements what it must do
Collect samples from every machine in the fleet, continuously.
Answer a range-and-aggregate query fast, especially on recent data.
Keep samples for as long as they are useful, affordably.
Bound the cost of the label dimension.
Survive a long-range or high-cardinality query without taking a node down.
Non-functional requirements what it must be
Ingest the fleet's firehose without falling behind, sharded across machines.
Keep the storage cost sub-linear in retention.
Serve the recent-data query fast, at about a millisecond.
Treat a dropped sample as a gap, not a fault.
Out of scope left out on purpose
How alerting rules are authored and evaluated
Logs and traces
The data model for a metric
How each sample is compressed on disk, bit by bit
How the design works
The finished design, explained one piece at a time: first the contract the system serves, then the smallest version that works, then the version that survives scale — walked use case by use case, one deep dive at a time, with the finished diagram at the end.
The API
The whole product is these endpoints. Every box in the design below exists to serve one of them.
SCRAPE targets
Take in samples — the fleet exposes its current numbers and the system pulls them on an interval, one series-timestamp-value at a time for every series a target exposes. A short-lived job that would die before the next pull can push instead. This is the firehose, and it happens millions of times a second at fleet scale.contract ▾
# Pull by default — the fleet exposes numbers, the system scrapes them on an interval.→ scrape target # over HTTP, on a fixed interval← (series, ts, value)… # every series the target currently exposes# a short-lived job pushes to a gateway instead, to be scraped there
QUERY range
Answer a range query — pick the series by their labels, give a time window, and ask for a summary over it: a rate, an average, a percentile. This is the operation the read path is built to make fast on recent data and safe on long ranges.contract ▾
# The read operation — a summary over a time window, made fast on recent data.→ query(selector, time range, aggregation)
← a series of aggregated values over the window # hot tier if recent, rollups if long
EXPIRE by-retention
Delete by retention — the system drops data on a schedule, not on a caller's request. Once samples pass their tier's retention window they are expired; a caller almost never deletes by hand, the retention policy does it.contract ▾
# Not caller-driven — the retention policy expires data per tier on a schedule.retention: raw kept for days · rollups kept for years
→ (no caller DELETE) — samples past their tier's window are dropped automatically
part 1 The single-server version
Start with the smallest thing that works: one app exposing a few numbers, and one server that pulls them in, stores them, and answers a query about them.
Start with the vocabulary, because these few words carry the whole problem. The app exposes some numbers — say a running count of requests it has handled and the current depth of its work queue. Each such number is a series: a named stream of values over time. What names a series is not just the metric name but the metric name together with its labels — key/value pairs like an endpoint or a host — so the request count for the checkout endpoint and the request count for the cart endpoint are two different series. Every reading the server records is a sample: a series, a timestamp, and a value. How often the server records a sample is the resolution — once every fifteen seconds, say. How long it keeps those samples is the retention — a few days, or a few years. The act of pulling the current values off the app is a scrape. And the question an operator later asks — average queue depth over the last hour — is a range query. Those words — a sample, a series, a label, resolution, retention, a scrape, a range query — carry everything that follows.
The day-one design is direct. There are two boxes. A Collector scrapes the app on an interval, pulling its current numbers over HTTP every fifteen seconds. And a Time-series store holds the samples. The store does not write every sample straight to disk one at a time, because that would be a tiny random write per sample and the disk would thrash. Instead it keeps the most recent couple of hours of samples in an in-memory head block, and to survive a crash it also appends each incoming sample to a write-ahead log on disk — a plain sequential log it can replay on restart to rebuild the head block. Every couple of hours the head block is written out as an immutable on-disk block covering that time span, and older blocks are later merged together into bigger ones. When the operator asks a range query, the server reads the relevant blocks — plus the in-memory head for anything very recent — and returns the summary. For one app and one server, this is genuinely fine, and it already has the shape the whole design keeps: pull in, buffer in memory with a log behind it, flush to immutable time-partitioned blocks, read a range back.
Now the teaser, and it is worth stating plainly even though the single server never feels it, because it is what justifies every later piece of this design. This one server is holding a handful of series. A real fleet holds millions. Take an illustrative instance of five million active series — squarely in the range a single production instance really runs. At a low resolution kept for a single year, the samples for just that one instance already run to terabytes on disk. That is a lot for just keep the numbers, but the terabytes are not the frightening part. The frightening part is what one careless label does to them. Attach one label whose values are unbounded, with a thousand distinct values, and those five million series multiply into billions, and the terabytes multiply into petabytes for a single year. No disk budget and no compression closes a gap that multiplies by a thousand. That petabyte wall is invisible on the single server, and it is the whole reason the scaled design is shaped the way it is. The scaled section does this arithmetic out loud when it reaches the wall; what matters here is that the wall is real, it is invisible at this size, and it is unavoidable at fleet size.
The whole system on day one.
Following a request
Samples in from the fleet
the Collector scrapes the app's current numbers over HTTP on a fixed interval~1 ms
and appends the samples to the store — into an in-memory head block, with a write-ahead log behind it, flushed to immutable blocks every couple of hours~1 ms
A range query out
the operator asks a range query and the store answers it, reading the relevant blocks plus the in-memory head for anything very recent~5 ms
One hop dominates every path: the client's request crossing the public internet to reach the datacenter. That hop is tens of milliseconds — call it about 30 — and it is set by physical distance, not by anything we build. Everything after it happens inside a single datacenter, where the network between two machines is well under a millisecond — so those numbers are the actual work, not the wait: a durable write to disk about 10 ms, an indexed read about 5 ms. A hop between two datacenters would cost 10–100× the in-datacenter number, which is why the whole path stays in one region.
This one-server design does not survive being pointed at a real fleet, and it fails in two places at once. It runs out of ingest rate: one server can pull and append only so many samples a second, and a fleet emits far more than one machine can take — Facebook's fleet is about eleven and a half million samples a second, and even a single large real instance appends over half a million. And it runs out of disk and memory: the capacity math above says the samples for a real fleet, at real resolution and retention, do not fit on one machine, and the head block for millions of series does not fit in one machine's RAM. So the write rate no longer fits and the data no longer fits, on any one server. That is what forces the scaled design, and it begins with the firehose.
part 2 The version that survives scale
This section walks the design by use case, in the order you actually have to decide things. First, spread ingest across many machines and pick the store's write path so it can swallow the firehose. Then face the wall the brief warned about: you cannot keep every raw sample forever, and the two independent levers that forces — bounding the label dimension, and rolling data up and tiering it by age. Then build the read path so the common recent query is fast and the rare enormous one is safe. And finally, watch a real company run straight into the cardinality wall in production, and see that the fix was exactly the lever this design already argued. The finished diagram waits at the end.
Two ways through this section
The four decisions below are the heart of this design, and there are two ways to take them. In the design room you make each one yourself and the board reacts to your pick. The write-ups after this point cover the same decisions in full — read them instead, or afterwards as reference.
use case 1 A fleet emits faster than one server can ingest
A single server can no longer keep up with the incoming samples. Thousands of machines each expose thousands of measurements, scraped constantly, and the combined stream overwhelms one box. Before reaching for anything clever, the exact shape of this data is what points to the right write path.
deep dive 1 A fleet emits faster than one server can ingest
The question
One server has run out of ingest rate. A fleet of machines is each exposing thousands of series, scraped every few seconds, and the total write rate is far past what one machine can pull in and append. The firehose is real now. But notice the shape of the problem before reaching for anything fancy: samples are small, they arrive roughly in time order, and once written they are never updated. That shape decides the write path, and it is the first real choice.
The obvious first answer is a general-purpose database — the kind most systems already run, a relational store on a B-tree. It is familiar, it is durable, and it can certainly hold rows of a series, a timestamp, and a value. The attraction is real: you know it, it queries flexibly, and you do not have to reach for something specialized. But watch what a firehose does to it. A B-tree keeps its data sorted in place, so every insert finds the right spot in the tree and writes it there, and under a relentless stream of inserts the tree is constantly being updated in place, splitting pages and rewriting them. Each tiny sample turns into a much larger amount of disk work — a small write amplifies into a big one. At a few thousand samples a second this is invisible; at hundreds of thousands a second per machine it is the bottleneck. A store that updates its data in place is fighting the workload, because the workload is almost pure append and the store keeps paying to stay sorted in place.
So the requirement sharpens: the write path has to be built for append, not for in-place update. That is exactly what a write-optimized store gives you, and it is why it wins over a general-purpose one. The general-purpose store was the familiar answer, but it amplifies every sample under a firehose; the write-optimized store buffers in memory behind a sequential log and flushes to immutable blocks, nothing ever updated in place, which is exactly what a relentless append-only firehose wants.
A write-optimized, append-friendly store, sharded across many machines. The write path has to be built for append, not for in-place update. That is exactly what a write-optimized store gives you, and it is the single-server design's head-block-and-log shape grown up. Incoming samples land in an in-memory head block, cheap because it is just memory. A write-ahead log on disk makes that memory durable — every sample is appended to a sequential log first, and a sequential append is the cheapest write a disk does, so there is no in-place update and no page splitting on the hot path. Every couple of hours the head block is flushed to an immutable on-disk block covering that time span, and a background process later merges small blocks into bigger ones. Reads and writes never fight over the same in-place structure, because written blocks are immutable and new data only ever appends. On disk each sample costs only one to two bytes, because consecutive samples in a series barely differ and are stored as tiny deltas. This is the write pattern behind Prometheus's own storage and behind purpose-built time-series stores generally — a named, well-understood primitive this design commits to without re-deriving its mechanics here.
Now size the ingest tier, and this is the both-sizings habit, because a metrics store has to be sized two different ways that give two different answers, and you take the larger. The throughput sizing is how many machines it takes to ingest the firehose: one real instance appends around half a million samples a second at the top end, and a fleet emitting roughly twelve million samples a second therefore needs on the order of tens of ingest instances just to keep up with the write rate — fleet samples a second over per-instance append rate, and it shards cleanly by splitting the series across more instances. The capacity sizing — how many machines it takes to hold the data — is a different calculation in different units, series times bytes per sample times retention over per-node RAM, and at real retention it is the far larger number and the wall the next use case is entirely about. So hold it for one more paragraph. The point here is that these are separate calculations, throughput is what you size the ingest tier by, and it spreads with the series.
Under the hood — how it works
Two facts about the Collector are worth pinning. First, there is a lever that shrinks the firehose before it is ever stored, and it is a big one: write-time aggregation. Many samples are only ever wanted in aggregate — a per-endpoint latency rolled up across all the hosts serving that endpoint, say — so aggregating them at ingest, before storage, collapses many series into one. Uber's M3 does exactly this and cuts its ingest from about five hundred million samples a second down to about twenty million persisted, a roughly twenty-five-fold reduction from aggregation alone, before any compression or rollup. It is a distinct lever from the ones the wall forces later, and it rides on the Collector, not as a separate box. Second, the Collector mostly pulls: it scrapes each target on an interval over HTTP, which has the nice property that a target that stops responding is visibly down, a health signal you get for free. The exception is a short-lived job that would finish and disappear before the next scrape — a batch job, a cron task — which instead pushes its samples to a gateway that holds them to be scraped.
The two sizings are separate calculations in separate units. Throughput sizes the ingest tier here — tens of instances at about half a million samples a second each for a roughly twelve-million-a-second fleet, sharded by splitting series across instances — while capacity is the next use case's wall, the larger of the two at real retention. The tier's failure story is soft by design: the write-ahead log makes the in-memory head block durable, so a crash replays the log on restart and loses nothing committed; and because the tier is sharded, one instance failing takes out only its slice of the series, not the whole fleet, and a dropped sample is a gap in one series, not a fault. The one thing this tier cannot fix by itself is the capacity wall — swallowing the firehose is one problem, and being able to afford to keep what it swallowed is the different, larger problem the next use case is about. And the one-line scope note from the brief lands here concretely: an alert is just a query that runs on a schedule over these same stored series, so it needs nothing new from the ingest path.
⚡ From production
The buffer-log-flush-merge write path is Prometheus's own storage design: an in-memory head block covering about two hours, a write-ahead log in fixed segments for crash recovery, immutable two-hour blocks flushed from the head, and background compaction merging them into bigger blocks. The one-to-two-bytes-per-sample figure is Prometheus's own capacity-planning rule. The fleet-scale numbers are Facebook's — seven hundred million samples a minute — Uber's M3 — about five hundred million a second aggregated down to about twenty million persisted, over six and a half billion series total — and Cloudflare's, whose biggest of over nine hundred instances appends about half a million samples a second.
The board after this deep dive — “Samples in from the fleet” traced, hop by hop. The numbered steps below walk the same path.
Samples in from the fleet
the Collector scrapes each app in the fleet on an interval, aggregating where it can before storage~1 ms
and appends the samples to the sharded, write-optimized store — a head block behind a WAL, flushed to immutable blocks — the series split across many instances so no one is the ingest ceiling~1 ms
use case 2 You cannot keep every raw sample forever
Sharding let the ingest tier absorb the flood. But absorbing it and affording to keep it are different problems, and the second is the one the brief promised would hurt. It is not about rate at all — it is about total cost, and this use case is where the whole board's signature difficulty lives.
deep dive 2 You cannot keep every raw sample forever
The question
The ingest tier swallows the firehose. Now comes the wall the brief warned about, and it is the heart of this whole board. The firehose was loud but tractable — you sharded it. Keeping what you swallowed is the problem that no amount of sharding solves, because it is not a rate problem, it is a total-cost problem, and the total cost is a number that grows in a way ordinary intuition badly underestimates. Operators want history — months of it for trends, years of it for capacity planning — but storing every raw sample at fleet resolution for years is a cost that grows with retention and, far worse, multiplies with the label dimension.
The aha is that this is not a compression problem you solve with a cleverer encoder, and it is not a disk-budget problem you solve by buying more disk. It is a multiplicative-cost problem. The storage cost is series count times bytes per sample times retention, and cardinality multiplies the first term: series count is the product of each label's distinct values, so one unbounded label with a thousand distinct values turns five million series into five billion, and every byte figure with it. Compression shrinks the bytes-per-sample term about twelvefold; rolling up and tiering by age shrinks the effective retention cost about forty-eightfold on the five-year-coarse-versus-one-year-raw comparison; but the only things that beat a multiplicative cost are refusing to multiply it and paying less per unit of aged time.
So the design needs two independent levers, because the wall grows on two axes. Cardinality control keeps the series count from exploding — refuse unbounded labels, cap active series per tenant — so the whole cost is not multiplied by a careless dimension. And the rollup pipeline coarsens data as it ages and tiers it from the fast store down to cheap object storage, expiring raw once a durable rollup exists, so the retention cost grows sub-linearly instead of as retention times full resolution. Neither lever alone clears the wall; together they turn a petabyte-a-year impossibility into an affordable design.
Bound the labels, and roll data up as it ages across tiered storage. Do the arithmetic out loud, because it is the argument. Take an illustrative instance of five million series at a fifteen-second resolution, and Prometheus's rule of about one-and-a-half bytes per sample. Build it up from the bytes: a sample every fifteen seconds is about two million samples per series per year, and at one-and-a-half bytes each that is roughly three megabytes per series per year, so across five million series one year of raw is roughly sixteen terabytes. Now compare it to keeping the same five million series but rolled up to a coarse one-hour resolution and kept for five years — the exact policy Uber's M3 documents. Five years at one sample an hour is about three hundred and thirty gigabytes for those series, roughly forty-eight times cheaper than a single year of raw, for five times the time span. The coarse tier is not a rounding error against the raw tier; it is a different order of magnitude. That comparison alone — before cardinality even enters — is why nobody keeps years of data at raw resolution.
And then cardinality enters, and it does not add, it multiplies. Series count is the product of each label's distinct values. Attach one unbounded label with a thousand distinct values — a user ID is the classic offender — and five million series becomes five billion. Every number above is multiplied by a thousand. The one-year-of-raw baseline goes from sixteen terabytes to about sixteen petabytes for a single year. This is the wall. And the tempting escape hatches do not clear it. Compression is real and valuable — encoding each sample as a tiny delta is exactly why a sample costs one to two bytes on disk instead of sixteen, roughly a twelvefold shrink — but that shrink is already baked into the numbers above, so there is no second twelvefold to take: the wall stays at petabytes a year. Buying more disk does not scale against a cost that multiplies with every careless label. So the wall forces two independent levers, on two different axes, and a real design needs both: cardinality control to bound the label dimension so the series count cannot explode, and the rollup pipeline over tiered storage to coarsen and age data so the retention cost grows sub-linearly. In practice cardinality control means refusing to put unbounded things — user IDs, email addresses, request IDs — into labels, and, where many tenants share a store, capping the active series any one tenant can create so a single misconfigured service cannot multiply the whole cluster's series count.
The second lever attacks the retention axis: as blocks age, roll their samples up to coarser resolution. Raw stays raw for a couple of days, then gets rolled up to one sample every five minutes, then to one sample an hour for the long tail — Thanos does this on a deliberate delay, waiting until a block is older than forty hours for the first rollup and older than ten days for the second, because recent data is exactly what operators query at full detail. And you tier the data as it ages: recent, high-resolution blocks live in the fast write-optimized store, and aged, rolled-up blocks move to cheap object storage. Once a durable rolled-up copy of a span exists in cheap storage, the original raw blocks for that span can be expired, because the coarse copy is enough for the long-range queries that reach that far back. Here is the myth this pipeline is most often mistaken for, and it matters because getting it wrong hides the real mechanism. Rolling data up does not, by itself, save storage. Thanos's own documentation is blunt: keeping raw and five-minute and one-hour resolutions all at once actually costs about three times more than raw alone, because each downsampled resolution is extra blocks stored alongside the raw, not instead of it. The purpose of the rollup is not to shrink disk — it is to make a query over months or years fast, by letting that query read a few coarse samples instead of scanning millions of raw ones. The storage saving is a separate decision: expiring the raw tier after its short retention window, which is only safe because the durable rollup already exists.
Under the hood — how it works
The delay on rollup is deliberate and worth understanding. Recent data is queried at full resolution constantly — an operator debugging right now wants every fifteen-second sample — so raw is left untouched while it is hot, and only coarsened once it has aged past the window where anyone wants that detail. That is why Thanos waits forty hours before the first rollup rather than doing it immediately. And the tiering is not just about cheap-versus-expensive disk; it is about matching storage speed to query frequency. The hot tier is in the fast write-optimized store because the overwhelming majority of queries hit it, and paying for fast storage there is worth it. The cold tier is object storage because the queries that reach years back are rare, so paying an object-storage round-trip on those rare queries is a good trade for storage that costs a fraction as much. Recent and frequently-queried gets fast and expensive; old and rarely-queried gets slow and cheap.
The Rollup worker runs off the hot path entirely — it processes aged blocks in the background, not live samples, so its throughput is measured in blocks per hour, not samples per second, and it never competes with ingest or queries. Its storage cost is the myth-checked one: it adds blocks — about threefold if all three resolutions are kept at once — and the net saving comes from the separate expiry of raw. Its failure story is gentle: if it lags or dies, no data is lost — the raw blocks persist until their retention expires, so a long-range query just falls back to slower raw scans or waits for the rollup to catch up; and because it processes immutable blocks, re-running it is idempotent. The Cold store is cheap object storage holding the rolled-up, long-retention, immutable blocks — its capacity sizing is the long-tail math, about three hundred and thirty gigabytes for five million series at one-hour resolution over five years, a fraction of the raw cost; its durability is object storage's own concern, fenced here the way the durable store behind a cache is. A cold read is slower — an object-storage fetch — but correct, and the hot tier is unaffected by a cold-store hiccup because live ingest and recent queries never touch it. If you want to cheaply measure how many distinct series a label is about to create before you accept it, a small probabilistic sketch for counting distinct things — a HyperLogLog is one — does it, but that is a measurement tool, not the fix; the fix is bounding the label dimension.
⚡ From production
M3 publishes the exact tiered-retention policy this dive argues: ten-second resolution kept for two days, one-minute for thirty days, and one-hour for five years, tying resolution directly to age. Thanos documents the deferred downsample schedule — five-minute after forty hours, one-hour after ten days — and states plainly that downsampling does not save space (it adds about threefold if all resolutions are kept) and exists to make long-range queries fast. Prometheus's own naming documentation is the source of the cardinality warning against labelling on unbounded sets like user IDs, and InfluxData's is the source of the multiplicative cardinality formula.
The board after this deep dive — “Samples in from the fleet” traced, hop by hop. The numbered steps below walk the same path.
Samples in from the fleet
the Collector scrapes each app in the fleet on an interval, aggregating where it can before storage~1 ms
the Collector appends samples to the sharded, write-optimized store — the hot, recent tier at raw resolution~1 ms
in the background the Rollup worker reads aged blocks from the store~1 ms
rolls their samples up to coarser resolution, and writes the rolled-up blocks to the cheap Cold store, after which the raw blocks for that span are expired~1 ms
use case 3 An operator asks for a summary over a long range, fast
With the write side finished, everything built so far pointed at a single moment: an operator opening a dashboard and wanting an answer immediately. Serving that read well turns on two stubborn realities of how these systems actually get queried, and the two of them together shape the whole read path.
deep dive 3 An operator asks for a summary over a long range, fast
The question
The write side is built: the firehose is sharded into the write-optimized store, cardinality is bounded, and aged data is rolled up and tiered to cheap storage. Now the operator opens a dashboard. All that storing was in service of this moment, and the read path has its own shape, forced by two facts about how metrics are actually queried. The first is recency skew, and it is dramatic: the overwhelming majority of queries are about the recent past. The second is about the rare query that reaches back months or years, and it is a safety fact rather than a speed one — a single query that pulls too many series or too long a range can exhaust one machine and fall over.
The naive read path treats every time range the same: take the query's range, scan the samples in it, aggregate, return. It is simple and uniform, and for a recent query it is even fine. But it is optimized for the wrong thing, because queries are not uniform in range — eighty-five percent of them read only the last twenty-six hours, and the rare one that reaches back years scans a vastly larger span. Treating all ranges equally means either serving the common recent query off disk when it could be in memory, or letting the rare long-range query scan millions of raw points and exhaust a machine. The read path is not one uniform scan; it is shaped by the skew.
So the requirement sharpens on two axes. Serve the common recent query from an in-memory hot tier, because that is where the queries actually are and a hot tier wins about seventyfold on latency for that mix. Run the rare long-range query over the coarse rolled-up tier, not over raw, because a few thousand coarse points is both fast and safe where millions of raw points is neither. And bound every query — a per-query memory ceiling, lazy decompression, cancellation on client disconnect — so no single query, however careless, takes a node down. Recent from memory, long from rollups, and never let one query exhaust a machine.
A hot tier for the common recent query, rollups for the rare long range, and a defensive query engine. The first fact is recency skew, and it is dramatic. The overwhelming majority of queries are about the recent past — an operator watching a deploy, debugging an incident happening now, checking the last few hours. Facebook measured this precisely: eighty-five percent of all queries read only the last twenty-six hours of data. That is not a mild lean; it means the read path is optimized for the wrong thing if it treats all time ranges equally. Because recent data is queried so much more than old data, keeping the recent window in an in-memory hot tier — rather than reading it off disk every time — pays for itself many times over. Facebook's in-memory tier in front of its on-disk store measured about a seventyfold query-latency improvement for exactly this reason. So the read path's first move is to serve the common recent query from the hot Time-series store, in memory, at about a millisecond.
The second fact is about the rare query, the one that reaches back months or years, and it is a safety fact. A long-range query cannot run over raw samples, for two reasons that point the same way. It would be slow, because months of raw samples is millions of points per series to scan. And it would be dangerous, because pulling that many points into memory to aggregate them can exhaust a single machine. This is not hypothetical: Uber's query engine hit a hard per-query memory ceiling of three and a half gigabytes from queries spanning very large numbers of series, and when the engine degraded under that pressure, operators kept refreshing their dashboards, piling new queries on top of ones that had never been cancelled, compounding the overload until the engine was redesigned around it. So the long-range query runs over the rollups, not the raw — it reads the coarse one-hour tier from the cold store, a few thousand points instead of millions, both fast enough and small enough to be safe. And the engine itself has to be defensive: cap the memory a single query may use, decompress data lazily rather than all at once, and cancel a query the instant the client that asked for it disconnects.
Under the hood — how it works
A single query can straddle the tiers. Error rate over the last two days reads mostly the hot tier and maybe one aged block; over the last two years reads almost entirely the cold rollups. The query engine's job is to figure out, from the time range and the resolution available, which tier holds the answer and to read the coarsest resolution that still answers the question — full raw for a recent debugging query, one-hour rollups for a multi-year trend. One kind of query deserves a note because it is subtle: a fleet-wide tail percentile cannot be computed by averaging per-machine percentiles, and shipping every raw sample to one place to compute it exactly would blow the very memory ceiling this dive is about. The practical answer is a small mergeable sketch of the distribution — a t-digest is one such structure — that each source maintains and that merge together cheaply to estimate the tail. The internals of that sketch are a topic of their own and are fenced here; the point for the read path is only that fleet-wide percentiles are estimated from mergeable sketches, not computed by hauling every raw sample into one query.
The Query engine is sized by the query rate and the cost per query, not by the sample rate — Uber's serves about twenty-five hundred queries a second and about eight and a half billion points a second in aggregate, a very different unit from the ingest firehose's samples per second, and the two must never be conflated. Its capacity concern is per-query, not total: the ceiling is how much memory one query may consume, because the danger is one enormous query, not the aggregate. Its failure story is the one Uber lived: without a ceiling and without cancellation, a few pathological queries plus a pile of abandoned dashboard refreshes can exhaust the engine's machines and cascade; the fix is exactly the defensive design above — cap, decompress lazily, cancel on disconnect. A query that reaches the cold tier is slower (an object-storage fetch) but correct, and a cold-tier hiccup slows only the rare long-range query, never the common recent one, because the hot tier answers that from memory.
⚡ From production
Facebook's Gorilla paper reports that eighty-five percent of queries read only the last twenty-six hours and that an in-memory tier in front of the on-disk store cut query latency by more than seventyfold for the production query mix. Uber's query-engine post reports the hard three-and-a-half-gigabyte per-query memory ceiling, the roughly twenty-five hundred queries a second and eight and a half billion points a second aggregate throughput, and the redesign around lazy decompression, pooled buffers, and cancel-on-client-disconnect after abandoned dashboard refreshes compounded an overload.
The board after this deep dive — “A range query out” traced, hop by hop. The numbered steps below walk the same path.
A range query out
the operator asks the Query engine for a time-range summary~1 ms
for the common recent query — 85% of them — the engine reads the hot, recent tier from the store in memory, in about a millisecond~1 ms
for a rare long range the engine reads the coarse rolled-up tier from the Cold store instead of raw — a few thousand coarse points, fast and safe — capping and cancelling the query so it cannot take a node downtens of ms
use case 4 When a runaway series count took the cluster down
On the happy path, the design holds together completely. This last use case begins with it already down — and not from some peripheral fault. The thing that fails is the board's own central wall, striking a live service for real, on the cardinality side, before the retention cost ever comes due. The rescue they chose is precisely the lever this design already placed on the Collector.
deep dive 4 When a runaway series count took the cluster down
The question
Step back and look at what is built. The ingest tier shards the firehose into a write-optimized store, cardinality is bounded at ingest, the rollup pipeline coarsens and tiers aged data to cheap storage, and the read path serves recent queries from memory and long ranges from rollups without letting one query take a node down. It is complete for the happy path. Now the premise arrives already broken, the way a real incident does — and the twist is that this is not an off-to-the-side failure that happens to touch the store. This is the wall this whole board was built around, hitting a real service in production, on the cardinality axis, before the retention math even gets a chance to bite. On the eleventh of March, 2021, Grafana Cloud's hosted Prometheus service had a roughly two-hour outage. It was not a flood of queries and not a disk filling up. It was a runaway series count.
The instinct, in the room and in the moment, is to read an out-of-memory cascade as a capacity shortfall and reach for the obvious lever — the cluster ran out of memory, so add more memory, raise the limit, give it room. And it is exactly the wrong move, for exactly the reason the signature dive established. The problem was never aggregate capacity. The problem was that one tenant's series count was unbounded, and an unbounded series count multiplies until it exhausts whatever memory you give it. Raising the limit is what let the tenant through in the first place — the forecast-based limit had been raised for their expected usage, and then the enforcement bug let them blow past even that. More memory would have bought a little time and then filled up too, because a multiplicative explosion outruns a linear addition of capacity.
The incident's deeper lesson is that cardinality control is only as good as its enforcement, and enforcement is easy to get subtly wrong. The limit existed; the bug was in applying it, so a tenant that should have been throttled at the edge was let through. A cardinality limit that is not actually enforced on the hot path is not a limit at all — it is a comment. Which is why the fix was not just lower the number but layers of ingestion-side protection, so that no single point of enforcement failing lets a runaway series count reach the store. The mechanism of bounding cardinality is simple to state; enforcing it reliably at ingest, as the workload shifts and new tenants arrive, is the ongoing engineering, and it is precisely where this incident went wrong.
Bound the cardinality — stricter per-tenant limits, correctly enforced, plus layered ingestion-side protection. A newly onboarded tenant started sending far more active series than its forecast-based limit had planned for — exactly the cardinality explosion this board's signature is about, a tenant's label dimension multiplying its series count past what anyone expected. That alone should have been caught by the per-tenant limit. But there was a bug in how the limit was enforced, so the runaway series growth was not stopped at the edge — it was allowed through, and it overwhelmed the cluster. The load drove a cascade of out-of-memory failures across the stack: the ingestion path ran out of memory under the series flood, and the failures spread outward until the internal auth gateways were failing their health checks and load balancers were pulling them from the pool. A single tenant's series count, unbounded by the broken limit, took the service down for about two hours.
The mitigation that actually resolved the incident was cardinality control, done right: stricter per-tenant active-series limits, enforced correctly this time, plus several added layers of ingestion-side protection so a runaway series count is stopped at the edge before it can overwhelm the store. That is the same lever this design already put on the Collector in the signature dive — cap the active series a tenant can create, and refuse the labels that explode the count. The reframe to carry out of this incident is the one the whole board is built on. Storage cost, and memory pressure, are multiplicative in the label dimension, and a multiplicative problem is not solved by adding a linear amount of hardware. You solve it by refusing to multiply — bounding cardinality at the source. Grafana learned it the expensive way, over two hours of outage; this design learns it as the signature. There is a read-path echo worth naming, because it shows the wall hits queries too: Uber's query engine hit its per-query memory ceiling precisely from queries that spanned enormous numbers of series — high cardinality on the read side — and the fix there was also to bound the blast radius, cap the query's memory and cancel it on disconnect, rather than give every query more memory. Ingest or query, the wall is the same shape: an unbounded count of series is a memory bomb, and the defense is bounding it, not feeding it.
Under the hood — how it works
The incident is Grafana Labs', reconstructed from their own postmortem: a roughly two-hour outage on the eleventh of March, 2021, when a newly onboarded tenant's active-series growth blew a forecast-based limit, a bug in limit enforcement let that growth overwhelm the cluster, and a cascade of out-of-memory failures — the ingestion path, then internal auth gateways failing health checks and being pulled from the load-balancer pool — took the service down. The fix was stricter, correctly enforced per-tenant active-series limits plus added layers of ingestion-side protection. The postmortem gives the date, the roughly two-hour duration, and the qualitative cascade; it does not publish exact series counts or memory numbers, so the fleet figures elsewhere on this board carry the arithmetic and this incident carries the dated-production-outage anchor.
The reframe carries past this board. Storage cost and memory pressure are multiplicative in the label dimension, and a multiplicative problem is not solved by adding a linear amount of hardware — you solve it by refusing to multiply, bounding cardinality at the source. And the read-path echo shows it is not only an ingest problem: a query that spans an enormous number of series is high cardinality on the read side, and the defense there was also to bound the blast radius — a per-query memory ceiling and cancel-on-disconnect — not to give every query more memory. Ingest or query, an unbounded count of series is a memory bomb, and the defense is bounding it, not feeding it.
⚡ From production
Grafana Cloud's hosted Prometheus service, on the eleventh of March, 2021, had a roughly two-hour outage when a newly onboarded tenant's active-series growth blew a forecast-based limit, a bug in limit enforcement let that growth overwhelm the cluster, and a cascade of out-of-memory failures — the ingestion path, then internal auth gateways failing health checks and being pulled from the load-balancer pool — took the service down. The fix was stricter, correctly enforced per-tenant active-series limits plus added layers of ingestion-side protection.
The board after this deep dive — “Samples in from the fleet” traced, hop by hop. The numbered steps below walk the same path.
Samples in from the fleet
the Collector scrapes each app in the fleet on an interval, aggregating where it can before storage~1 ms
the Collector appends samples to the sharded, write-optimized store — the hot, recent tier at raw resolution~1 ms
in the background the Rollup worker reads aged blocks from the store~1 ms
rolls their samples up to coarser resolution, and writes the rolled-up blocks to the cheap Cold store, after which the raw blocks for that span are expired~1 ms
Where this design sits on consistency (and CAP)
This design makes opposite durability choices for its two kinds of state, on purpose, because they are worth completely different things. The ingest and query path — the Collector, the write-optimized store's hot tier, and the aggregates a reader pulls — chooses availability and throughput over strong consistency. A dropped sample is a gap in one series, not a fault; a scrape that misses degrades to a small hole in the data, not a stalled system; and a range query serves the aggregate it can compute from the tiers it can reach, possibly a moment behind the very latest sample. None of this coordinates to be instantaneously complete, because a metrics system is a possibly-stale summary of numbers over time, not a ledger.
The Cold store makes the other choice. It is the durable, long-retention tier — the authoritative copy of the rolled-up history that long-range queries read directly, and the thing that makes it safe to expire the raw tier under it. So the Cold store is the durability-leaning, replicated, backed-up dataset, and losing a rolled-up block there is a real, unrecoverable harm, not one the next scrape repairs. That is exactly why the hot tier can afford to be fast and rebuildable: a store instance that dies loses only its slice of recent series, which refills from the ongoing scrape, while the years of history live safely in cheap object storage. Durability is bought where it matters — the long-retention tier — and declined where it does not — the recent, constantly-refilled hot path.
There is a name for the underlying choice. When a network fault splits a system in two — that split is the P, for partition — each operation has exactly two honest options: answer anyway from what this half knows and risk being incomplete, which is the A, for availability, or refuse until the halves agree, which is the C, for consistency. A partition is not something you choose; it is weather. The useful way to apply the CAP theorem is per operation, by asking what a wrong answer would cost. Here a dropped sample or a slightly-behind aggregate costs almost nothing while a lost rolled-up block of history costs everything, so the design takes availability and throughput for the ingest and query path and pays for durability only on the Cold store. Grafana's cardinality outage is the same lesson in the field: keep the system available by bounding what one tenant can push at the ingest edge, rather than letting an unbounded write take the whole store down.
That is the whole machine, a write path and a read path meeting at two tiers of storage, and every box on it was placed by one of the deep dives above. On the write path the Collector scrapes each app in the fleet on an interval, aggregates where it can, refuses unbounded labels and caps active series per tenant, and appends the samples to a sharded, write-optimized store buffering recent samples in an in-memory head block behind a write-ahead log. As those blocks age, the Rollup worker reads them, rolls their samples up to coarser resolution, and writes the rolled-up blocks to the Cold store, after which the raw blocks are expired. On the read path an operator's range query goes to the Query engine, which reads the hot, recent tier from the store for the common recent query and the cold, rolled-up tier from the Cold store for the rare long-range one, bounding every query so none can take a node down. If you carry away one sentence, make it the one the whole design turned on: a cost that multiplies is a different kind of problem from a cost that adds, and you cannot buy your way out of a multiplicative cost with a linear amount of hardware — so ask what happens when one factor in that product goes unbounded before you design.
The finished design — every box placed by one of the deep dives above.
What's on the diagram
app / service
app / service. The monitored machine on the write side — every process in the fleet exposes its current numbers (a request count, a queue depth, a latency reading) for the Collector to scrape on an interval. It reports and waits for nothing; a scrape it misses is a small gap in one series, not a fault, which is exactly why the write path can be cheap and best-effort.
operator
operator. The reader on the far side — a human at a dashboard, or an alert rule running on a schedule — who asks the Query engine for a summary over a time range: a rate, an average, a percentile. Almost every question they ask is about the recent past, which is the whole reason the read path pays for an in-memory hot tier.
Collector · Prometheus
Collector · Prometheus. The write-side front door: it scrapes each app in the fleet on an interval (pull by default, a push gateway for short-lived jobs), aggregates where it can before storage, and enforces cardinality control — refusing unbounded labels and capping active series per tenant — before appending to the store. It holds only scrape config, the in-flight batch, and the active-series counters the limits need, and is sized by the fleet's samples a second; a runaway series count is stopped here or it OOM-cascades the store.
Time-series store · Prometheus TSDB / M3DB
Time-series store · Prometheus TSDB / M3DB. The hot tier and the ingest sink — a sharded, write-optimized store that buffers recent samples in an in-memory head block behind a write-ahead log and flushes them to immutable, time-partitioned blocks at one to two bytes a sample. It is sharded by splitting series across instances (carried in badges, never a split-cylinder — it is not a partitioned database), and sized by the larger of throughput (fleet samples a second ÷ a per-instance append ceiling) and the capacity of its hot window; a crash replays the WAL, and a lost instance loses only its slice of recent series.
Rollup worker · Thanos compactor
Rollup worker · Thanos compactor. The signature's engine on the age axis: a background process that reads aged blocks from the store, rolls their samples up to coarser resolution on a deferred schedule (five-minute after forty hours, one-hour after ten days), and writes the rolled-up blocks to the Cold store, after which the raw blocks are expired. Its throughput is blocks an hour, not samples a second; it adds storage rather than saving it (the saving is the separate raw expiry), and a lagging worker loses no data because raw persists until its retention runs out.
Cold store · object storage
Cold store · object storage. The durable, cheap, long-retention tier — object storage holding the rolled-up, immutable blocks (M3's one-hour-for-five-years policy), the authoritative copy of history that long-range queries read directly and the reason it is safe to expire raw under it. It is sized by the long-tail math (about three hundred and thirty gigabytes for five million series at one-hour resolution over five years, a fraction of the raw cost); a cold read is slower — an object-storage fetch — but correct, and a cold-store hiccup touches only the rare long-range query.
Query engine · PromQL / M3 query
Query engine · PromQL / M3 query. The read-side hub: it answers range and aggregation queries, reading the hot, recent tier from the store for the common recent query (the overwhelming majority, the last day or so) and the cold, rolled-up tier from the Cold store for long ranges, never scanning raw for a multi-year span. It is sized by the query rate and cost per query, never by the sample rate, and every query is capped, decompressed lazily, and cancelled the instant its client disconnects, so no single query — however careless — takes a node down.
What this design never covered — raise it yourself
How each sample is compressed on disk
A strong follow-up is how you get to one or two bytes per sample. The honest answer names it — encoding each sample as a small delta from the previous one rather than a full timestamp and value — as a named primitive that buys about a twelvefold shrink, while making the point that even that does not clear a cardinality wall that multiplies by a thousand. The bit-level encoding is its own topic.
How a fleet-wide percentile is computed
A sharp interviewer may push on a tail percentile across every machine, since you cannot average per-machine percentiles and you cannot afford to ship every raw sample to one place. Name the answer as a small mergeable sketch of the distribution (a t-digest is one) maintained per source and merged cheaply, and fence the sketch's internals as their own topic.
How alerting fits
A natural where do alerts live invites the boundary. Name it as a standing query that runs on a schedule over the same stored series a dashboard reads — so it rides on top of the store and the query path and needs nothing new from the storage design; the rule language and routing are a separate system.
Cheaply measuring cardinality before you accept a label
A follow-up on how you'd know a label is about to explode points at a distinct-count problem. Name it as a small probabilistic sketch for counting distinct values (a HyperLogLog is one) used as a measurement that tells you when you are about to breach a limit — a tool, not the fix, which is still bounding the label.
A bespoke rollup-and-tiering visualizer
The deepest how does it actually work is watching aged blocks coarsen and move down the tiers, and the storage cost fall as raw expires under a durable rollup. Name it as the mechanism the signature dive teaches by arithmetic, and note that a runnable model of it would make the rollup-doesn't-save-space, expiring-raw-does nuance concrete.
Go deeper — the primitives this design leaned on
The write-optimized store (the LSM write pattern) ↗ — buffer writes in memory behind a log, flush to immutable blocks, merge in the background; the write path this design commits to for the firehose without re-deriving it