Topic 25.8
Design a Data Dashboard
In one line
A dashboard is a grid of independent widgets that each load, refresh, fail and render on their own; the design is about a shared query cache, polling or streaming at sensible rates, charts that draw thousands of points cheaply, virtualised tables, filters and time ranges in the URL, and permissions enforced on the server.
Think of it like this
The dashboard of a car. The speedometer updates many times a second, the fuel gauge every few seconds, the service reminder once a day. If one bulb fails, the rest still work. And the mechanic's screen shows things the driver's doesn't. A data dashboard is the same: widgets with their own refresh rates, failures that stay local, and what you see depends on who you are.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Widget
- One self-contained box on a dashboard: a number, a chart or a table with its own data and refresh rate.
- Polling
- Asking the server for fresh data again every few seconds.
- Streaming
- The server pushing new data as it happens, over SSE or a WebSocket.
- Pre-aggregation
- The server summarising raw events (for example per minute) so the client downloads totals, not millions of rows.
- Downsampling
- Reducing thousands of points to about as many as the chart has pixels, keeping its shape.
- Bucket
- A time slice that data is grouped into, like 1 minute or 1 hour.
- Row-level security
- The server only returning the rows a user is allowed to see.
- Widget registry
- A map from widget type to its component, so a dashboard can be described as data.
Step by step
01Requirements and clarifying questions
Who uses it and how fresh must it be? An executive dashboard can be 5 minutes old; an on-call dashboard needs seconds. How much data per widget? Fixed or editable layout? Then numbers: 12–30 widgets, first widgets in 2 s, live widgets ≤ 30 s old, charts up to 10k points, tables up to 100k rows.
02Architecture
The dashboard is described as data (layout + widget configs). A shared filter state (from the URL) feeds every widget's query. A query cache de-duplicates and polls. Live widgets may also subscribe to a stream. The server aggregates and enforces permissions.
03Component tree
Each widget sits inside its own boundaries and only loads when visible.
04Data model
The dashboard is data, so it can be saved, shared and rendered by a generic grid.
type Range = { kind: "relative"; last: "1h" | "24h" | "7d" } | { kind: "absolute"; from: string; to: string };
type Filters = { range: Range; tz: string; region?: string[]; product?: string[] };
type WidgetConfig = {
id: string;
type: "kpi" | "line" | "bar" | "table";
metric: string; // e.g. "revenue"
groupBy?: string;
refreshMs: number; // 0 = no polling
requires: string; // permission, e.g. "sales:read"
};
type Layout = { widgetId: string; x: number; y: number; w: number; h: number }[];
type Series = { name: string; bucket: "1m" | "5m" | "1h"; points: [t: number, v: number][] };05Interface (API)
One query endpoint takes the metric, the absolute range, the bucket and the filters, so every widget uses the same, cacheable call. The bucket comes from the range and the chart's width.
POST /api/query
{ "metric": "revenue", "from": "2024-01-08T00:00:00Z", "to": "2024-01-15T00:00:00Z",
"bucket": "1h", "filters": { "region": ["eu"] } }
-> 200 { "series": Series[], "generatedAt": "2024-01-15T00:00:05Z" }
-> 403 { "error": "missing permission sales:read" }
GET /api/table/orders?sort=-amount&cursor=...&limit=100®ion=eu # server-side sort and paging
GET /api/me -> { "permissions": ["sales:read"] }
SSE /api/live?metrics=errors,latency # event: point, data: { metric, t, v }06Deep dive: shared polling
Two widgets showing the same metric share one cache entry and one timer, at the faster of their two rates. Unmounting a widget recomputes the rate; when nobody watches, polling stops.
07Deep dive: rendering many points and rows
Ask the server for a bucket that gives about one point per pixel. If raw data does arrive, downsample with min/max buckets so spikes survive. Draw on canvas past a few thousand points. Tables page on the server and virtualise in the browser.
Try it yourself
- 1
Unmount the fast widget
In the polling example, remove the "Revenue (live)" widget. Predict the revenue request count after 1.1 s at a 500 ms rate, then check.
- 2
Give the user more permissions
Add
"hr:read"toME.permissions. Predict thehidden:line and the payroll request count. - 3
Average instead of min/max
Write a
downsampleAvgthat pushes the average of each bucket. Predict whether "spike kept" staystruefor 7d, then check the chart.
Code & diagrams
Two widgets show revenue but share one timer at the faster 300 ms rate: 4 requests in 1.1 s (at 0, 300, 600 and 900 ms). Payroll is hidden (and never fetched) for a user without `hr:read`; the server must refuse it anyway.
Preview
Console
Nothing logged yet.Expected console output
hidden: Payroll (needs hr:read)
requests after 1.1 s: revenue 4, users 6, payroll 0A 300 px chart can't show 10,080 points. Min/max buckets cut 7 days down to 300 points and still keep every spike; averaging would flatten them. In production the server does this by returning a coarser bucket.
Preview
Console
Nothing logged yet.Expected console output
1h: 60 -> 60 points, spike kept: true
24h: 1440 -> 300 points, spike kept: true
7d: 10080 -> 300 points, spike kept: truefunction useMetric(metric: string, filters: Filters, refreshMs: number) {
const { from, to, bucket } = resolveRange(filters.range, filters.tz); // rounded to the bucket
return useQuery({
queryKey: ["metric", metric, from, to, bucket, filters.region], // same key = shared cache entry
queryFn: ({ signal }) => api.query({ metric, from, to, bucket, filters }, signal),
refetchInterval: refreshMs || false, // each observer may ask; requests for one key are de-duplicated
refetchIntervalInBackground: false, // default: no polling while the tab is hidden
placeholderData: keepPreviousData, // keep the old chart while a new range loads
staleTime: 10_000,
});
}Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
One timer per widget
In the polling example, make each widget poll on its own: replace the if (e.rates.size) ... line in reschedule with for (const ms of e.rates.values()) setInterval(() => refresh(key), ms);.
Myth vs fact
Myth
Real-time dashboards need WebSockets.
Fact
Most business dashboards are fine with polling every 10–60 seconds, which is simpler, cacheable and easy to pause. Streaming pays off for sub-second operational data.
Myth
Draw every data point so nothing is hidden.
Fact
A chart has a fixed number of pixels. Thousands of extra points cost rendering time and add nothing visible. Downsample with a method that keeps peaks.
Myth
Hiding a widget is enough to protect its data.
Fact
Anyone can call the API directly. The server must check permissions and filter rows; the client only hides what the user couldn't use anyway.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Round relative ranges to the bucket on the client: "last 24 h" computed from
Date.now()to the millisecond gives every widget a unique cache key and defeats both client and server caches. Roundtodown to the minute (or the bucket) and every widget, and every user, shares results. - ▸
Batch streaming updates: append incoming points to a buffer and flush to state once per
requestAnimationFrame, or useuseDeferredValuefor the chart (Phase 16.7) so typing in the filter bar stays responsive while charts update. - ▸
Use the Page Visibility API and an idle check to stop polling for an unattended dashboard on a wall screen at night; a hundred such screens polling every 5 s is real load.
- ▸
Accessibility for charts: a descriptive
aria-labelor caption with the key takeaway, a data table alternative, colour plus shape or pattern for series (not colour alone), and keyboard access to tooltips. The dataviz work counts as much as the code in a design review.
Remember this
- 1
Requirements. Functional: a grid of widgets (number cards, line and bar charts, tables), a shared time range (last hour, 24 hours, 7 days, custom) and filters (region, product) that apply to every widget, drill-down from a chart to a table, a saved layout per user, export. Non-functional: the first widgets visible within 2 s, live widgets at most 10–30 s old (or sub-second for an operations dashboard), smooth interaction (INP < 200 ms) even while data refreshes, charts with 10,000+ points and tables with 100,000 rows stay responsive, and users see only data they're allowed to. Clarify: who uses it (executives, analysts or on-call engineers), how fresh data must be, how much data per widget, and whether the layout is fixed or user-editable.
- 2
Widgets and layout. Each widget is a self-contained unit: a config (
{ type, metric, viz, refreshMs, requires }), its own Suspense and error boundary (one failing query shows "Couldn't load, Retry" without blanking the page, Phase 14.5), and lazy loading when it scrolls into view (IntersectionObserver) so a 30-widget dashboard doesn't fire 30 queries at once. The layout is a CSS grid with breakpoints for mobile; user-editable layouts (drag and resize, for example with react-grid-layout) are saved per user as a list of{ widgetId, x, y, w, h }. A widget registry mapstypeto a lazy-loaded component so chart code downloads only when needed. - 3
Polling versus streaming. Polling (asking every N seconds) is simple, works with ordinary HTTP caching, and is right for most business dashboards. Make it smart: one poll per query however many widgets show it, at the fastest rate any of them asked for; pause when the tab is hidden (
document.visibilityState, which TanStack Query'srefetchIntervalInBackground: falsedefault does for you); back off after errors. Streaming (Server-Sent Events or WebSocket, Phase 24.6) pushes changes and suits sub-second operations dashboards; batch incoming points and render at most once per animation frame so a burst of messages doesn't cause a burst of renders. Either way, the server pre-aggregates (sums per minute) so the browser never downloads raw events. - 4
Charts performance. A chart can't show more points than it has pixels. Downsample to roughly one or two points per pixel, ideally on the server (ask for
bucket=5mwhen the range is 7 days), or on the client with min/max bucketing that keeps spikes visible (plain averaging hides the very spike an on-call engineer is looking for); LTTB (largest-triangle-three-buckets) is a popular shape-preserving algorithm. SVG is easy and accessible up to a few thousand elements; Canvas handles tens of thousands; WebGL handles millions. Memoise the transformed data (useMemo, or the React Compiler, Phase 16.4–16.5), keep heavy transforms in a Web Worker, and lazy-load the chart library. Every chart needs a text alternative: a summary ("Revenue rose 12% this week") and a "View as table" toggle (Phase 18). - 5
Large tables, filters, time ranges and caching. Tables with 100,000 rows use server-side sorting, filtering and pagination, and virtualise what's loaded (rows, and columns if there are many, Phase 24.1), with sticky headers and
role="grid"keyboard navigation for interactive tables. Filters and the time range live in the URL (?range=7d®ion=eu&tz=Asia/Kolkata) so a link reproduces the exact view. Relative ranges ("last 24 hours") are converted to absolutefrom/torounded to the bucket (the minute, the hour), so every widget asks the same question and caches can hit. Time zones are explicit. The query cache key includes metric, filters, range and bucket; switching back to a range hits the cache, and keeping the previous data on screen while the new range loads avoids flashing empty charts. - 6
Permissions. The server decides what each user may see: it filters rows (row-level security, for example only your region) and refuses metrics you lack permission for. The client receives the user's permissions (
/api/me) only to hide widgets and actions they can't use, which is a convenience, not security (Phase 21.4). Export and drill-down endpoints check permissions again. Monitoring tracks query latency and error rate per widget, so slow widgets can be fixed or cached.
Explain it without notes
Walk me through the architecture of a dashboard with 20 widgets.
Polling or streaming: how do you choose, and how do you make polling efficient?
How do you render a chart with 100,000 points?
How would you design filters and time ranges for a dashboard?
How are permissions handled in a dashboard?
Practice
Make the polling example pause while the tab is hidden.
Add a "View as table" toggle to the chart example that shows the last 5 raw values of the selected range.
Trade-offs
- ↔
Fewer, bigger queries (one request for the whole dashboard) load faster on slow networks but couple every widget's latency to the slowest one; per-widget queries load progressively and fail independently.
- ↔
SVG charts are easy to style and accessible but slow past a few thousand elements; canvas is fast but needs extra work for accessibility and interaction.
- ↔
Short polling intervals feel live but multiply server load by users times widgets; choose per widget and pause when nobody's looking.
Done when you can
I can design a dashboard as data: layout, widget configs and a registry.
I can choose polling or streaming and share one poller per query.
I can downsample time series so charts stay fast and keep spikes.
I can design URL-based filters and rounded time ranges for caching.
I can plan virtualised tables with server-side paging and sorting.
I can explain where permissions are enforced and where they're only shown.