Skip to content
QSWEQB
hardDesignCase StudySeniorStaffLead

Design the frontend for a data-heavy operational dashboard with live updates.

Stream a server-rendered shell, virtualise the dense views and draw high-density charts to canvas, keep server cache separate from UI and URL state, coalesce socket messages into one commit per frame, give every widget its own empty, error and stale states, and enforce a byte budget per pull request.

7 min readUpdated 2026-07-26

What the interviewer is scoring

  • Does the candidate quantify the load before choosing anything
  • Whether they separate cached server data from client UI state and from state that belongs in the URL
  • That they decouple message arrival rate from render rate rather than relying on the framework to cope
  • Whether error and empty states are scoped per widget instead of to the whole page
  • Can they justify a bundle budget with arithmetic from a stated device and network assumption
  • Whether they treat a dropped connection as a UI state rather than as a retry detail

Answer

Pin down the load before designing for it

Nothing here can be justified without numbers, so state them as assumptions and let the interviewer correct you. Say twenty-five widgets on the default view, one of them a table reaching fifty thousand rows after a wide filter, around two hundred update messages a second spread unevenly across those widgets with bursts at shift open, a few thousand concurrent users mostly on desktop with a minority on warehouse tablets, and sessions measured in hours.

The last two matter more than they look: long-lived sessions turn leaks and unbounded caches into incidents rather than annoyances, and the tablet population sets the device you budget against. If the real answers are five widgets, five hundred rows and one update a second, most of what follows is over-engineering, and saying so is worth more than the design.

Rendering strategy per surface, not per application

The page is authenticated, per-user and non-indexable, so there is no SEO argument for server rendering and no useful shared cache. The first-paint argument survives: stream a server-rendered shell — navigation, layout, widget frames, skeletons — so the structure is visible while the bundle and the data are in flight. Everything past the shell is client-rendered, since the data is user-specific and about to change anyway.

Within the page, choose per surface. The table is virtualised: render only the rows intersecting the viewport plus a small overscan, and with uniform row heights the offsets are arithmetic rather than measurement. Fifty thousand rows becomes perhaps forty DOM rows, which is the difference between a browser that responds and one that does not. Charts split on mark count: SVG gives one node per mark, convenient for hit-testing and CSS but not viable in the low thousands, so a dense time series goes to canvas where the series is one element and hit-testing runs against your own data. Aggregation or resampling over a large series belongs in a Web Worker, because the main thread's only job during a burst is to paint. Panels below the fold get content-visibility: auto, so their layout and paint are skipped until they are scrolled towards.

Four state layers, kept apart

Most dashboards that become unmaintainable did so by putting everything in one store. There are four kinds of state here, with different owners, lifetimes and invalidation rules. Server data lives in a query cache — TanStack Query, SWR or an Apollo cache — keyed by the query and its parameters, with request deduplication, a staleTime per resource, and background refetch. It is a cache, not application state, so it may be evicted and refetched. Client UI state is what the server has never heard of: which panels are collapsed, the selected row, whether a drawer is open. Keep it local to the component that owns it and lift it only when a sibling genuinely needs it. URL state is the subset that must survive a reload and be shareable: filters, sort, date range, selected tab. Put it in the query string, because otherwise "send me what you are looking at" is impossible and the back button is broken. Transport state is the socket buffer below, and it deliberately does not enter the render tree until it is flushed.

The payoff is that a widget's data flow reads as one line — URL state parameterises a cache key, the key resolves to data, the data renders — with no path where a filter change and a socket update race to write the same object.

Getting live data in without rendering two hundred times a second

Choose the transport on the direction of traffic. Server-Sent Events are plain HTTP with reconnection and event-ID resumption built into the browser's implementation, they multiplex over HTTP/2, and they pass through infrastructure that treats anything unusual with suspicion — but they are one-way. A WebSocket gives a bidirectional channel, which you want when clients subscribe and unsubscribe as widgets mount, at the price of owning the heartbeat, reconnect and backoff yourself. Here that is the usual answer, with a protocol of snapshot-on-subscribe followed by deltas carrying a monotonic sequence number, so a gap tells the client its state is unreliable and it should re-request a snapshot rather than quietly drift.

The rendering side is where dashboards fall over. Two hundred messages a second, each committing state, is two hundred render passes into a display that refreshes sixty times, so most of the work is discarded before a pixel changes. Decouple the rates by buffering outside framework state entirely and flushing on a frame:

const pending = new Map<string, Tick>();
let frame = 0;

socket.addEventListener('message', (event) => {
  const tick: Tick = JSON.parse(event.data);
  // Conflate by key: only the newest value for an instrument can matter to a cell.
  pending.set(tick.symbol, tick);
  if (!frame) frame = requestAnimationFrame(flush);
});

function flush() {
  frame = 0;
  const batch = [...pending.values()];
  pending.clear();
  store.applyBatch(batch); // One commit, therefore one render pass per frame.
}

Two properties make this work. The Map conflates, so forty updates to one symbol cost one render rather than forty, and requestAnimationFrame stops firing in a backgrounded tab, so flushing pauses by itself. Add a Page Visibility check that unsubscribes from high-rate feeds once the tab has been hidden for a while, because a dashboard left open on a second monitor for six hours is the normal case.

Every widget owns its own four states

Scope failure to the widget. Each panel gets its own error boundary and its own query, so a 500 from the alerts service leaves the other twenty-four panels working while the alerts panel shows an inline retry that refetches only its own key. A single page-level boundary turns one bad endpoint into a blank screen.

Empty needs disambiguating too, because "no data" has at least three causes with three different affordances. Nothing has ever existed here is an onboarding state that should say how data arrives. Nothing matches the current filter should offer to clear the filter, quoting what was filtered. Not permitted to see this is neither, and rendering it as empty is how you teach users the dashboard is broken. Loading is a skeleton shaped like the eventual content rather than a spinner, so nothing shifts when data lands.

The dashboard that looks live and is not

The failure this design has to take seriously is not slowness, it is a stale reading presented as current. When the socket drops, the store still holds the last values and the widgets still render them, so the numbers look exactly as authoritative as they did a minute ago and an operator decides on data that stopped updating at 09:14. The transport layer cannot notice this; only the UI can. So connection health is first-class UI state with three values, live, reconnecting and stale, and the transition to stale is time-based rather than event-based: if no message has arrived for a resource within a multiple of its expected interval, mark it stale even though the socket claims to be open. Stale widgets desaturate, show the age of the value and stop animating, because motion reads as liveness. Reconnection then walks jittered exponential backoff and re-requests snapshots rather than resuming as though nothing happened.

Deriving the budget rather than quoting one

A budget is defensible only if you can show the arithmetic. Take the p75 client as a mid-range tablet on constrained office wifi at roughly 1.5 Mbps effective throughput, which is about 190 KB per second, and assume — as a figure to verify on the device rather than trust — that parsing and compiling compressed JavaScript costs around a millisecond per kilobyte on that CPU. If the shell must be interactive in 1.5 seconds, 200 KB compressed spends about 1.05 seconds in transfer plus 0.2 in parse and compile before a line of your code runs, consuming the budget before rendering starts. So the initial route gets 170 KB of compressed JavaScript and everything else defers.

That forces the split naturally. Framework, router, shell and query client sit in the initial chunk. The virtualised grid loads with the route containing a grid; the charting engine loads with the first chart, ideally after first paint; date-picker locales, the spreadsheet export and the admin views are separate chunks most sessions never fetch. Then enforce it with a size check that fails the pull request when a chunk crosses its ceiling, because a budget nobody can breach by accident is the only kind that survives a year of feature work. Pair it with field measurement of INP, since what matters is whether the dashboard responds during a burst, and no bundle size predicts that on its own.

Likely follow-ups

  • Traffic is 200 messages a second across 25 widgets. Where exactly does the first thing break, and what do you measure to confirm it?
  • Would you choose Server-Sent Events or a WebSocket here, and what does the answer depend on?
  • A user filters the table to 40 rows and shares the URL with a colleague. What did you have to design for that to work?
  • The chart library alone is a third of your budget. What are your options besides removing the charts?

Related questions

Further reading

frontend-architecturevirtualisationwebsocketscachingperformance-budget