cf.completefrontend
My progress
SYSTEM DESIGN · #112

Design an analytics dashboard

Build a dashboard of 20 or more charts and tables over time series data, with global filters, date ranges, drill-down, and live updates, where the hard part is fetching and drawing a lot of data fast without one slow or broken widget ruining the page.

The question

Design the frontend of an analytics dashboard, like Google Analytics, Datadog, or the Stripe Dashboard. A user opens a dashboard and sees many charts and tables: page views over time, revenue by country, error rates, top pages.

They can pick a date range, add filters that apply to every widget, click a bar to drill down, and rearrange the widgets. Some dashboards show live data. How would you build it so it stays fast and correct?

Real-life examples: Google Analytics, Datadog dashboards, Grafana, Mixpanel, Stripe Dashboard

Requirements exploration

"Analytics dashboard" covers a five-chart page for a small shop and a 40-panel operations screen that refreshes every 10 seconds for an on-call engineer. Those two need very different designs, so the first five minutes go to questions.

A widget in this guide is one tile on the dashboard: a line chart, a bar chart, a big number (a "stat"), a table, or a map. Each widget has its own query and its own settings. A dashboard is a named, saved arrangement of widgets plus the filters that apply to all of them.

Questions that change the design

QuestionAnswer we assumeWhat it changes
How many widgets on one dashboard?Typically 10 to 30, up to 60Fetching strategy, lazy rendering, how many charts are alive at once
How many points can one chart have?Raw data can be millions of events; a chart shows at most a few thousand pointsServer-side aggregation, downsampling, SVG or Canvas
Can users build and edit dashboards?Yes: add, remove, drag, resize widgets; save; shareDashboard config model, versioning, edit mode
Do filters apply to every widget?Yes: a global date range and filters like country or plan, plus per-widget overridesFilter state, URL design, query keys
Is the data live?Some dashboards auto-refresh every 10 s to 5 min; ops dashboards streamPolling or streaming, pausing in hidden tabs
Who reads it, and where?Desktop browsers mostly; phones for a quick glance; wall screens for opsResponsive grid, read-only mobile layout
Which time zone is "today"?The dashboard's or the user's chosen zone, not the browser's by defaultDate range resolution, query cache keys

Functional requirements

  • View a dashboard: a grid of widgets, each showing a chart, a number, or a table.
  • Pick a date range (absolute, or relative like "last 7 days") and compare against a previous period.
  • Add global filters (country is India, plan is Pro) that every widget respects.
  • Drill down: click a bar or a slice to filter the other widgets by that value, and undo it from a breadcrumb.
  • Hover a chart to see exact values, with the crosshair synced across all time charts.
  • Edit mode: add, remove, move, and resize widgets; change a widget's query; save and share.
  • Auto-refresh at a chosen interval; some widgets stream new points.
  • Export a widget as CSV or PNG, and schedule an emailed report.

Non-functional requirements

  • First useful paint: the dashboard frame and the first visible widgets in under 2.5 s on a good laptop (the Largest Contentful Paint budget).
  • Changing a filter shows new numbers in under 1 s for typical queries, with old data kept on screen (dimmed) while the new data loads.
  • Hover and crosshair at 60 frames per second: about 16 ms per frame for everything, so each chart gets a few milliseconds.
  • One failing or slow widget never blocks or breaks the others.
  • A chart never receives more than a few thousand points; the payload for a whole dashboard stays in the hundreds of KB, not tens of MB.
  • Accessible: every chart has a text alternative and a data table; nothing relies on color alone.
  • Numbers, dates, and currencies formatted for the user's locale; layout works right to left.

Check your understanding · Exercise 1

Which answers from the interviewer would change your design the most? Select all that apply.

What makes this hard

Before picking any library, name the forces. Five of them drive every later decision.

  1. Many independent things on one page. 20 widgets means 20 queries, 20 loading states, and 20 chances to fail. They share filters, but each is slow or fast, empty or full, on its own.
  2. Too much data. A year at one-second resolution is 31.5 million points; a chart 800 pixels wide has about 800 x positions. Everything between the database and the pixels is about shrinking data without lying.
  3. Time is slippery. "Today" in Mumbai and in San Francisco are different windows, "last 7 days" moves every minute, and daylight saving makes some days 23 or 25 hours long. Get it wrong and two people see different numbers on the same link.
  4. Everything is connected. One range change re-runs every query, one click filters every other widget, and one hover moves every crosshair. A naive design turns each into a storm of requests or re-renders.
  5. The config is user data. Users build dashboards, so layout and queries live in a saved JSON document that must be versioned, validated, shared, and rendered by code written months later.
Wireframe of the dashboard. A toolbar holds the date range, Last 7 days, a comparison toggle, versus previous, a refresh control set to 1 minute, and an Edit button. Below, a filter bar holds two chips, country IN and plan Pro, and a breadcrumb, All, then Device mobile. The grid has three stat widgets in the first row: Revenue 15.9K up 12 percent, Orders 1,204, and Conversion 3.1 percent. The second row has a wide line chart of revenue by day with a dashed comparison line and a crosshair, and a Top pages table with pricing, docs, and blog.Revenue overviewLast 7 daysvs previousRefresh 1mEditcountry: INplan: ProAll / Device: mobileRevenue15.9K, +12%Orders1,204Conversion3.1%Revenue by dayTop pages/pricing 4.2K/docs 3.1K/blog 1.9K
Three bands: what time, what slice of the data, and the widgets. Every widget has a title, a body, and its own status, so one can be loading or broken while the rest work.

The wireframe shows the parts we'll design, top to bottom: a toolbar with the date range, comparison toggle, refresh control, and edit button; a filter bar with chips and a drill-down breadcrumb; then the grid of widgets. Each widget has a header (title, menu for export and "view as table"), a body (the chart), and its own status. The grid is 12 columns wide on desktop, so a widget's width is a number from 1 to 12.

Think first

A product manager says: "Just fetch all the data once when the page loads, then let the charts filter it in the browser." When does that work, and when does it fall apart?

The dashboard model: config, grid, versions

The naive approach makes each dashboard a React page: RevenueDashboard.tsx with six chart components and their fetch calls. It ships fast, until users ask to add a chart, move one, or build their own, and each request becomes a code change and a deploy.

With 5,000 customer-built dashboards, that's impossible. The dashboard has to be data.

Decision How should a dashboard be described?

Code per dashboard

Each dashboard is a component with hard-coded charts and queries.

Pros

  • Full freedom per page
  • Type-checked, easy to start

Cons

  • Users can't edit or create dashboards
  • Every change is a deploy
  • No sharing or versions

JSON config plus a widget registry

A dashboard is a JSON document: a list of widgets, each with a type, a query, a grid position, and display settings.

The app has a registry mapping each widget type (line, bar, stat, table) to a lazily loaded component.

Pros

  • Users edit dashboards; the server stores them like any document
  • One renderer for every dashboard
  • Easy to version, diff, copy, and share

Cons

  • Needs a schema and migrations as the format grows
  • Less freedom than raw code

Embedded scripting

Widgets contain user code (JavaScript or a template language) that fetches and draws.

Pros

  • Maximum flexibility for power users

Cons

  • Security risk: user code in your origin is an XSS hole unless sandboxed in an iframe
  • Hard to optimize or batch

What we pick: JSON config plus a registry. It is the model Grafana, Datadog, and most BI tools use: Grafana even lets you view and edit the dashboard's JSON directly. Keep queries declarative (metric, aggregation, group by, filters) so the client can batch, cache, and rewrite them for drill-down. The config-driven-ui lesson covers the general pattern.

What the config looks like

ts
type Dashboard = {
  id: string;
  schemaVersion: 4;          // format version, for migrations
  revision: number;          // bumps on every save, for conflicts
  title: string;
  defaults: { range: RangeSpec; timeZone: string; filters: Filter[] };
  widgets: Widget[];
};

type Widget = {
  id: string;                // stable, never reused
  type: "line" | "bar" | "stat" | "table" | "pie";
  title: string;
  query: Query;              // declarative, see the API chapter
  layout: { x: number; y: number; w: number; h: number }; // 12-col grid
  display: { unit?: "currency" | "percent" | "bytes"; stacked?: boolean };
  overrides?: { range?: RangeSpec; ignoreGlobalFilters?: string[] };
};
A dashboard is a document. Widgets don't fetch; they declare a query.
The dashboard config as a document. The Dashboard record has schema version 4 and revision 17, and holds defaults: range, time zone, and filters. It holds three widgets: w1, a line chart; w2, a bar chart; and w3, a stat. Each widget has a query and a layout. Widget w1's query is the sum of revenue by day and country. A widget registry maps the types line, bar, and stat to lazily imported components.Dashboardschema 4, rev 17defaultsrange, tz, filtersw1: linequery + layoutw2: barquery + layoutw3: statquery + layoutsum(revenue)by day, countryWidget registryline, bar, stat: lazy importsmigrations on loadsave sends revision
The document says what to show, never how to fetch it. The registry turns each type into code, loaded only when a widget of that type exists.

Two details matter. First, the widget never says "fetch this URL"; it says "sum of revenue by day, grouped by country". The data layer decides how and when to fetch, which is what lets it merge the global filters, dedupe, and batch.

Second, overrides lets one widget opt out: a "this month vs last month" stat may ignore the global range. Its header should then show a small badge saying so, or users will think it's broken.

The editable grid

Layout uses a grid of 12 columns and fixed-height rows (say 40 px). Each widget's layout is x, y (in grid units), w, and h. This is the model of react-grid-layout, the library behind many dashboards: it handles drag, resize handles, and compaction, which means floating widgets up to fill gaps, like Tetris pieces settling. Gridstack.js is a framework-free alternative with the same model.

  • Store layout in grid units, never pixels. The same config then works on a 1280 px laptop and a 2560 px wall screen.
  • Store a layout per breakpoint, or derive small screens. A common rule: below 768 px, stack every widget full width in reading order (sort by y, then x). Derived layouts mean users don't have to design a phone layout.
  • Drag only in edit mode. In view mode, a stray drag while selecting text in a table must not move widgets. Edit mode also shows resize handles and a "done" button.
  • Use transform for the moving widget, not top and left, so the drag runs on the compositor. The kanban-board guide covers drag mechanics, keyboard moves, and announcements in depth; the same rules apply here, with arrow keys moving a widget one grid cell.
  • Debounce chart redraws during a resize. While a user drags a resize handle, the chart can stretch with CSS; redraw once when the resize ends or at most every 100 ms.

Versions, conflicts, and sharing

Two kinds of version live in the config. schemaVersion is the format: when we rename display.unit or add a field, old documents still exist. The client (or server) runs migrations on load: small pure functions v3 to v4 applied in order, so code only ever handles the latest shape. revision is the content version: it lets the server reject a stale save.

Concurrent edits are rare but real: two teammates edit the "Q3 revenue" dashboard. Real-time co-editing with CRDTs (conflict-free replicated data types) is overkill for a dashboard. Use optimistic concurrency: the save sends revision: 17; if the server is already at 18, it returns 409 Conflict with the newer document, and the client shows "Priya saved a newer version. Reload, or save yours as a copy?" Keep a server-side history of saved revisions so any save can be restored.

  • Share by link: the URL holds the dashboard id plus the current filters and range, so the receiver sees what the sender saw (next chapter).
  • Permissions: view, edit, owner. Checked on the server; the client only hides the edit button.
  • Embed and public links: a signed, expiring token scoped to one dashboard and read-only queries. Never embed a normal session.
  • Copy: duplicate the JSON with new widget ids; it's the cheapest way to let people experiment without touching a shared dashboard.

Check your understanding · Exercise 2

You're adding the dashboard save flow. Which choices are sound? Select all that apply.

Global filters, date ranges, and time zones

The naive version keeps the range in component state as two Date objects: start = startOfDay(now - 7 days) in the browser's zone. Three bugs follow. First, Priya in Mumbai shares the dashboard with Sam in San Francisco, where "today" starts 12 h 30 min later in October, so their totals differ.

Second, Sam refreshes and loses the filters, because they were never in the URL. Third, every widget computes now on its own a few milliseconds apart, so no two cache keys ever match.

Decision How do we store the selected range?

Absolute timestamps

Store from and to as ISO instants, like 2026-10-14T00:00:00Z.

Pros

  • Exact and reproducible: a link always shows the same window

Cons

  • "Last 7 days" stops moving: tomorrow's visit shows an old window
  • Auto-refresh can't slide forward

Relative spec, resolved per refresh

Store an expression: now-7d to now, or now/d (start of today) to now.

Resolve it to instants once per refresh tick, in the dashboard's time zone, and share that one result with every widget.

Pros

  • Keeps sliding, so live dashboards work
  • Short, readable URLs
  • One now for all widgets: consistent numbers and cache keys

Cons

  • Needs a small parser and a zone-aware date library
  • Two people opening the link an hour apart see different windows (usually what they want)

Let the server resolve

Send range=last_7_days and let each query resolve it on the server.

Pros

  • No date math in the client

Cons

  • Each widget's request may resolve now differently
  • The client can't label axes or the picker without asking

What we pick: Store a relative spec and support absolute ranges too (a user who drags to zoom gets an absolute window). Resolve on the client once per tick so every widget shares the same instants. This is the model Grafana uses with its now-7d syntax, and it fits the url-as-state lesson: the URL is the source of truth for what you're looking at.

Resolving a range

ts
type RangeSpec = { from: string; to: string }; // "now-7d/d", "now", or ISO

// One call per refresh tick, shared by every widget.
function resolveRange(spec: RangeSpec, tz: string, now: Date, bucketMs: number) {
  const from = parseExpr(spec.from, tz, now); // zone-aware: "/d" = start of day in tz
  const to = parseExpr(spec.to, tz, now);
  // Snap "to" up to the bucket edge: requests made seconds apart get one key.
  const snappedTo = Math.ceil(to.getTime() / bucketMs) * bucketMs;
  return { from: from.toISOString(), to: new Date(snappedTo).toISOString(), tz };
}
Snapping to the bucket edge is what makes caching possible for relative ranges.

Snapping matters more than it looks. With a 1-minute bucket, every request in the same minute has the same to, so the cache can serve the second widget that asks for "page views, last 7 days". Without snapping, to differs by milliseconds and the cache hit rate is zero.

Today in two time zones on one UTC time axis running from 12:00 on 20 October to 12:00 on 22 October. In Asia/Kolkata, today runs from 18:30 UTC on 20 October to 18:30 UTC on 21 October. In America/Los_Angeles, during daylight saving, today runs from 07:00 UTC on 21 October to 07:00 UTC on 22 October. The two windows overlap for only 11.5 hours.Asia/Kolkata: 18:30 to 18:30 UTCMumbai todaySan Francisco today11.5 h overlapAmerica/Los_Angeles: 07:00 to 07:00 UTC12:00, 20 Oct12:00, 21 Oct12:00, 22 Octtime in UTC
Same word, two different 24-hour windows. Put the zone in the URL as an IANA name like Asia/Kolkata and resolve "today" in it, or the two people see different totals.
  • The zone is a dashboard setting, defaulting to the organization's zone, with a per-user override. It goes in the URL as an IANA time zone name (from the standard tz database) like Asia/Kolkata, never as an offset like +05:30, because offsets change with daylight saving.
  • The server buckets in that zone. A "day" bucket in America/New_York on a daylight saving switch day is 23 or 25 hours long. If the server buckets by UTC days, the bars won't line up with what the user calls a day. Send tz with every query.
  • Format axis labels in the same zone with Intl.DateTimeFormat and its timeZone option, not the browser's zone.
  • Zone-aware math needs help today. Date only knows UTC and the local zone. The Temporal API handles zones properly and has shipped in Chrome and Firefox, but not yet in Safari, so most apps still use date-fns-tz, Luxon, or a Temporal polyfill. The date-picker guide covers zone-aware picking in depth.

Comparison periods

"Compare to previous period" shifts the window back by its own length: last 7 days compared with the 7 days before. "Previous year" is trickier: shifting by 365 days lines a Monday up with a Sunday, so weekly patterns look like a drop. Shift by 364 days (52 weeks) to keep weekdays aligned, and say so in the tooltip.

The comparison series is drawn on the same x axis by offsetting its timestamps, usually as a dashed or lighter line. The stat widget shows the change as +12.4% with an arrow and a word, not only red or green.

Filters in the URL

text
/d/rev-q3?from=now-7d%2Fd&to=now&tz=Asia%2FKolkata&cmp=prev
         &f=country:eq:IN&f=plan:in:pro,team&drill=w12.device:eq:mobile
Everything needed to reproduce the view: range, zone, comparison, filters, drill-down.
  • Use history.pushState for committed changes (picking a range, adding a filter) so Back undoes them; use replaceState for in-between states like typing in a filter box.
  • Parse and validate on load: unknown fields are dropped, not crashed on. A link from an old version must still open.
  • Keep the URL short: if filters grow past roughly 2,000 characters, store them server-side and put a short id in the URL.
  • Filters that equal the dashboard defaults are left out of the URL, so the plain link stays clean.

Check your understanding · Exercise 3

A user in Mumbai shares a "Today" link with a colleague in San Francisco. What makes both see the same numbers? Select all that apply.

Fetching data for 20 widgets

The naive version: each widget runs useEffect(() => fetch(...), [filters]). A user clicks "Last 30 days", then quickly "Last 90 days". Twenty widgets fire 20 requests, then 20 more, while the first batch keeps burning server time nobody will look at.

Some 30-day answers arrive after the 90-day ones and overwrite them, so a chart shows the wrong range with the right label. Two widgets asking the same question (a revenue stat and a table footer) fetch it twice. And every change blanks all 20 charts to spinners. That's four problems: request count, stale responses (races), duplicate work, and flicker.

Decision How do widgets get their data?

One request per widget

Each widget's query becomes its own POST /api/query, keyed in a server-state cache.

Pros

  • Simple; each widget succeeds, fails, and caches on its own
  • Fast widgets show first
  • Over HTTP/2 or HTTP/3, 20 requests share one connection

Cons

  • 20 request headers, 20 auth checks
  • The server can't share work between similar queries

One request for the whole dashboard

Send every widget query in one call, get one JSON response back.

Pros

  • One round trip
  • The server can plan all queries together and share scans

Cons

  • The slowest query holds back every widget
  • One failure can fail the whole response
  • Caching is all or nothing: change one widget's query and you refetch all

Per-widget queries, batched transport, streamed results

Widgets still own a query key each. A small batcher collects the queries requested in the same 10 ms window and sends them in one POST /api/query/batch.

The server streams results back as each finishes (one JSON object per line), and the batcher resolves each widget's promise as its line arrives.

Pros

  • Per-widget caching, errors, and cancelling
  • Few round trips
  • Fast widgets still show first

Cons

  • More moving parts: batcher, streaming parser, per-item cancel

What we pick: Start with one request per widget over HTTP/2, in a server-state cache: simple, and good enough for 10 to 20 widgets. Move to the batched, streamed transport when dashboards reach 30 or more widgets or per-request auth is costly. Widgets only see a query key and a promise, so the switch doesn't touch widget code. Grafana's /api/ds/query endpoint, for example, accepts several queries in one request.

The server-state cache does the heavy lifting

A library like TanStack Query or SWR keeps a cache of query results by key, shares one in-flight request between everyone asking for the same key, and refetches in the background. The server-state-libraries lesson covers them in general. For a dashboard, the key design is everything:

ts
function useWidgetData(widget: Widget, ctx: DashboardContext) {
  // Merge global filters, drill-down, and widget overrides into one query.
  const query = buildQuery(widget.query, ctx); // includes resolved range + tz
  return useQuery({
    queryKey: ["series", canonical(query)],    // sorted keys: same question, same key
    queryFn: ({ signal }) => api.query(query, { signal }),
    placeholderData: keepPreviousData,         // keep old chart while loading
    staleTime: ctx.refreshMs ?? 60_000,
  });
}
The key is the canonical query, not the widget id. Two widgets asking the same question share one request.
  • Deduplication: the key is a canonical form of the query (object keys sorted, filters sorted, range already resolved and snapped). The stat and the table footer that both want total revenue produce the same key and share one request. The request-deduplication lesson covers the pattern.
  • Stale-while-revalidate: keepPreviousData keeps the old chart on screen, dimmed, while the new range loads, instead of 20 spinners. Going back to a range you saw a minute ago is instant from cache, then refreshed in the background.
  • Cancelling: TanStack Query passes an AbortSignal to the query function. If you hand it to fetch, the library aborts the request once no component observes the old key; if you ignore it, the request runs to the end and its result is cached. The server should also stop the database query when the client disconnects. The request-races lesson covers why aborting matters even when late answers are ignored.
  • Ignoring late answers: because results are stored by key, a late answer for the 30-day key lands in the 30-day cache slot. It can never overwrite the 90-day chart. Keying by query is what kills the race.
Range

All six widgets show cached data for the last 7 days.

Filter lab. Range 7d. Revenue (stat): key sum rev 7d, ready. Revenue by day: key rev/day 7d, ready. Orders (stat): key count ord 7d, ready. By country (bar): key rev/ctry 7d, ready. Table total: key sum rev 7d, ready. This month: key sum rev month, ready. In flight: 0. Totals: 0 sent, 0 cache hits, 0 deduped, 0 cancelled.widgetquery keythis changeRevenue (stat)sum rev 7dreadyRevenue by dayrev/day 7dreadyOrders (stat)count ord 7dreadyBy country (bar)rev/ctry 7dreadyTable totalsum rev 7dreadyThis monthsum rev monthreadyin flight 0, sent 0, cache hits 0, deduped 0, cancelled 0
All six widgets show cached data for the last 7 days.

Try the lab. Change the range twice quickly and watch the in-flight requests get cancelled. Switch back to a range you already loaded and see cache hits. Click a bar to cross-filter and notice the clicked widget itself doesn't refetch, and two widgets with the same query share one request.

Sequence diagram of a range change. The user picks Last 90 days. URL state gives the query client new keys for 20 widgets. The query client aborts the 30-day requests still in flight, then sends 18 queries, because two pairs of widgets share a key. It tells the widgets to keep their old charts, dimmed. The query API returns 17 results and one TIMEOUT error. The client renders 19 charts, because the shared keys serve two widgets each, and one widget with a Retry button.UserURL stateQuery clientQuery APILast 90 days20 new keysabort 30-day18 queriesold charts, dimmed17 results1 TIMEOUT19 charts, 1 Retry
The failure path stays local: the timed-out widget shows Retry while 19 charts render from 17 results. Aborting first means the server stops work nobody will see.

Which widgets fetch first

A 40-widget dashboard shows about 8 to 12 widgets above the fold on a laptop. Fetch those first and the rest as they near the viewport (the performance chapter shows how). That cuts the first load from 40 queries to about 12, which matters when 200 people open the Monday dashboard at 9:00.

Check your understanding · Exercise 4

A user clicks "Last 30 days" then "Last 90 days" within 300 ms. With per-widget query keys in TanStack Query and signal passed to fetch, what happens? Select all that apply.

Never ship a million points: aggregation and rollups

A busy site records 2,000 page views per second, about 63 billion events a year. Even a per-second count is 31.5 million numbers: hundreds of MB as JSON, and seconds of frozen tab to draw. The chart is maybe 900 pixels wide, so the real question is which 900 or so numbers are worth drawing, and who computes them.

Decision Where does the data get small?

Ship raw rows, aggregate in the browser

The API returns events; JavaScript groups them by day.

Pros

  • Flexible: re-group without a request

Cons

  • Breaks past tens of thousands of rows
  • Leaks rows the user maybe shouldn't see
  • Slow on phones

Server aggregates into time buckets

The query says interval: 1h; the server returns one value per hour per series.

Pros

  • Payload sized to the chart
  • Uses the database's fast group-by
  • Same numbers for every client

Cons

  • Changing the bucket size needs a new request

Server buckets from precomputed rollups

A pipeline keeps summary tables at 1 minute, 1 hour, and 1 day. Engines offer this in different forms: ClickHouse materialized views that write into aggregating tables, Druid's rollup at ingestion, Pinot's star-tree index.

The query engine reads the coarsest rollup that still fits the asked interval.

Pros

  • A year at 1-day buckets reads 365 rows, not billions
  • Fast and cheap for long ranges

Cons

  • Storage and pipeline cost
  • Only aggregations that can be merged (see below)

What we pick: The server always aggregates into buckets, and long ranges read rollups. The client's part of the deal is to send a sensible interval (or a maxPoints and let the server pick) and never ask for raw rows in a chart. Raw rows only appear in a paginated table, and the data-table guide covers that.

Picking the bucket size

ts
const NICE = [10e3, 60e3, 5 * 60e3, 15 * 60e3, 3600e3, 6 * 3600e3, 86400e3, 7 * 86400e3];

// Aim for at most one point per 2 pixels of chart width.
function pickInterval(rangeMs: number, widthPx: number) {
  const target = Math.max(50, Math.min(1000, Math.floor(widthPx / 2)));
  const raw = rangeMs / target;
  return NICE.find((n) => n >= raw) ?? NICE[NICE.length - 1];
}
Nice intervals (1 minute, 1 hour, 1 day) also map onto rollup tables and make readable axes.
Every range lands between about 100 and 400 points per series, so payload and draw time stay flat.
RangeInterval (900 px chart)Points per seriesRollup read
Last 1 hour10 s360raw or 10 s
Last 24 hours5 min2881 min
Last 7 days1 hour1681 hour
Last 30 days6 hours1201 hour
Last 365 days1 day3651 day

Use the real chart width, measured with ResizeObserver, rounded to a few width classes (say 400, 800, 1600 px) so resizing by 3 pixels doesn't create a new cache key. In edit mode, a widget stretched from 4 to 12 columns may ask for a finer interval once the resize ends.

Rollups. Raw events, billions of them, feed three summary tables: a 1 minute rollup with 525,600 rows per series per year, a 1 hour rollup with 8,760, and a 1 day rollup with 365. A Last 24 hours query at 5 minute buckets reads the 1 minute rollup. A Last 7 days query at 1 hour buckets reads the 1 hour rollup. A Last year query at 1 day buckets reads the 1 day rollup.raw eventsbillions1 min rollup525,600 rows/yr1 hour rollup8,760 rows/yr1 day rollup365 rows/yrLast 24 hours5 min bucketsLast 7 days1 hour bucketsLast year1 day bucketspipeline
A year at day buckets reads 365 rows per series instead of scanning billions of events. Each query picks the coarsest rollup that's still fine enough for its interval.

Not every number can be rolled up

  • Sums and counts merge: hourly page views add up to daily page views.
  • Averages merge only with their counts: store sum and count, divide at read time. The average of 24 hourly averages is wrong when traffic is uneven.
  • Distinct counts don't add: 1,000 unique users on Monday plus 1,000 on Tuesday is not 2,000 if many came both days. Rollups store a HyperLogLog sketch, a small structure (a few KB) that estimates distinct counts and can be merged, with an error around 1 to 2 percent.
  • Percentiles don't average: the p95 (95th percentile) of hourly p95 values is not the daily p95. Systems store mergeable quantile sketches instead; Datadog built DDSketch for this. If the UI says "p95 latency", the tooltip should say if it's an estimate.
  • Averages hide spikes: a 30-second outage averaged into a 1-hour bucket nearly vanishes. For ops metrics, return min and max per bucket too and draw them as a light band behind the line.

A compact response shape

json
{
  "queryId": "q_7f3a",
  "interval": 3600000,
  "timestamps": [1760400000000, 1760403600000, 1760407200000],
  "series": [
    { "key": "country=IN", "values": [1204, 1310, null] },
    { "key": "country=US", "values": [980, 1022, 1105] }
  ],
  "meta": { "partial": false, "sampled": false, "truncatedSeries": 0 }
}
Columnar: timestamps once, then arrays of numbers. null means no data, which is not the same as zero.

Columnar arrays are often about half the size of an array of objects, and they're what fast chart libraries like uPlot want as input, so no transform is needed. Three meta fields keep the UI honest: partial (the newest bucket is still filling, so draw it dashed), sampled (the engine sampled rows to answer fast, so show a small "estimated" badge), and truncatedSeries (a group-by with 4,000 countries returned the top 10, so the legend says "and 3,990 more").

Check your understanding · Exercise 5

Which statements about rollups are true? Select all that apply.

Drawing charts: SVG, Canvas, WebGL, and LTTB

Data arrives small (hundreds of points per series). But a dashboard can still draw a lot: 20 charts, each with up to 10 series, some zoomed into raw data with 50,000 points. The renderer decides how much that costs.

Decision What should a chart draw with?

SVG

Each bar, point, or line is a DOM element, styled with CSS.

Pros

  • Crisp at any zoom; easy hover and click on each element
  • Accessible: elements can carry aria-label and focus
  • Easy to export as an SVG file

Cons

  • Every element costs style, layout, and memory
  • Slows down past a few thousand elements per chart (an estimate; depends on device)

Canvas 2D

One bitmap; the chart redraws by calling lineTo and fillRect in JavaScript.

Pros

  • Handles tens of thousands of points per frame
  • One DOM node, low memory
  • Can draw in a worker with OffscreenCanvas (all major browsers; Safari since 16.4)

Cons

  • Hit testing is your job (find the nearest point yourself)
  • Nothing for screen readers: needs a separate text alternative
  • Must handle devicePixelRatio or it looks blurry

WebGL

The GPU draws points and lines from typed arrays in shaders.

Pros

  • Millions of points, smooth pan and zoom

Cons

  • Much more code; text and axes are awkward
  • Contexts are limited per page, so 20 WebGL charts is a problem
  • Rarely needed after server aggregation

What we pick: Pick by element count, per chart. SVG for bar charts, pies, and lines with up to roughly 1,000 to 5,000 points: most widgets. Canvas for dense time series, scatter plots, and heatmaps above that. WebGL only for a special widget (a scatter of a million sessions), never as the default. These thresholds are estimates; measure on a mid-range laptop and a phone.

Which renderer fits which point count, on a log scale from 10 to 10 million points per chart. SVG is comfortable from 10 up to a few thousand points. Canvas 2D from about a thousand up to several hundred thousand. WebGL from about a hundred thousand up to millions. The ranges overlap and are rough estimates.SVGCanvas 2DWebGLoverlaps: measure on real devices101001K10K100K1M10Mpoints per chart (log scale)
Rough estimates for a mid-range laptop, not hard limits. In the overlaps, measure: the shape of the chart, hover needs, and the device matter as much as the count.

Libraries and where each fits

Most dashboards use two: an SVG library for most widgets and a Canvas one for dense time series.
LibraryRenders withFits when
D3You choose (it gives scales, shapes, axes)Custom charts; often used for math only, with React drawing the SVG
RechartsSVG, React componentsFast to build standard charts with modest point counts
visxSVG, low-level React pieces built on D3A custom design system of charts with full control
EChartsCanvas by default, SVG optionalMany chart types out of the box, large datasets, built-in dataZoom
uPlotCanvasDense time series where speed and bundle size matter most

Chart libraries are heavy. Load each widget type's code only when a widget of that type is on the dashboard: the widget registry maps line to () => import("./LineWidget"). A dashboard with only stats and tables never downloads the charting library. The code-splitting lesson covers the mechanics.

LTTB: shrinking a line without losing its shape

Sometimes the client does hold too many points: a user zooms into an hour of raw data, or a streaming chart has accumulated 20,000 points. Taking every 20th point (naive sampling) is fast but can skip the one spike that matters. Averaging each group smooths it away. Largest-Triangle-Three-Buckets (LTTB), from Sveinn Steinarsson's 2013 thesis at the University of Iceland, keeps the shape.

  1. Always keep the first and last point.
  2. Split the points in between into target - 2 equal buckets.
  3. Walk the buckets left to right. For each one, look at three things: the point you kept in the previous bucket (A), each candidate point in this bucket (B), and the average point of the next bucket (C).
  4. Keep the candidate B that makes the triangle ABC largest. A spike makes a tall triangle, so spikes win.
ts
function lttb(xs: number[], ys: number[], target: number): number[] {
  const n = xs.length;
  if (target >= n || target < 3) return xs.map((_, i) => i);
  const keep = [0];
  const size = (n - 2) / (target - 2);
  let a = 0;
  for (let i = 0; i < target - 2; i++) {
    const start = Math.floor(i * size) + 1, end = Math.floor((i + 1) * size) + 1;
    const nEnd = Math.min(Math.floor((i + 2) * size) + 1, n);
    let cx = 0, cy = 0;
    for (let j = end; j < nEnd; j++) { cx += xs[j]; cy += ys[j]; }
    cx /= nEnd - end; cy /= nEnd - end;      // average of next bucket
    let best = start, max = -1;
    for (let j = start; j < end; j++) {
      const area = Math.abs((xs[a] - cx) * (ys[j] - ys[a]) - (xs[a] - xs[j]) * (cy - ys[a]));
      if (area > max) { max = area; best = j; }
    }
    keep.push(best); a = best;
  }
  keep.push(n - 1);
  return keep; // indexes of points to draw
}
One pass, O(n). In our test, 20,000 points to 800 took well under a millisecond on a laptop.
Raw points
Down to 60 by

5,000 points reduced to 60 with every nth point: spike lost: highest point drawn is 59, not 100. Rough draw cost with one element per point: SVG about 42 ms, Canvas about 2 ms.

Chart lab. 5,000 points reduced to 60 with every nth point: spike lost: highest point drawn is 59, not 100. Rough draw cost with one element per point: SVG about 42 ms, Canvas about 2 ms.5,000 points, drawn as 60 by every nth pointspike lostdashed: true shape (max per pixel column)solid: the 60 points a chart would drawSVG, one element per point: about 42 msCanvas, same points: about 2 ms (rough estimates)
5,000 points reduced to 60 with every nth point: spike lost: highest point drawn is 59, not 100. Rough draw cost with one element per point: SVG about 42 ms, Canvas about 2 ms.

In the lab, push the point count up and watch the estimated SVG cost climb far faster than Canvas. Then shrink the same series to 60 points three ways: every nth point skips the spike, bucket averages flatten it, LTTB keeps it.

LTTB is for drawing only: never compute totals or averages from downsampled points. For ops data where every extreme matters, min-max downsampling (keep the lowest and highest point per pixel column) is a common alternative.

Size, sharpness, and labels

  • Size from the container: a ResizeObserver on the widget body reports its size, which the grid decides. Never read offsetWidth during render, which forces layout.
  • Sharp Canvas: set canvas.width = cssWidth * devicePixelRatio, keep the CSS width as is, and scale the context. Without it, a Canvas chart is blurry on every 2x screen.
  • Nice ticks: pick 4 to 8 ticks at round values (0, 250, 500, 750) with a helper like D3's scale.ticks(), and fewer on narrow widgets.
  • Numbers with Intl: new Intl.NumberFormat(locale, { notation: "compact" }) turns 1,234,567 into "1.2M" in English; style: "currency" and style: "percent" cover money and rates. Create formatters once per locale, not once per tick label.
  • Dates on axes follow the interval: hours for a day view, "Mon 14" for a week, "Oct" for a year, all through Intl.DateTimeFormat with the dashboard's timeZone. The locale-formatting lesson covers Intl in depth.

Check your understanding · Exercise 6

Which rendering choices are sound? Select all that apply.

Synced crosshairs, drill-down, and cross-filtering

Dashboards are for answering "why": revenue dropped at 14:00, so what else happened then, in which country, on which device? Two interactions answer that: a crosshair synced across all time charts, and clicking a value to filter everything else by it.

A crosshair on every chart at once

Naive version: on pointermove, store hoverTime in a global store, and every chart reads it. Pointer moves arrive about once per display frame, 60 to 120 times a second, and each one re-renders 20 chart components through React, re-running their data transforms. On a mid-range laptop that can take 30 ms or more per move: the crosshair lags the mouse.

Decision How do charts share the hover position?

Global state, React re-render

hoverTime lives in a store; every chart subscribes and re-renders.

Pros

  • Simple and declarative

Cons

  • Re-renders 20 charts per pointer event
  • Blows the 16 ms frame budget

Pub/sub plus an overlay layer

A tiny event bus (the hover bus) carries { t, sourceId }. Each chart subscribes outside React and draws a vertical line and a dot on its own overlay (a separate Canvas or one SVG line) imperatively.

Messages are coalesced with requestAnimationFrame: at most one update per frame.

Pros

  • No React renders while hovering
  • The chart body isn't redrawn, only the overlay
  • Works across chart libraries

Cons

  • Imperative code to maintain
  • Each chart must map t to its own x pixel and nearest point

The library's built-in sync

uPlot has cursor sync groups; ECharts has echarts.connect for linked charts.

Pros

  • No code to write

Cons

  • Only works between charts of the same library

What we pick: Pub/sub plus an overlay, coalesced to one update per frame, with library sync used inside it where available. Sync by timestamp, not by pixel, because widgets have different widths. Charts whose range doesn't include t (a widget with a range override) just hide their crosshair. The tooltip appears only on the chart under the pointer; the others show the line and a dot, which is enough to compare.

Synced crosshairs. Three charts, Revenue, Errors, and Latency, sit side by side. The pointer moves over Revenue, which publishes time 14:00 to a hover bus at most once per frame. The bus tells Errors and Latency, which draw a vertical line at 14:00 on their overlay layer only. Revenue shows the tooltip. No React render happens while hovering.RevenueErrorsLatencyhover bust = 14:00, once a framepointermoveoverlay onlytooltip only on the hovered chart
Sync by timestamp, not by pixel: each chart maps t to its own x. Only the overlay redraws, so a hover costs well under a millisecond per chart.

Finding the nearest point under the pointer is a binary search on the sorted timestamps: about 10 steps for 1,000 points. For scatter plots, build a quadtree or a grid index once per data change. Never loop over all points on every move. The debounce-throttle lesson explains why requestAnimationFrame is the right throttle for anything visual.

Click to filter everything else

Two related ideas. Cross-filtering: click the "India" bar in "Revenue by country" and every other widget filters to India. Drill-down: go one level deeper in a hierarchy, so the same chart changes from countries to cities in India, or a click opens a detail view with raw events. Both add an entry to a filter stack that's shown as a breadcrumb.

ts
type DrillStep = {
  sourceWidgetId: string;  // who created it
  field: string;           // "country"
  op: "eq" | "in" | "between";
  value: string | string[] | [number, number];
  kind: "crossfilter" | "drilldown";
};

// The effective filters for one widget:
function filtersFor(widget: Widget, global: Filter[], steps: DrillStep[]) {
  const fromSteps = steps
    .filter((s) => !(s.kind === "crossfilter" && s.sourceWidgetId === widget.id))
    .map(({ field, op, value }) => ({ field, op, value }));
  return [...global, ...fromSteps].filter((f) => !widget.overrides?.ignoreGlobalFilters?.includes(f.field));
}
The source widget doesn't filter itself; it highlights the selection instead.
  • The source widget doesn't filter itself. If clicking "India" turned the country chart into a single bar, the user couldn't click "Brazil" next. It keeps all bars and dims the unselected ones, and its query key doesn't change, so it doesn't refetch.
  • Click replaces, Shift-click or Ctrl-click adds. A second click on India removes the step. Escape clears all cross-filters.
  • Brushing a time range (dragging across a line chart) is a cross-filter on time: it sets an absolute range for the whole dashboard, and Back returns to "Last 7 days".
  • The breadcrumb reads "All data, Country: India, Device: Mobile". Each crumb is a button; clicking one pops the steps after it. Each step also goes into the URL (drill= parameters) with pushState, so Back undoes a click.
  • Fields must match. A widget built on a data source without a country field can't apply the filter. Show a small "filter not applied" note on it instead of silently ignoring it.
Sequence diagram of a cross-filter. The user clicks the India bar in the country chart. The chart pushes a drill step, country equals India, into the URL. The URL state gives 9 other widgets new query keys. The chart itself dims the other bars and does not refetch. The query client sends 9 queries to the API, which returns 9 results, and 9 widgets re-render. Later the user presses Back: the URL pops the step, the old keys are still in cache, and the widgets show the unfiltered data at once.UserChartURLQueriesAPIclick Indiapush step9 new keysdim others9 queries9 resultsre-render 9Backcache hit
The source chart highlights instead of filtering itself. Because the step went in with pushState, Back undoes it, and the unfiltered results come straight from the cache.

Check your understanding · Exercise 7

A user clicks the "Mobile" slice in a device pie chart. Which behaviors are right? Select all that apply.

Keeping 20+ widgets fast

Put 40 widgets on a page, render all of them on load, and measure. If each chart's first render (scales, paths, axes, legend) takes 10 to 40 ms on a mid-range laptop, the main thread is busy for around a second in long tasks. Clicks wait, and Interaction to Next Paint (INP), which should stay under 200 ms, goes far past it.

Yet most of those 40 charts are below the fold, and nobody has looked at them. The runtime-performance lesson covers long tasks and INP in general; here's what's specific to dashboards.

Decision What happens to widgets the user can't see?

Render everything

Mount all widgets, fetch and draw all of them.

Pros

  • Simple; scrolling never shows a placeholder

Cons

  • Slow first load, many long tasks
  • Fetches data nobody looks at
  • Live updates redraw invisible charts

content-visibility: auto on each widget

The browser skips layout and paint for offscreen widgets; contain-intrinsic-size reserves their height.

Pros

  • One line of CSS
  • No JavaScript

Cons

  • React still renders them and their data still loads
  • Doesn't save script time, which is the main cost

Lazy mount with IntersectionObserver

Each grid cell renders a placeholder of the right size (known from the grid h). An observer with rootMargin of about one screen height mounts the real widget, which then fetches and draws.

Once mounted, a widget stays mounted, but pauses redraws and live updates while offscreen.

Pros

  • First load does about a quarter of the work on a 40-widget page
  • No layout shift: the grid already knows each cell's size

Cons

  • A fast scroll can show placeholders for a moment
  • Print and export must force-mount everything

What we pick: Lazy mount with IntersectionObserver, plus content-visibility: auto as a cheap extra for widgets that are mounted but offscreen. Unlike a list, the grid is small (dozens, not thousands), so we don't need to unmount and recycle like the virtualized-list guide does; keeping seen widgets mounted makes scrolling back instant.

Lazy widgets on a long dashboard. Six rows of two widgets. The first three rows are inside the viewport and are mounted and drawn. The fourth row is inside the root margin below the viewport and is loading. The last two rows are waiting: placeholders sized from the grid, with no fetch and no chart code run.readyreadyreadyreadyreadyreadyloadingloadingwaitingwaitingwaitingwaitingviewport: drawnroot margin: fetchingplaceholders, sized by h
An IntersectionObserver with a root margin of about one screen starts each widget just before it scrolls in. Placeholders take their height from the grid, so nothing shifts.

Keeping the main thread free

  • Memoize transforms on the data reference. Pivoting rows into series, stacking, and LTTB depend only on the response and the widget settings, so useMemo on those skips them when a parent re-renders.
  • Stable references from the cache. Server-state libraries keep the same object when refetched data is equal (TanStack Query calls this structural sharing), so memoized transforms and React.memo charts skip work after a refresh that changed nothing.
  • Spread the work. When a filter changes, up to 20 responses arrive close together. Wrap the filter change itself in startTransition so React can interrupt that render for a click. Results that land through the query cache's useSyncExternalStore subscription still render as blocking updates, so keep per-widget work small (memoized transforms) and draw heavy charts in a worker.
  • Avoid layout thrash. Reading getBoundingClientRect in one chart and writing styles in another, 20 times over, forces 20 layouts. Take sizes from ResizeObserver callbacks and write in requestAnimationFrame.
  • Freeze during drag. In edit mode, while a widget is dragged, other widgets only move with transform; no chart redraws until the drop.

Moving drawing off the main thread

For the heaviest Canvas widgets (a heatmap with 50,000 cells, a 20-series streaming chart), the drawing itself can move to a Web Worker. canvas.transferControlToOffscreen() hands the canvas to a worker as an OffscreenCanvas, supported for 2D drawing in Chrome, Edge, Firefox, and Safari 16.4 and later. The worker owns the data, runs LTTB, and draws, while the main thread only forwards pointer positions and size changes. A slow redraw then can't delay a click anywhere on the page.

ts
// main thread
const off = canvas.transferControlToOffscreen();
const worker = new Worker(new URL("./chart.worker.ts", import.meta.url), { type: "module" });
worker.postMessage({ type: "init", canvas: off, dpr: devicePixelRatio }, [off]);
worker.postMessage({ type: "data", ts, values }, [ts.buffer, values.buffer]); // typed arrays, moved not copied
new ResizeObserver(([e]) => worker.postMessage({ type: "resize", ...e.contentRect })).observe(canvas);

// chart.worker.ts
onmessage = ({ data }) => {
  if (data.type === "init") ctx = data.canvas.getContext("2d");
  if (data.type === "data") { series = lttbTyped(data.ts, data.values, width); draw(); }
};
Transfer the typed arrays so a 1 MB series moves in microseconds instead of being copied.

It's a trade: hover and tooltips now need a message round trip, and the code is harder to debug. Use a worker for the two or three heaviest widget types, not everything. The google-sheets guide uses the same idea for its canvas grid.

BudgetTargetHow we stay inside it
LCPunder 2.5 sShell and first 8 to 12 widgets only; chart code split by widget type
INPunder 200 msFilter changes in transitions, no long tasks over 50 ms, workers for heavy charts
CLSunder 0.1Grid cells sized from config before data arrives
Hover frameunder 16 ms totalOverlay-only redraw, one update per frame
JS for a stats-only dashboardunder about 150 KB gzippedChart libraries loaded only when used

Check your understanding · Exercise 8

A 40-widget dashboard feels sluggish. Which changes help? Select all that apply.

Live updates and auto-refresh

An ops dashboard on a wall screen refreshes every 10 seconds. Naively, that's every widget refetching its full range every 10 s: 30 widgets times 168 points, mostly identical to last time, forever, including in the 14 background tabs an engineer forgot to close. Multiply by 500 engineers and the query engine spends most of its time recomputing numbers nobody is looking at.

Decision How do new points reach the charts?

Full refetch on an interval

Every N seconds, re-run every widget's query for the whole range.

Pros

  • Simple, stateless, always correct
  • Works with any query

Cons

  • Recomputes the whole range each time
  • Load grows with every open tab

Incremental polling

Keep the series in memory. Every N seconds, ask only for buckets from the last complete one onward: from = lastBucket.

Replace the last (partial) bucket, append new ones, drop buckets that slid out of the range.

Pros

  • Tiny requests: a few buckets instead of hundreds
  • Plain HTTP, cacheable, works behind any proxy

Cons

  • Merge logic per series
  • Late-arriving events can change older buckets: do a full refetch every few minutes

Server push (server-sent events or WebSocket)

The client subscribes to a set of queries; the server pushes new bucket values as they close.

Pros

  • Lowest latency, seconds or less
  • No empty polls

Cons

  • The server must keep a subscription per query per tab
  • Reconnect and gap-filling logic
  • Overkill for refresh rates of 30 s and up

What we pick: Incremental polling for refresh intervals of 10 s and longer, which covers almost all dashboards, with a full refetch every 5 minutes to catch late data. Use server-sent events only for widgets that truly need second-level freshness, like a live request rate. Either way, the chart only appends: it never re-creates scales and axes for a new point.

Pause what nobody sees

  • Hidden tab: listen to visibilitychange and stop all timers when document.visibilityState is hidden. On return, refetch once right away, then resume. TanStack Query does this for refetchInterval unless you set refetchIntervalInBackground.
  • Offscreen widget: a mounted widget scrolled out of view skips its refresh; it refetches when it scrolls back in, if its data is stale.
  • Jitter: add a random 0 to 2 s offset to each tab's timer. Otherwise 300 wall screens that loaded at 9:00:00 all poll at 9:00:10, 9:00:20, and so on, in lockstep.
  • Back off on errors: after a failed refresh, wait 2x longer, up to a few minutes, and show "Last updated 3 min ago" on the widget instead of an error banner.
  • User control: a refresh menu with Off, 10 s, 1 min, 5 min, and a "refresh now" button. The interval goes in the URL (refresh=1m) so a wall screen link keeps it.
Auto-refresh every 10 seconds over two minutes. Polls run at 0, 10, 20, and 30 seconds. The tab is hidden from 40 to 90 seconds, so no polls run. At 90 seconds the tab is visible again and the dashboard refetches at once, then resumes at 100, 110, and 120 seconds. Each poll asks only for new buckets, with up to 2 seconds of random jitter per tab.tab hidden: no pollsrefetch at once0 s40 s90 s120 seach poll: only new bucketsplus 0 to 2 s jitter per tab
visibilitychange stops the clock while hidden and refetches once on return. Polls ask only for buckets newer than the last complete one.

For streaming charts, keep each series in a fixed-size ring buffer (typed arrays) and redraw at most once per animation frame. With a relative range like "last 1 hour", the window slides on each tick: the x scale shifts and the oldest bucket falls off the left edge.

Check your understanding · Exercise 9

A team leaves 300 wall screens open on a dashboard refreshing every 10 s. What reduces load without making the screens stale? Select all that apply.

Widget states, tables, and exports

With 20 independent queries, something is always loading, empty, or failing somewhere. The naive page has one isLoading and one error banner for everything: one slow query keeps the whole dashboard on a spinner, and one thrown exception in a chart's render takes down the React tree, leaving a white page. The fix is to treat every widget as its own small app with its own state machine and its own error boundary.

State machine for one widget. Waiting goes to loading when the widget comes near the view. Loading goes to ready when data arrives, or to error on a failure or a 15 second timeout. Error goes back to loading on Retry. Ready goes to refreshing when filters change or the refresh clock ticks, and refreshing returns to ready with new data. Empty and partial results are ready states with a message.waitingloadingreadyerrorrefreshingnear viewdatafail or 15 s timeoutRetryfilters or ticknew data: back to readyempty, partial:ready with a note
Each widget runs this machine on its own, so a timeout in one never touches the others. Refreshing keeps the old chart on screen; only the first load shows a skeleton.
StateWhat the user seesNotes
waitingA gray placeholder the size of the cellNot near the viewport yet; nothing fetched
loadingA skeleton in the chart's shapeFirst load only; after a 15 s timeout, an error with Retry
readyThe chartData in cache
refreshingThe old chart, dimmed, with a thin progress barFilters changed or auto-refresh; never blank it
empty"No data for this range" with the active filters listedZero rows is not an error; zero is not null
partialThe chart plus a badge: "2 of 5 series failed" or "estimated"From meta.partial, sampled, or per-series errors
errorA short message, the request id, and RetryOther widgets unaffected; old data kept if there was any
  • An error boundary per widget: a render crash in one chart (a library bug on an odd dataset) shows that widget's error state and reports it, while the other 19 keep working.
  • Errors that teach: "This query took longer than 30 s. Try a shorter range or a coarser interval" beats "Error 504". Map common error codes to messages with a next step.
  • Retry for one widget, not the page: the Retry button refetches that widget's query key only.
  • Stale is fine if labelled: if a refresh fails, keep the old chart and show "Updated 4 min ago" rather than replacing it with an error.

Table widgets

A "top pages" table widget usually shows 10 to 50 aggregated rows, sorted on the server. Expanded to all 40,000 pages, it becomes a full data table with server-side sorting, cursor pagination, and virtualized rows, which the data-table guide covers. The dashboard's job is to pass the same filters and drill-down steps into the table's query, and to let a row click act as a cross-filter.

Exporting

  • CSV of a widget: for what's on screen (a few hundred rows), build it in the browser from the cached response and download with a Blob and an object URL. For "all rows", ask the server to generate the file, because the client only has the aggregated, maybe truncated, result. Escape cells that start with =, +, -, or @ so a spreadsheet doesn't run them as formulas (CSV injection).
  • PNG of a chart: Canvas charts call canvas.toBlob(). SVG charts serialize with XMLSerializer, load that into an Image, and draw it on a canvas, after inlining computed styles and fonts. Add the title, range, and filters so the image makes sense in a slide deck.
  • SVG of a chart: the serialized SVG itself, for print-quality output.
  • Scheduled reports: "email this dashboard every Monday at 9:00" runs on the server. A job opens the dashboard URL in a headless browser and renders a PDF or PNG. The dashboard needs a print mode that mounts every widget without animations, and an "all widgets settled" signal the job can wait for.

Check your understanding · Exercise 10

One widget's query times out after the user changes the range. What should happen? Select all that apply.

Architecture, data model, and API

The guiding rule: widgets are dumb. A widget gets a config and a context (range, zone, filters, drill-down, locale) and returns pixels. Everything shared (time, query building, caching, batching, refresh timers, hover sync) lives in services below the widgets, so 20 widgets don't each reinvent it.

Architecture. In the browser: URL state and a refresh clock feed the time resolver, which resolves one now. The dashboard shell loads the config from the Dashboards service. The time resolver and the shell feed the query builder, which merges filters. The query builder gives keys to the query client, which caches, dedupes, and aborts, and calls the BFF, which queries the engine's rollups. Widget frames read from the query client, publish hover and clicks to the hover bus, and load chart code from the widget registry, which hands heavy charts to a chart worker.BrowserURL staterange, filtersRefresh clocktick, pauseShellconfig, editTime resolverone nowQuery buildermerge filtersHover bust, clicksWidget frameslazy, boundaryQuery clientcache, abortRegistrylazy widget typesChart workerOffscreenServerDashboardsBFFauth, limitsEnginerollups
Widgets sit at the edge: they only read keys from the query client and draw. Time, filters, caching, and timers each live in one shared place.
PartResponsibilityWhy the boundary is here
Dashboard shellLoads the config, runs migrations, renders toolbar, filter bar, and gridOne place owns the document and edit mode
URL stateParses and writes range, zone, filters, drill, refreshThe URL is the source of truth for the view; everything else derives from it
Time resolverTurns the range spec into instants once per tickOne shared now keeps widgets consistent and cacheable
Query builderMerges widget query, global filters, drill steps, overrides, intervalWidgets declare; one function decides the real query
Query clientCache, dedupe, stale-while-revalidate, abort, batchingNetwork policy in one place; widgets only see keys
Widget registryMaps type to a lazy componentChart libraries load only when needed
Widget frameError boundary, lazy mount, state UI, header menuFailure and loading handled the same for every type
Hover busHover time, cross-filter clicksFast, render-free signals between widgets
Refresh clockTimers, jitter, visibility pauseOne clock for the page instead of 20
Chart workersHeavy Canvas drawing and downsamplingKeep long work off the main thread

Reads follow one path: URL and config, then the time resolver and query builder, then the query client, which checks its cache before the network. Columnar series come back, get transformed (memoized), and are drawn. Writes go the other way: layout and query edits change an in-memory draft, and Save sends it with its revision.

Data flow. The URL and config give range, filters, and time zone to the query builder, which gives a query and its key to the query client. The query client sends POST /api/query to the BFF, which runs SQL on the engine's rollups. The reply, about 20 KB of columnar JSON, comes back up the right side into the query client's cache. The cache gives series to a memoized transform, which gives points to the chart. On writes, the chart's drag and resize change an edit draft, which is saved to the Dashboards API with PUT and its revision.URL + configQuery builderQuery clientBFFQuery enginerange, filters, tzquery + keyPOST /api/querySQLChartTransformmemoizedCacheabout 20 KB JSONseriespointsEdit draftDashboards APIPUT + revision
Requests go down the left; data comes back up the right. What crosses each arrow is small: a few filters going out, a few hundred numbers per series coming back.

Where each piece of data lives

Each piece of client data has one home. The table above already placed the two big ones: view state in the URL, query results in the server-state cache. Put the range in component state and sharing breaks; put results in a global store and you rebuild a cache library by hand.

DataLives inWhy
Dashboard config (widgets, layout, defaults)Query cache, plus a draft copy in a store during edit modeServer document; the draft holds unsaved edits
Resolved range and nowMemory, recomputed per tickDerived; never stored
Hover time, highlighted seriesHover bus and chart-local stateChanges 60 times a second; must not re-render React trees
Widget sizeResizeObserver in the frameComes from layout, not config
User preferences (zone, locale, theme)Server profile, cached in local storageFollows the user across devices

The one entity worth typing carefully is the query, because three things depend on its shape: the cache key (it must serialize the same way every time), the batcher (it must be plain data), and drill-down (the query builder must be able to add a filter to it). Keep it free of functions, dates as Date objects, and anything else that doesn't survive JSON.stringify.

ts
type Query = {
  source: "events" | "billing" | "apm";
  metrics: { field: string; agg: "count" | "sum" | "avg" | "p95" | "uniq" }[];
  groupBy?: string[];                 // ["country"]
  interval?: number | "auto";         // ms; "auto" = server picks from maxPoints
  maxPoints?: number;                 // from chart width
  filters: { field: string; op: "eq" | "in" | "between" | "contains"; value: unknown }[];
  range: { from: string; to: string; tz: string };
  compare?: "previous_period" | "previous_year";
  limit?: number;                     // top N series
};
Declarative and serializable, so it can be hashed for a cache key, batched, and stored in the config.

Endpoints

http
GET  /api/dashboards/rev-q3              -> Dashboard (with revision, ETag)
PUT  /api/dashboards/rev-q3              { ...dashboard, revision: 17 }
     -> 200 { revision: 18 } | 409 { latest: Dashboard }

POST /api/query                          { query: Query }
     -> 200 SeriesResult | 400 { code: "RANGE_TOO_LARGE", maxDays: 400 }

POST /api/query/batch                    { queries: { id, query }[] }
     -> 200 application/x-ndjson, one line per finished query:
        { "id": "w3", "result": SeriesResult }
        { "id": "w7", "error": { "code": "TIMEOUT", "requestId": "r_91c" } }

POST /api/exports                        { widgetId, format: "csv", query }
     -> 202 { exportId }  (poll or get an email when ready)
The batch endpoint streams one line per query, so a slow widget never holds back a fast one.
  • Errors carry a code and a request id. The widget maps the code to a helpful message; the request id goes in the error UI and the error report, so support can find the server log.
  • Limits are enforced on the server (max range, max series, max points) and returned as codes the UI can explain. The client also applies them, but only for speed.
  • Caching headers for history. A range that ended days ago rarely changes, so the server can send Cache-Control: private, max-age=300. Live ranges get no-store and rely on the client cache.
  • Per-item cancel in batches. Aborting the batch fetch cancels every widget in it, so send POST /api/query/cancel with the ids, or keep batches small.

Accessible charts and global users

A Canvas chart is a picture. To a screen reader it's nothing at all; to a user with red-green color blindness, a red and a green line can look the same; to a keyboard user, a tooltip that only appears on hover doesn't exist. Accessibility for dashboards is mostly about giving every chart a second, non-visual form, and not hiding meaning in color.

Text alternatives and the table behind every chart

  • A summary label: wrap the chart in a figure with a figcaption, or give the chart container role="img" and an aria-label built from the data: "Revenue, last 7 days: rose from 12,400 to 15,900 dollars, peak 17,200 on Thursday." Generate it from the series; don't make authors write it.
  • View as table: each widget menu has "Show data as table", which renders a real table with headers, the same numbers, and the same formatting. It's also the best fallback for anyone who wants exact values.
  • Status changes announced politely: when a filter changes, one aria-live="polite" region says "Dashboard updated: 18 widgets, 2 with errors", not 20 separate announcements.

Keyboard exploration

Each chart is one Tab stop, so the keyboard user isn't forced through 168 points per chart. Inside a focused chart, Left and Right move between points (or bars), Up and Down switch series, Home and End jump to the ends, and Enter applies the cross-filter, the same action as a click. The focused point shows the tooltip and crosshair, and a live region reads the value: "Thursday 16 October, India, 4,210 dollars, 3 of 7".

With an SVG chart, points can be real focusable elements; with Canvas, the chart keeps an index of the active point and draws a focus ring itself. Highcharts' accessibility module is a good model to study: it uses the same arrow-key scheme, adds a hidden screen reader section before the chart, and offers a "view as data table" button.

Not color alone

  • Direct labels at the end of each line beat a color legend; legends also show the line style. WCAG 1.4.1 (Use of Color) requires that color is never the only way information is shown.
  • Use dash patterns and marker shapes for series, and textures for bar or area fills, so print and grayscale still work.
  • Lines, bars, and axes need at least 3:1 contrast against the background (WCAG 1.4.11, Non-text Contrast); text needs 4.5:1.
  • Up and down changes show an arrow and a sign (+12.4%), not only green and red.
  • Respect forced-colors: active (Windows high contrast): draw with system colors like CanvasText so lines don't vanish, and respect prefers-reduced-motion by turning off chart animations.

The visual-accessibility lesson covers contrast and color blindness in depth, and the data-table guide covers accessible tables.

Numbers, dates, money, and direction

  • Format everything through Intl with the user's locale: 1,234.5 in en-US is 1.234,5 in de-DE, and ar-EG uses Arabic-Indic digits and separators. Grouping and compact notation are localized too: in en-IN, 1234567 groups as 12,34,567, and compact notation shows it as "12L" (12 lakh).
  • Currency belongs to the data, not the user. A dashboard in euros stays in euros for a user in India; format with currency: "EUR" in the user's locale. Never silently convert.
  • The first day of the week varies (Sunday in the US, Monday in much of Europe), which changes "this week". Make it a dashboard setting, defaulting from the locale.
  • Right-to-left: mirror the layout (y axis on the right, legend order, breadcrumb direction). Whether time flows right to left on the x axis is a product choice; many RTL products mirror it. Decide once, apply to every chart, and test with native readers.

Check your understanding · Exercise 11

Which make a Canvas line chart usable without sight or a mouse? Select all that apply.

Running it in the interview

Sixty minutes is not enough to say everything in this guide. The skill is choosing: show that you know where the hard parts are, go deep on two or three, and name the rest in a sentence each.

MinutesWhat to doWhat to draw or say
0 to 6Scope questionsWidgets per dashboard, editable or not, live or not, raw data size, time zones
6 to 12Requirements and the wireframeToolbar, filter bar, grid, widget anatomy; budgets for LCP, filter change, hover
12 to 22Architecture and data flowURL state, time resolver, query builder, query client, widget frames, registry
22 to 45Two or three deep divesFetching (keys, dedupe, abort, keep previous), aggregation and rollups, rendering and LTTB
45 to 55The rest, brieflyCross-filter, crosshair sync, live refresh, states, accessibility, i18n
55 to 60Trade-offs and what you'd do nextWhat you'd measure first; what you'd cut for a version 1

Say the key sentence early: "The client never gets raw data. Each widget declares a query; the server aggregates it to about as many points as the chart has pixels." It shows you see the core constraint. Then draw the fetch path for a filter change, because it carries most of the interesting decisions: one shared now, canonical keys, dedupe, abort, keep previous data.

Think first

Follow-up: "The CEO's dashboard takes 8 seconds to load. Where do you look first?"

Think first

Follow-up: "Two people see different totals for the same dashboard link. Why?"

Summary

  • A dashboard is a JSON document: widgets with declarative queries, grid layout in grid units, a schema version for migrations, and a revision for safe saves.
  • Range, zone, filters, and drill-down live in the URL. Resolve relative ranges once per tick in the dashboard's IANA zone, snap to the bucket, and share that with every widget.
  • The server aggregates into time buckets sized to the chart (a few hundred points per series) and reads rollups for long ranges. Distinct counts and percentiles need mergeable sketches.
  • Key query results by the canonical query: that gives deduplication, stale-while-revalidate, abort on filter change, and no races. Batch the transport only when widget counts demand it.
  • Pick the renderer per chart: SVG for most widgets, Canvas for dense series, WebGL rarely. LTTB downsamples for drawing while keeping peaks; never compute totals from it.
  • Hover sync goes through a render-free event bus, one update per frame. Cross-filters skip their source widget and show up as a breadcrumb in the URL.
  • Lazy-mount offscreen widgets, memoize transforms, pause refresh in hidden tabs, and give every widget its own state machine and error boundary.
  • Every chart gets a generated summary, a data table, keyboard exploration, and meaning beyond color; every number goes through Intl in the user's locale and the data's currency.

Check your answer

Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.

Junior 0 of 3
Mid-level 0 of 4

References

Concepts behind it

Try next