ESC

Type to search the knowledge base.

Design a Dashboard Analytics UI

Frontend system design for analytics dashboards — layout, query UX, charts, caching, and realtime refresh.

intermediate4 min read
  • system-design
  • interview
  • architecture
  • dashboard

Scope the problem

In scope (frontend):

  • Dashboard layout (grid of widgets)
  • Global filters (date range, workspace, segment)
  • Chart/table widgets with loading/error/empty
  • Drill-down navigation
  • Performance with many widgets
  • Optional realtime/polling refresh

Out of scope: OLAP engine design, warehouse modeling (mention only as API constraints).

Assumptions: B2B analytics; users compare ranges; permissions per widget/data set.

Requirements & metrics

Type Examples
Functional filter, visualize, export CSV, share link
Non-functional interactive filters < 300ms UI; query may be seconds
Product time-to-first-widget, error rate, saveable views

High-level UI architecture

┌──────────────────────────────────────────────────────────┐
│ Dashboard shell: title, saved views, share, refresh      │
├──────────────────────────────────────────────────────────┤
│ Global filter bar: date, compare, project, granularity   │
├────────────────────────┬─────────────────────────────────┤
│ Widget grid            │ Side peek / drilldown drawer    │
│  KPI · timeseries ·    │                                 │
│  table · pie           │                                 │
└────────────────────────┴─────────────────────────────────┘

Component model

DashboardPage
├── FilterBar (controlled filter state ↔ URL)
├── WidgetGrid (responsive layout)
│   └── WidgetContainer
│       ├── WidgetHeader (title, menu, last updated)
│       └── WidgetBody → Chart | Table | KPI
└── QueryClient / useWidgetQuery

Widget contract

type WidgetId = string;

type GlobalFilters = {
  range: { start: string; end: string }; // ISO dates
  compareRange?: { start: string; end: string };
  timezone: string;
  projectId: string;
  granularity: "hour" | "day" | "week";
};

type WidgetConfig = {
  id: WidgetId;
  type: "kpi" | "timeseries" | "table" | "breakdown";
  title: string;
  queryKey: string; // server query template id
  visualization: Record<string, unknown>;
};

type WidgetDataResponse = {
  series?: { t: string; v: number }[];
  rows?: Record<string, string | number>[];
  value?: number;
  asOf: string;
};

Data fetching & cache

Layer Strategy
URL filters + dashboard id for shareable state
React Query key: [widgetId, filtersHash]
HTTP Cache-Control for immutable past ranges; no-store for “live”
Memo expensive chart transforms
const key = ["widget", widget.id, filters];
useQuery({
  queryKey: key,
  queryFn: ({ signal }) => fetchWidget(widget.id, filters, signal),
  staleTime: isLive(filters) ? 30_000 : 5 * 60_000,
  placeholderData: keepPreviousData, // smooth filter tweaks
});

Parallelism: widgets fetch independently with concurrency limit (e.g. 4–6) to avoid stampeding the API when a dashboard with 20 widgets mounts.

Abort: filter changes abort in-flight queries.

Rendering & charts

  • Lazy-load chart library per widget type (import("echarts") or similar)
  • Reserve height to avoid CLS
  • Downsample large series on server; client shouldn’t plot 100k points
  • Tables: virtualize long breakdowns
  • Color tokens from design system (colorblind-safe palettes)

Cross-filtering

Click bar → updates global filter or local highlight. Decide:

  1. Global filter update (URL changes; all widgets refetch)
  2. Local brush (fast; only one chart)

Senior answer: support local highlight immediately + optional “apply as filter”.

Realtime / refresh

Mode Mechanism
Manual refresh button invalidates queries
Polling interval while tab visible
Push websocket invalidation message

Pause when document.hidden. Show asOf timestamp so users trust numbers.

Permissions

  • Dashboard list filtered by ACL
  • Widget query 403 → specific empty state (“Ask admin”) not generic error
  • Don’t leak metric names in error payloads

Performance budgets

  • Shell interactive quickly; widgets progressive
  • Defer below-fold widgets (IntersectionObserver mount)
  • Avoid one giant Redux dump of all series data
  • Prefer Web Worker for heavy client aggregations (rare if server does OLAP)

Empty / error / slow query UX

  • Skeleton matching chart shape
  • Timeout message with retry
  • Partial failure: other widgets still render
  • “Query too heavy” guidance (narrow date range)

Tradeoffs

  1. Flexible widget builder vs curated templates — power vs support cost
  2. URL-complete state vs ephemeral UI-only brushes
  3. Client aggregation vs server — scale favors server
  4. Realtime everywhere vs cost — poll critical KPIs only

Interview close

Scope filters + widget grid → independent cached queries keyed by filters → progressive load → a11y of charts (tables/summaries) → concurrency and visibility-aware refresh. Mention export and saved views as extensions.

Further reading