← Back to question bank
System DesignStaffHard#5015 · 65m

Design an analytics dashboard

Lead a frontend system design interview for an analytics dashboard. Cover requirements, state ownership, data fetching, rendering, accessibility, performance, testing, and rollout.

Answer Strategy

An analytics dashboard is the system design question that exposes whether you can think about state ownership across the URL, server cache, client memory, and chart rendering. The interview win is naming who owns time range, granularity, filters, and selected metric — and matching each to URL state, server query, or component-local state explicitly.

Lead with one sentence: "every shareable input lives in the URL." Range, granularity, and filters round-trip through search params so a refresh produces the same chart, a deep link reproduces the analyst's view, and the back button is meaningful. The same query object keys the server-side cache so identical queries dedupe; this is what lets a 6-person team open the same dashboard without 6x server load.

Volunteer the dangerous cases. A "live" dashboard polling every second on a 30-day range crushes the server; the right answer is to update only the most recent bucket, not the full series. A timezone bug between the client and server makes "today" look different across team members; pick UTC for storage, render in the viewer's zone. Charts that re-allocate full datasets on hover stutter; memoize the visible slice. CSV export should reuse the same query helper so the user gets exactly what they see. The reference shows the URL contract and bucket math — the architecture conversation is everything around it.

Reference Implementation: Dashboard Query Kernel

Pure helpers for bucket math and URL round-tripping. The component, server cache, and CSV exporter all consume this kernel.

type Range = { fromMs: number; toMs: number };
type Granularity = 'minute' | 'hour' | 'day';

type DashboardQuery = {
  range: Range;
  granularity: Granularity;
  filters: Record<string, string>;
};

// Pure helper: derive bucket boundaries the server cache key uses. Same
// math runs in URL state, the data layer, and the chart renderer so all
// three agree on what "today" or "this hour" means.
export function computeBuckets(query: DashboardQuery): Array<{ start: number; end: number }> {
  const step =
    query.granularity === 'minute' ? 60_000 :
    query.granularity === 'hour' ? 60 * 60_000 :
    24 * 60 * 60_000;

  const buckets: Array<{ start: number; end: number }> = [];
  // Snap the start to the granularity so refresh produces the same buckets.
  const snappedStart = Math.floor(query.range.fromMs / step) * step;
  for (let cursor = snappedStart; cursor < query.range.toMs; cursor = step) {
    buckets.push({ start: cursor, end: cursor + step });
  }
  return buckets;
}

// Decode and encode the URL contract. Refresh, deep links, and shared
// dashboards all rely on this round-trip being stable.
export function queryToSearchParams(query: DashboardQuery): URLSearchParams {
  const params= new URLSearchParams();
  params.set('from', String(query.range.fromMs));
  params.set('to', String(query.range.toMs));
  params.set('g', query.granularity);
  for (const [key, value] of Object.entries(query.filters)) {
    params.set('f.' + key, value);
  }
  return params;
}

export function searchParamsToQuery(input: URLSearchParams): DashboardQuery {
  const filters: Record<string, string> = {};
  for (const [key, value] of input.entries()) {
    if (key.startsWith('f.')) filters[key.slice(2)] = value;
  }
  return {
    range: {
      fromMs: Number(input.get('from') ?? 0),
      toMs: Number(input.get('to') ?? Date.now()),
    },
    granularity: (input.get('g') as Granularity) ?? 'hour',
    filters,
  };
}

Runnable Playground

Edit the implementation and run the tests directly in the browser. For system design questions, the playground focuses on the core state/data logic that the UI would call.


type Range = { fromMs: number; toMs: number };
type Granularity = 'minute' | 'hour' | 'day';

type DashboardQuery = {
  range: Range;
  granularity: Granularity;
  filters: Record<string, string>;
};

function computeBuckets(query: DashboardQuery): Array<{ start: number; end: number }> {
  const step =
    query.granularity === 'minute' ? 60_000 :
    query.granularity === 'hour' ? 60 * 60_000 :
    24 * 60 * 60_000;

  const buckets: Array<{ start: number; end: number }> = [];
  const snappedStart = Math.floor(query.range.fromMs / step) * step;
  for (let cursor = snappedStart; cursor < query.range.toMs; cursor += step) {
    buckets.push({ start: cursor, end: cursor + step });
  }
  return buckets;
}
TypeScript · runnable

Testing Strategy

Convert the answer into observable behavior. In a mid-senior interview, say which behaviors are covered by unit tests, interaction tests, accessibility checks, and one browser smoke path.

Kernel
Pure tests for computeBuckets at each granularity and for queryToSearchParams round-trip with filters. No DOM needed.
Cache identity
Verify two queries that differ only by filter key order produce the same canonical search-param string. This is what makes the server-side cache hit.
Performance
Profile a chart with 5,000 buckets in the React DevTools profiler. Verify hover does not re-allocate the full dataset; memoize the visible slice or move drawing to an HTMLCanvas/WebGL adapter.
import { describe, it, expect } from 'vitest';

describe('computeBuckets', () => {
  it('snaps the start to the granularity boundary', () => {
    const buckets = computeBuckets({
      range: { fromMs: 60_001, toMs: 60_001 + 3 * 60_000 },
      granularity: 'minute',
      filters: {},
    });
    expect(buckets[0].start).toBe(60_000);
    expect(buckets).toHaveLength(3);
  });

  it('produces 24 buckets for a 24-hour day at hourly granularity', () => {
    const buckets = computeBuckets({
      range: { fromMs: 0, toMs: 24 * 60 * 60_000 },
      granularity: 'hour',
      filters: {},
    });
    expect(buckets).toHaveLength(24);
  });
});

describe('URL round-trip', () => {
  it('preserves range, granularity, and filters', () => {
    const original = {
      range: { fromMs: 1, toMs: 2 },
      granularity: 'hour' as const,
      filters: { region: 'us', tier: 'pro' },
    };
    const params = queryToSearchParams(original);
    const decoded = searchParamsToQuery(params);
    expect(decoded).toEqual(original);
  });
});

Interviewer Signal

Shows whether you can turn a broad product surface into a durable frontend architecture with clear contracts.

Constraints

  • Spend the first five minutes on requirements and non-goals.
  • Name client, server, cache, and URL state separately.
  • Include accessibility, performance, and observability before the end.

Model Answer Shape

  • Clarify users, scale, latency, collaboration, offline, and device constraints.
  • Draw the route/component/data-flow shape before diving into component props.
  • Choose explicit boundaries for API clients, cache, local state, design-system primitives, and tests.

Tradeoffs

  • Generic primitives increase reuse but require stronger documentation and ownership.
  • Client-side richness improves speed after load but can raise hydration and bundle costs.
  • Real-time updates help freshness but complicate ordering, backpressure, and recovery.

Edge Cases

  • Slow network and partial data.
  • Permission changes while the user is on the page.
  • Large datasets, long sessions, and stale caches.

Testing And Proof

  • Contract tests for API adapters.
  • Interaction tests for critical workflows.
  • Performance budget and E2E scenario for the most important path.

Follow-Ups

  • How would you roll this out safely to 1% of users?
  • What would become a shared platform primitive after the second product adopted it?