Target Level: Senior Frontend Engineer / Staff Engineer
Duration: 45-60 minutes
Interview Focus: Real-Time Dashboards, Time-Series Data, Rendering Performance, Caching, Query Orchestration
Interview Importance: π΄ Critical β Analytics dashboards are a strong frontend system design topic because they force you to reason about heavy data volumes, real-time updates, rendering bottlenecks, partial failures, and user experience under performance pressure.
When asked to design a Grafana-like dashboard, interviewers are evaluating:
Pro Tip: Start with the dashboard constraints: number of panels, refresh frequency, cardinality of metrics, and how much historical data must be shown at once.
A high-performance analytics dashboard is a frontend system that lets users monitor live and historical metrics across many panels such as line charts, heatmaps, stat cards, tables, and alerts.
Typical user actions:
Loading diagramβ¦
Think of it like an airport control room. Operators need many screens updating continuously, but they must still be able to zoom into one critical issue instantly without freezing the whole system.
| Interview Problem | What You Should Say |
|---|---|
| Too many charts on screen | Render only what is visible, virtualize heavy panels, and isolate updates |
| Large time ranges | Downsample data before drawing |
| Frequent refreshes | Deduplicate queries and use stale-while-revalidate caching |
| Mixed data sources | Use a query orchestration layer instead of letting panels fetch independently |
| One broken panel | Use panel-level error boundaries and partial rendering |
| Live metrics + historical metrics | Separate streaming path from historical query path |
Before designing, ask:
Loading diagramβ¦
Do not let every panel own its full networking, caching, retry, polling, and rendering lifecycle independently. That creates duplicated requests, inconsistent behavior, and massive CPU churn.
Scenario: 24-panel dashboard opens with "Last 1 hour" and 10s refresh βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ Step 1: Dashboard config loads panels = 24 visible_panels = 8 hidden_below_fold = 16 Action: render layout + skeletons immediately Step 2: Query planner runs raw_queries = 24 duplicate_queries = 6 unique_queries = 18 Action: merge duplicates by queryKey Step 3: Cache lookup fresh_hits = 7 stale_hits = 5 misses = 6 Action: - return 7 immediately - show stale data for 5 and revalidate in background - fetch 6 from network Step 4: Visibility prioritization high_priority = 8 visible panels low_priority = 10 below fold paused = 6 collapsed/tab-hidden panels Action: fetch visible panels first Step 5: Rendering stat_cards -> DOM small sparklines -> SVG dense time-series charts -> Canvas Action: avoid expensive SVG paths for very large datasets Step 6: Refresh tick after 10s unchanged query keys = reuse cache metadata live panels = apply incremental append historical panels = request only delta window when possible
const PANEL_REGISTRY = { stat: StatPanel, line: LineChartPanel, heatmap: HeatmapPanel, table: TablePanel, logs: LogsPanel, }; const DashboardPanel = ({ panel }) => { const PanelComponent = PANEL_REGISTRY[panel.type] ?? UnsupportedPanel; return ( <PanelErrorBoundary panelId={panel.id}> <PanelComponent panel={panel} /> </PanelErrorBoundary> ); };
Why this matters: interviewers like extensible systems. New panel types should plug into the dashboard runtime without rewriting the whole page.
class QueryOrchestrator { constructor(client, cache) { this.client = client; this.cache = cache; this.inFlight = new Map(); } async fetch(queryKey, fetcher) { const cached = this.cache.get(queryKey); if (cached?.fresh) return cached.data; if (this.inFlight.has(queryKey)) { return this.inFlight.get(queryKey); } const request = fetcher() .then((data) => { this.cache.set(queryKey, data); return data; }) .finally(() => { this.inFlight.delete(queryKey); }); this.inFlight.set(queryKey, request); return request; } }
Why: Without this, 10 panels using the same metric can trigger 10 identical network calls.
Use IntersectionObserver or layout visibility metadata so offscreen panels refresh less often.
const getRefreshPriority = ({ isVisible, isPinned, isInteracting }) => { if (isPinned || isInteracting) return 'high'; if (isVisible) return 'normal'; return 'low'; };
Why: A dashboard with 40 panels should not spend equal CPU and network bandwidth on hidden panels.
| Rendering Mode | Best For | Avoid When |
|---|---|---|
| DOM | Small stat cards, filters, legends | Large point sets |
| SVG | Interactive charts with low-to-medium point count | Tens of thousands of points |
| Canvas | Dense time-series charts, heatmaps | Heavy accessibility requirements without fallback |
| WebGL | Extremely large datasets, advanced visualization | Simpler charts where complexity is not justified |
Interview line: "I would default to SVG for simple charts, but switch to Canvas for high-density time-series panels because thousands of DOM nodes or long SVG paths can cause jank."
Never draw 1 million points into a 1200-pixel wide chart when only ~1200 horizontal pixels are visible.
const downsampleByBucket = (points, bucketCount) => { if (points.length <= bucketCount) return points; const bucketSize = Math.ceil(points.length / bucketCount); const result = []; for (let i = 0; i < points.length; i += bucketSize) { const bucket = points.slice(i, i + bucketSize); let min = bucket[0]; let max = bucket[0]; for (const point of bucket) { if (point.value < min.value) min = point; if (point.value > max.value) max = point; } result.push(min, max); } return result; };
Why: The user sees trend fidelity, not every raw point.
| Layer | Responsibility |
|---|---|
| Dashboard Shell | Layout, toolbar, variables, saved state |
| Query Orchestrator | Dedup, batching, retries, cancellation |
| Cache Layer | SWR, TTL, memory cache, optional IndexedDB |
| Transformation Layer | Parsing, aggregation, downsampling |
| Panel Runtime | Visibility, subscriptions, error isolation |
| Rendering Layer | DOM/SVG/Canvas/WebGL decision |
Do not use a naive global setInterval that refreshes every panel at once.
Better approach:
const shouldRefreshPanel = ({ isTabVisible, isInFlight, refreshPolicy }) => { if (!isTabVisible) return false; if (isInFlight) return false; return refreshPolicy !== 'paused'; };
Move these off the main thread when payloads are large:
| Decision | Option A | Option B | My Interview Answer |
|---|---|---|---|
| Data fetching | Each panel fetches independently | Central orchestration | Central orchestration for dedup + consistency |
| Live updates | Polling | WebSocket/SSE | Polling for simple dashboards, streaming for critical live panels |
| Chart rendering | SVG | Canvas/WebGL | SVG for light charts, Canvas/WebGL for dense charts |
| Cache strategy | No cache | SWR + TTL cache | SWR + TTL for fast repeat loads |
| History loading | Full range every time | Delta fetch / windowing | Delta fetch when backend supports it |
| Layout rendering | Render all panels always | Virtualize/defer offscreen panels | Defer offscreen heavy panels |
Use query deduplication, visibility-based fetching, per-panel rendering isolation, Canvas for dense charts, and downsampling before draw.
No. I would use streaming only for panels that need near-real-time freshness. Historical or low-priority panels can poll less frequently.
Each panel should have isolated loading/error state, independent cancellation, and a timeout budget. The dashboard shell should render partial success.
Debounce query regeneration, cancel stale in-flight requests with AbortController, and preserve previous data while loading the next result.
Ask the backend for pre-aggregated or downsampled series and render only screen-relevant points.
Panel render time, dropped frames, refresh latency, cache hit rate, query failure rate, and long tasks on the main thread.
β BAD
useEffect(() => { const timer = setInterval(fetchPanelData, 5000); return () => clearInterval(timer); }, [fetchPanelData]);
β GOOD
useEffect(() => { scheduler.register(panel.id, panel.queryKey, panel.refreshInterval); return () => scheduler.unregister(panel.id); }, [panel.id, panel.queryKey, panel.refreshInterval, scheduler]);
Why it breaks: independent timers drift, duplicate requests, and spike CPU/network usage.
β BAD
chart.draw(points); // points.length = 800000
β GOOD
const visibleWidth = chartWidthInPixels; const sampledPoints = downsampleByBucket(points, visibleWidth); chart.draw(sampledPoints);
Why it breaks: rendering cost grows with data size, not with what users can actually see.
β BAD
setDashboardState((prev) => ({ ...prev, panels: prev.panels.map(updatePanelData), }));
β GOOD
panelStore.update(panelId, nextSeries);
Why it breaks: broad state replacement causes unrelated panels to re-render.
β BAD
if (dashboardErrors.length > 0) { return <FullPageError />; }
β GOOD
return panels.map((panel) => ( <PanelBoundary key={panel.id}> <DashboardPanel panel={panel} /> </PanelBoundary> ));
Why it breaks: one panel failure should not blank the entire dashboard.
| Operation | Complexity | Explanation |
|---|---|---|
| Query dedup lookup | O(1) average | Map-based lookup by normalized query key |
| Downsampling | O(n) | Must scan the raw points once |
| Visible panel scheduling | O(p) | p = number of panels |
| Chart draw after sampling | O(w) or O(s) | w = pixel width, s = sampled point count |
| Dashboard layout render | O(p) | One pass over panels |
| Cache storage | O(q + d) | q queries and d cached datasets |
| Topic | Best Practice |
|---|---|
| Networking | Central query orchestration |
| Caching | SWR + dedup + TTL |
| Rendering | Choose DOM/SVG/Canvas/WebGL by density |
| Large datasets | Downsample before render |
| Refresh | Priority-based scheduler, not independent timers |
| Reliability | Panel-level isolation and graceful degradation |
Test your understanding with 3 quick questions