Analytics · Guide ·

How to show analytics for a high-throughput system

Your events live in a store built for aggregation, not for page loads. The dashboard has to be instant and correct at the same time, and those pull in opposite directions. This is how to have both — and the read path that took ours from 20 seconds to 200 milliseconds while doubling what fits on a page.

[ 01 — The tension ]

The two forces

Every event your system produces gets logged — sent, delivered, read, clicked, converted. That log is the truth, and it lives somewhere built for aggregating across millions of rows: a search cluster, a columnar warehouse, whatever you picked.

Then someone opens a dashboard and wants fifty rows of computed metrics, paginated, sorted, with rates and revenue attribution. Two requirements, in direct opposition:

requirement
instant
< 300ms
×
pick one?
requirement
fresh
seconds old
or don't
what works
both
saved → refresh
You don't have to choose, but you do have to sequence. Serve something immediately, then correct it. The trick is that the correction is almost always a no-op, and knowing when it isn't.

Aggregate live on every request and the page takes as long as your heaviest query — for us, twenty seconds on a busy account, which is long enough to alt-tab and forget why you opened it. Serve a precomputed copy and the numbers for something still actively running are minutes behind reality, which for a campaign mid-send is worse than slow.

[ 02 — Storage ]

Stop aggregating on read

The first move is to stop computing metrics in the request path at all. Keep the event store as the source of truth, and keep a materialised copy of the computed numbers in your transactional database. Pages read the copy. Nothing else.

One decision worth making deliberately: put the numbers on the entity row, not in a separate analytics table. A campaign row carries its own sent, delivered, read, revenue and the timestamp of when those were last computed. No join, no second table to keep in step, no window where the entity exists and its metrics don't.

Why on the row

The alternative — a metrics table keyed by entity id — sounds cleaner and costs you a join on every list query, plus a whole class of bug where the two tables disagree about what exists. Metrics are an attribute of the campaign, not a separate concept. Store them like one.

You now have a fast read and a new problem: the copy goes stale. Everything below is about refreshing it as rarely as you can get away with.

[ 03 — The pattern ]

Two calls, one endpoint

The frontend fires the same request twice, differing in one flag. Not two endpoints — one endpoint with two modes, so there is no chance of the shapes drifting apart.

The two-call sequence The page requests the saved copy and paints immediately. It then repeats the request in refresh mode; the service reads the saved copy, checks each row's freshness, recomputes only the stale rows from the event store, writes them back, and returns fresh numbers, which silently replace what is on screen. Page Service Saved copy Event store CALL 1 — fast path cached: true read rows response painted · ~200ms CALL 2 — refresh cached: false read check freshness, per row ONLY IF STALE recompute stale rows fresh metrics → write back fresh response

Two requests, one endpoint, one flag. The dashed block is the only part that touches the event store, and on most page loads it doesn't run at all — everything on screen is older than the freeze threshold.

Two details that matter more than they look:

[ 04 — Freshness ]

Freshness should scale with age

A single TTL is the obvious implementation and it's wrong in both directions at once. Set it tight and you recompute year-old campaigns that cannot possibly have changed. Set it loose and a campaign that is mid-send shows numbers from ten minutes ago.

The fix: make the tolerance a function of how old the thing is. Recent data is volatile and worth recomputing. Old data is effectively immutable and recomputing it is pure waste.

Age of the entityRefresh if older thanWhy
< 7 days5 minstill moving — reads, clicks, orders arriving
7 – 14 days15 minslowing down
14 – 30 days30 minoccasional late attribution
> 30 daysneverfrozen — nothing will change again
The last row does the heavy lifting. Most rows on any given page are older than a month, so most of the time call 2 does no work at all. That is what makes the pattern affordable.

Two guards worth adding while you're in there:

[ 05 — Tiering ]

Two tiers of recompute

Here is the part that most affects your event-store bill. A list page and a detail page need very different amounts of data, and if you compute one payload for both you are computing the expensive one fifty times per page load.

So define two tiers, and make one a strict superset of the other:

Tier A — the listTier B — the detail
Computes headline counts, revenue, rates everything in A plus engagement detail, failure reasons, funnel steps, opt-outs
Query cost light heavy
Rows per request up to a page exactly one
Stamps written A A and B
The superset relationship is the whole trick. Because B computes everything A does, a detail refresh can honestly stamp both — so opening a campaign also freshens its row in the list. It never works the other way.

That asymmetry is why you need two freshness timestamps, not one. The list checks the A stamp; the detail page checks the B stamp. Collapse them into one and you get a detail page that believes it's fresh because a light list refresh touched the row — showing stale funnel numbers next to correct headline numbers, which is the kind of bug that erodes trust in the whole dashboard.

Naming matters here

Call them what they are — headlineComputedAt and detailComputedAt beats updatedAt1 and updatedAt2. Someone will have to reason about which screen a given stamp gates, at speed, during an incident.

[ 06 — Aggregation ]

Aggregating families at read time

Most systems like this have parent/child structure. A campaign gets retried, so the original send has three follow-up attempts, each its own row with its own numbers. The list wants one line per campaign; the detail page wants a choice.

Resist the urge to store a pre-combined total. Aggregate on read. A stored rollup is a third copy that can disagree with the two you already have, and it needs invalidating every time any child changes. Summing four rows at read time is free by comparison.

Three rules, and the second and third are where the bugs live:

1
Counts sum.
Delivered, clicked, orders, revenue. Straightforward addition across the family.
2
Rates never sum.
Recompute from the summed totals. Adding a 40% read rate to a 60% read rate gives you 100%, which is not a thing. Averaging them is also wrong unless the denominators happen to match.
3
Cardinality doesn't sum either.
The same person appears in the original send and in two retries. Add the per-row "people reached" and you've counted them three times. This needs a de-duplicated count computed once across the whole family and stored on the parent.
Rule 3 is the one that ships to production. It looks like a sum, it type-checks like a sum, and it silently overstates reach by however many retries you ran.
Family-aware staleness

If a parent's displayed total includes its children, then the parent is stale when the parent or any child is stale. Check freshness across the family, not just the row you're rendering — otherwise the combined number sits wrong until something else happens to refresh a child, and you get bug reports about totals that "fix themselves eventually".

Then expose the choice rather than deciding for the user. The list collapses to one row per family with combined numbers and expands to show each attempt separately. The detail page offers combined, original, or a specific attempt. Same aggregation function behind all of it — only the selection differs.

[ 07 — Caching ]

A cache layer, if the dashboard is hot

Everything so far removes the event store from the hot path. If a dashboard is opened often enough — a team all watching the same account, or a page that polls — the transactional read becomes the hot path instead, and that is when a cache in front earns its place.

Add it after the rest, not instead of it. A cache over a slow aggregation just serves stale slow data; a cache over a materialised read is genuinely just speed.

1
Cache the response, not the rows
Key on everything that changes the output: tenant, filters, sort, page, date range. A row-level cache leaves you reassembling and re-sorting on every hit, which is most of the cost you were trying to avoid.
2
TTL shorter than your shortest refresh window
Cache TTL and materialisation TTL stack. A 5-minute cache over a 5-minute refresh window means numbers up to ten minutes old. Keep the cache well under — a couple of minutes at most.
3
Only the fast call may be cached
Call 1 caches. Call 2 must bypass, or the refresh path returns a cached response and the screen never corrects itself — which quietly disables the entire two-call design while looking like it works.
4
Invalidate on write-back
When call 2 writes fresh numbers, drop the keys that cover that entity. Hard-refresh bypasses the cache entirely.
Step 3 is the one to get right. It is very easy to add caching at the controller, catch both calls, and ship a dashboard that is fast and permanently a few minutes behind.
The same sequence with a cache in front The fast call is served from cache on a hit. The refresh call always bypasses the cache, recomputes stale rows, writes them back and drops the affected cache keys, so the next fast call is both instant and correct. Page Cache Service Saved copy + events CALL 1 — may never reach the service cached: true HIT → painted on miss only CALL 2 — must bypass the cache cached: false straight past the cache recompute stale → write back drop affected keys fresh response next visitor gets a warm hit

The crossed circle is the whole point. If the refresh call is allowed to hit the cache, it returns a cached answer, nothing is ever recomputed, and you ship a dashboard that is fast and permanently behind.

A time-series chart across the same data is worth caching per segment rather than per request — each bucket of the range under its own key, so changing the window reuses most of the work instead of recomputing the whole series.

[ 08 — From production ]

What we hit

The design above is what we ended with. Here is what it replaced and what went wrong on the way.

A 20-second page

The original read path aggregated from the event store on every load, for every row on the page. On a large account that was 20 seconds — long enough that people opened it, switched tabs, and came back having forgotten why. Moving to a materialised copy with the two-call pattern took it to 200ms, and we doubled the page size at the same time because per-row cost had stopped mattering.

Summed rates

The first aggregation across a retry family added the rates. Read rate came out over 100% for any campaign with two retries, which at least fails loudly. Click-through came out plausible-but-wrong, which doesn't. Rates are now always recomputed from summed counts, and never stored pre-combined.

The same customer, counted three times

Reach was summed across the family. Since a retry targets people who didn't engage the first time, the overlap is nearly total — so a campaign with three retries reported roughly three times the audience it actually touched. The fix was a de-duplicated count computed once across the family, stored on the parent, and never derived by addition again.

Totals that fixed themselves later

Freshness was checked on the row being rendered. A parent whose displayed cost included its children would show a stale total whenever a child was stale — then silently correct minutes later when something else refreshed that child. Support tickets described it as numbers that "settle down after a while", which is a horrible thing for a billing figure to do. Staleness is now evaluated across the whole family.

The pattern across all four: the slow version was obvious and the wrong version was quiet. A 20-second page gets fixed because everyone complains. A reach number inflated 3× by a summed cardinality gets quoted in a board deck.

[ 09 — Reference ]

The checklist

A dashboard is a cache with opinions. The only question is how many layers of it you're willing to admit to.