[Guide] Build a Realtime Dashboard (Stream Blocks) - implementation notes
The Realtime Dashboard guide proposes an intentionally "blocking" streaming posture: stream blocks to show the most complete view, derive indicators and store them so the UI stays coherent, and keep the whole thing anchored in a route shell contract. I'm curious how people applied this without making the product feel unresponsive.
How did you decide what should block (stream blocks) versus what can stream progressively? Did you store derived "freshness/lag" indicators, and what did you render as evidence for them? How did you handle duplicate reads across panels while keeping the UI consistent? What was your strategy for cancellation/aborts in a "most complete" posture?
Comments (16)
Back to latestThe blocking posture felt better than expected because the UI was honest about it.
We show a "completing view" lane instead of pretending partial data is final.
We logged stream-block boundaries as contract transitions so debugging was straightforward:
txt
[tips] streamBlock=sections lane=pending reason=route:enter
[tips] streamBlock=sections lane=ok reason=renderFetch:resolve
[tips] derive indicator lagMs=420 freshnessAt=2023-02-08T06:06Z
Then the UI renders data-stream-block=ok + data-lag-ms=420 so support can see it too.
Counterpoint: blocking can be a hard sell for dashboards where users expect immediate numbers.
We used a split posture: block for "global totals", stream for per-card details.
That aligns with how I read it: choose block boundaries intentionally.
If the global totals are wrong, the whole dashboard feels wrong—so they're worth blocking for.
Stored derived indicators were essential (lag, freshness, stream lane).
Otherwise every card invents its own "stale" rules and the UI contradicts itself.
Duplicate reads: we used a winner rule based on revision + source. And we logged the merge decisions:
txt
[tips] merge winner=panel:overview rev=128 loser=panel:detail rev=127In a "most complete" posture, abort/cancel should be a normal lane, not an error.
We render abort evidence so users understand why the view didn't update (navigation, filter change).
We used a render fetch boundary plus a derived indicator bundle that gets written once per update tick.
It prevented death-by-1000-re-renders in the cards.
Counterpoint: blocking can hide partial usefulness.
We added a secondary surface that shows last-known-good (LKG) values with explicit evidence labels.
LKG is a great compromise if you label it clearly.
If it looks current but isn't, users lose trust fast.
The derive-on-mutation posture was a perf win for us.
We stop deriving inside card renders and treat derived indicator bundles as the UI contract.
Docs tie-in: the data fetching posture pages and the app router mindset both push you toward explicit boundaries.
Related: Data Fetching and Caching (Best Practices) and The App Router Mindset.
Our UI felt less "blocked" when we made the completion target explicit (e.g., "Waiting for 3 sections").
Even if it's a lie, it's a useful lie because it sets expectations.
We also introduced a render tick posture (hot/warm/cool) for streaming cadence and logged it:
txt
[tips] tick=warm windowMs=5000 reason=defaultDashboardCounterpoint: if your backend is slow, blocking becomes painful no matter what.
We added a route posture that switches to progressive mode on high latency (explicit toggle).
We found that "derived indicators stored" also improved incident response.
When dashboards are wrong, being able to see the lag/freshness story matters as much as the numbers.
If you're doing stream blocks, invest in a stable route shell layout.
Layout shifts ruin trust even when the data is correct.
The guide made me stop treating streaming as "free".
Streaming is a product decision; block boundaries are part of the contract and should be logged like everything else.