A live screen is mostly not live

· 8 min · caching · data platform · redis · fabric

The screens that are hardest to build are not the complicated ones. They are the ones that have to be live.

A live operations screen almost never has a single source. It has two.

There is an event stream. In my case RFID scans from the shop floor, landing in Dataverse as they happen. And there is reference data describing what those events mean, which is owned by the ERP, Business Central, and reaches the application through a Microsoft Fabric warehouse. A scan tells you that piece 4417 moved. It does not tell you what 4417 is, whose order it belongs to, when that order is due, or which station it goes to next. Those live somewhere else entirely.

That last one matters more than it looks. The event says where a piece is. Only the routing says where it should go, and the routing is in the ERP. So the screen an operator actually needs is not a feed of scans at all. It is a scan joined to a routing, and the join is the product.

So a render is a join across two systems, and that is where the trouble starts.

Two clocks, one poll

The event side changes constantly. That is the whole point of it, and it is why the screen polls every ten seconds: an operational display that is thirty seconds stale gets checked against the physical rack once, disagrees once, and is never trusted again.

The reference side does not change constantly. An item description, a customer, an order quantity, a due date, and the routing that says which operations happen in which order. These change when somebody edits them, which is to say rarely. A routing is set when the order is released and then holds for the life of it. Most of this will not change today. Much of it will not change this year.

Built the obvious way, both are fetched on every poll, because both appear in the same render. And that means:

A composed view refreshes at the rate of its fastest-moving source. Everything slower in it is over-fetched by exactly the ratio between the two change rates.

That ratio is not a rounding error. Polling every ten seconds across a ten-hour day is 3,600 renders per screen. If the reference data behind a row changes once a day, you fetched it 3,600 times to observe one change. At fifty screens, that is 180,000 fetches a day of data that changed once.

One render, two sources event stream changes every few seconds fetched every 10 s matched reference data changes about once a day fetched every 10 s 3,600 × The expensive source is the one that did not change. It is the warehouse round trip, it is metered capacity, and it is being asked at the cadence of the other source. 50 screens × 3,600 renders = 180,000 fetches a day of data that changed once. One render, two sources event stream changes every few seconds fetched every 10 s matched reference data changes about once a day fetched every 10 s 3,600 × over-fetched 50 screens × 3,600 renders = 180,000 fetches of one change.
The two bars are the same width because the render treats both sources identically. Only one of them earns it.

The cost lands on the wrong half

If the over-fetching were free this would be an aesthetic complaint. It is not free, and worse, it is expensive on exactly the side that did not need refreshing.

Dataverse is an operational store built for constant small reads. That is what it is for, and it copes.

The Fabric warehouse is a different machine with a different cost model. In my measurements a call to it costs something in the region of 150 ms before it does any work at all: connect, authenticate, plan, dispatch, return. That floor barely moves between one row and a hundred thousand, because it is not about the data. And Fabric capacity is shared and metered in CU, so those calls are taken from the same pool as your pipelines, your notebooks and your reports.

So the arrangement is: the cheap-to-query source is the one that actually changed, and the expensive-to-query source, on a shared capacity you are billed for, is being polled at a cadence set by a completely different system's event rate. Nothing in the code says that out loud. It is just what happens when two sources share a render.

Split the render by change rate

The fix is not "add a cache" so much as "stop pretending these are one thing".

The volatile side keeps coming live from Dataverse on every poll, because it genuinely changed. The stable side moves into Upstash Redis, refreshed on a cadence that matches how often it actually changes rather than how often the screen redraws. The join happens in the Next.js application, against a few megabytes of memory in the same region as the app.

The warehouse is still the system of record for that reference data. It is simply no longer in the request path of a screen that redraws every ten seconds.

What makes this comfortable rather than frightening is that the hard part of caching mostly evaporates. The usual objection is invalidation, and invalidation is only hard when you cannot predict change. Here you can: reference data changes through known write paths, at known times, at a rate you can measure. You are not guessing at a TTL for something volatile. You are memoising something that was already nearly constant.

And the thing you must never cache, the live event stream, is the one thing you never do cache. The design puts the cache exactly where staleness is cheap and keeps it away from where staleness is fatal.

Megabytes against millions

Measure the working set once you have split it and the second surprise arrives.

The stable half is tiny. It is the current state of a few thousand things, not the history of all of them: a few megabytes. Against that, the request count runs to tens of millions a month.

Megabytes of data, millions of operations. Every instinct that says "we need a bigger warehouse" is aimed at the wrong axis. The bottleneck was never data volume. It was request rate against a system that prices and throttles by compute rather than by rows.

Fabric warehouse Redis
Built for hard questions, large data, occasionally easy questions, tiny data, constantly
Round trip ~150 ms floor ~1 ms, same region
Concurrency shared CU, metered, throttles trivially parallel
Holds everything, historically the current answer only
Right for the reference data itself the reference data on a screen

This is facts and dimensions again

It took me embarrassingly long to notice that this is a very old idea arriving from an unfamiliar direction.

Splitting a view by change rate is the fact-and-dimension split. Events are facts: many, fast, append-only. Reference data is dimensions: few, slow, overwritten. Dimensional modelling separates them because it makes queries honest. Caching separates them because they have different refresh economics.

Same seam, two completely different reasons to cut along it. When a performance problem and a modelling principle point at the same line, that line is usually real.

Two fixes, not one

The version of this story that ends "so we put Redis in front of it" is missing half the fix, and it is the half that makes the rest honest.

Result-set caching in the Fabric warehouse is worth turning on and is frequently left off. It removes the recomputation, which is real CU on a metered capacity. What it does not remove is the round trip, the connection, or the fact that every poll still arrives at the warehouse and is accounted against the shared pool.

The application cache removes the trip entirely and puts the data in the same region as the app.

They solve different halves. Result-set caching makes the warehouse cheaper to ask. Splitting by change rate means you mostly do not ask.

The same mistake, one layer down

Watch for this, because it is easy to congratulate yourself and walk straight into it.

You remove thirty chatty round trips to the warehouse, then rebuild them against the cache. Thirty individual GETs to draw one screen is thirty round trips, and while 1 ms is not 150 ms, thirty of them in sequence is still 30 ms of pure waiting on every render, on every screen.

An MGET or a pipeline collapses them into one. On a fixed-price tier this saves no money at all, which is exactly why it survives: nothing bills you for it, so nothing warns you. It buys latency and headroom, and it makes the request count in your dashboard reflect what the application needs rather than how it happens to be written.

Chattiness is a property of the calling code, not of the thing being called. Moving to a faster backend relocates it rather than fixing it.

How to know it is load bearing

I found out by accident, which is the only honest way these things get tested.

A spend cap on the Redis store was reached, it stopped serving, and the application quietly fell back to querying Fabric directly. CU consumption spiked immediately and visibly. Nobody had deployed anything. The only change was that a few megabytes of memory stopped answering.

That is now the test:

If you turned it off, would anyone notice?

There is a lot of decorative caching in the world, sitting in front of things that were never slow, buying a consistency problem in exchange for nothing. A cache whose removal is invisible is not a cache, it is a liability with good PR. The ones worth keeping announce themselves the moment they stop.

The tell

One debugging note, because dashboards lie in a specific way.

The cache console reported 0 B of storage while the database was plainly serving reads. Storage metrics update on their own schedule and will happily report zero for a live keyspace, and I spent a while investigating a data loss that had not happened. DBSIZE answered honestly, in one command.

When a metric disagrees with observed behaviour, believe the behaviour and go find a primitive that answers directly. An aggregated dashboard number is downstream of a pipeline that can be late, broken, or measuring something adjacent to what its label claims.

The rule

Refresh each source at the rate it changes, not at the rate the page redraws.

A screen is a composition, and compositions hide their own cost model. Nothing in a render tells you that one half of it is volatile and the other half has been identical for a month. You have to go and ask each source how often it actually changes, and then let the answers differ.

Almost every live screen is mostly not live. The interesting question is never how fast the fast part is. It is how much of the screen you have been refreshing at that speed for no reason.