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
| Question | Answer we assume | What it changes |
|---|---|---|
| How many widgets on one dashboard? | Typically 10 to 30, up to 60 | Fetching 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 points | Server-side aggregation, downsampling, SVG or Canvas |
| Can users build and edit dashboards? | Yes: add, remove, drag, resize widgets; save; share | Dashboard 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 overrides | Filter state, URL design, query keys |
| Is the data live? | Some dashboards auto-refresh every 10 s to 5 min; ops dashboards stream | Polling or streaming, pausing in hidden tabs |
| Who reads it, and where? | Desktop browsers mostly; phones for a quick glance; wall screens for ops | Responsive grid, read-only mobile layout |
| Which time zone is "today"? | The dashboard's or the user's chosen zone, not the browser's by default | Date 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.
- 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.
- 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.
- 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.
- 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.
- 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.
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?
It works for small, fixed datasets: a few thousand rows, a handful of dimensions, no live data. Then client-side filtering is instant and simple, and some embedded dashboards do exactly that.
It falls apart when raw data is large (millions of events can't cross the network), when each widget needs a different aggregation (sum by day, count distinct users by country), or when permissions limit which rows a user may see. For a product analytics dashboard, the server must aggregate and the client must ask focused questions.
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?
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
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[] };
};
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, thenx). 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
transformfor the moving widget, nottopandleft, 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?
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
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 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.
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_Yorkon 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. Sendtzwith every query. - Format axis labels in the same zone with
Intl.DateTimeFormatand itstimeZoneoption, not the browser's zone. - Zone-aware math needs help today.
Dateonly knows UTC and the local zone. TheTemporalAPI 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
/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
- Use
history.pushStatefor committed changes (picking a range, adding a filter) so Back undoes them; usereplaceStatefor 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?
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:
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,
});
}
- 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:
keepPreviousDatakeeps 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
AbortSignalto the query function. If you hand it tofetch, 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.
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.
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?
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
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];
}
| Range | Interval (900 px chart) | Points per series | Rollup read |
|---|---|---|---|
| Last 1 hour | 10 s | 360 | raw or 10 s |
| Last 24 hours | 5 min | 288 | 1 min |
| Last 7 days | 1 hour | 168 | 1 hour |
| Last 30 days | 6 hours | 120 | 1 hour |
| Last 365 days | 1 day | 365 | 1 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.
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
minandmaxper bucket too and draw them as a light band behind the line.
A compact response shape
{
"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 }
}
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?
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.
Libraries and where each fits
| Library | Renders with | Fits when |
|---|---|---|
| D3 | You choose (it gives scales, shapes, axes) | Custom charts; often used for math only, with React drawing the SVG |
| Recharts | SVG, React components | Fast to build standard charts with modest point counts |
| visx | SVG, low-level React pieces built on D3 | A custom design system of charts with full control |
| ECharts | Canvas by default, SVG optional | Many chart types out of the box, large datasets, built-in dataZoom |
| uPlot | Canvas | Dense 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.
- Always keep the first and last point.
- Split the points in between into
target - 2equal buckets. - 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).
- Keep the candidate B that makes the triangle ABC largest. A spike makes a tall triangle, so spikes win.
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
}
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
ResizeObserveron the widget body reports its size, which the grid decides. Never readoffsetWidthduring 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"andstyle: "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.DateTimeFormatwith the dashboard'stimeZone. 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?
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.
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.
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. 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.
Escapeclears 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) withpushState, so Back undoes a click. - Fields must match. A widget built on a data source without a
countryfield can't apply the filter. Show a small "filter not applied" note on it instead of silently ignoring it.
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?
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.
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
useMemoon 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.memocharts 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
startTransitionso React can interrupt that render for a click. Results that land through the query cache'suseSyncExternalStoresubscription still render as blocking updates, so keep per-widget work small (memoized transforms) and draw heavy charts in a worker. - Avoid layout thrash. Reading
getBoundingClientRectin one chart and writing styles in another, 20 times over, forces 20 layouts. Take sizes fromResizeObservercallbacks and write inrequestAnimationFrame. - 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.
// 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(); }
};
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.
| Budget | Target | How we stay inside it |
|---|---|---|
| LCP | under 2.5 s | Shell and first 8 to 12 widgets only; chart code split by widget type |
| INP | under 200 ms | Filter changes in transitions, no long tasks over 50 ms, workers for heavy charts |
| CLS | under 0.1 | Grid cells sized from config before data arrives |
| Hover frame | under 16 ms total | Overlay-only redraw, one update per frame |
| JS for a stats-only dashboard | under about 150 KB gzipped | Chart 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?
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
visibilitychangeand stop all timers whendocument.visibilityStateishidden. On return, refetch once right away, then resume. TanStack Query does this forrefetchIntervalunless you setrefetchIntervalInBackground. - 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.
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 | What the user sees | Notes |
|---|---|---|
| waiting | A gray placeholder the size of the cell | Not near the viewport yet; nothing fetched |
| loading | A skeleton in the chart's shape | First load only; after a 15 s timeout, an error with Retry |
| ready | The chart | Data in cache |
| refreshing | The old chart, dimmed, with a thin progress bar | Filters changed or auto-refresh; never blank it |
| empty | "No data for this range" with the active filters listed | Zero rows is not an error; zero is not null |
| partial | The chart plus a badge: "2 of 5 series failed" or "estimated" | From meta.partial, sampled, or per-series errors |
| error | A short message, the request id, and Retry | Other 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
Bloband 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 withXMLSerializer, load that into anImage, 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.
| Part | Responsibility | Why the boundary is here |
|---|---|---|
| Dashboard shell | Loads the config, runs migrations, renders toolbar, filter bar, and grid | One place owns the document and edit mode |
| URL state | Parses and writes range, zone, filters, drill, refresh | The URL is the source of truth for the view; everything else derives from it |
| Time resolver | Turns the range spec into instants once per tick | One shared now keeps widgets consistent and cacheable |
| Query builder | Merges widget query, global filters, drill steps, overrides, interval | Widgets declare; one function decides the real query |
| Query client | Cache, dedupe, stale-while-revalidate, abort, batching | Network policy in one place; widgets only see keys |
| Widget registry | Maps type to a lazy component | Chart libraries load only when needed |
| Widget frame | Error boundary, lazy mount, state UI, header menu | Failure and loading handled the same for every type |
| Hover bus | Hover time, cross-filter clicks | Fast, render-free signals between widgets |
| Refresh clock | Timers, jitter, visibility pause | One clock for the page instead of 20 |
| Chart workers | Heavy Canvas drawing and downsampling | Keep 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.
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.
| Data | Lives in | Why |
|---|---|---|
| Dashboard config (widgets, layout, defaults) | Query cache, plus a draft copy in a store during edit mode | Server document; the draft holds unsaved edits |
Resolved range and now | Memory, recomputed per tick | Derived; never stored |
| Hover time, highlighted series | Hover bus and chart-local state | Changes 60 times a second; must not re-render React trees |
| Widget size | ResizeObserver in the frame | Comes from layout, not config |
| User preferences (zone, locale, theme) | Server profile, cached in local storage | Follows 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.
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
};
Endpoints
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)
- 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 getno-storeand rely on the client cache. - Per-item cancel in batches. Aborting the batch fetch cancels every widget in it, so send
POST /api/query/cancelwith 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
figurewith afigcaption, or give the chart containerrole="img"and anaria-labelbuilt 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
tablewith 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 likeCanvasTextso lines don't vanish, and respectprefers-reduced-motionby 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
Intlwith the user's locale: 1,234.5 inen-USis 1.234,5 inde-DE, andar-EGuses Arabic-Indic digits and separators. Grouping and compact notation are localized too: inen-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.
| Minutes | What to do | What to draw or say |
|---|---|---|
| 0 to 6 | Scope questions | Widgets per dashboard, editable or not, live or not, raw data size, time zones |
| 6 to 12 | Requirements and the wireframe | Toolbar, filter bar, grid, widget anatomy; budgets for LCP, filter change, hover |
| 12 to 22 | Architecture and data flow | URL state, time resolver, query builder, query client, widget frames, registry |
| 22 to 45 | Two or three deep dives | Fetching (keys, dedupe, abort, keep previous), aggregation and rollups, rendering and LTTB |
| 45 to 55 | The rest, briefly | Cross-filter, crosshair sync, live refresh, states, accessibility, i18n |
| 55 to 60 | Trade-offs and what you'd do next | What 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?"
Measure before guessing: in the browser's performance panel and the network waterfall, split the 8 s into waiting for config, waiting for queries, and main-thread work.
If queries dominate: which are slowest, are they reading raw data instead of rollups, are 40 widgets fetching at once instead of the visible 10, are any duplicates? If main-thread work dominates: long tasks from rendering every chart, SVG with too many elements, transforms not memoized. Fix the biggest slice, then measure again.
Think first
Follow-up: "Two people see different totals for the same dashboard link. Why?"
Most likely time: they resolve "today" in different zones, or the link lacked the zone, or each widget computed now separately. Next: the link lacked a filter that one of them had in local state, or one saw cached data from before a late-arriving batch of events.
The fixes are the ones in this guide: zone and filters in the URL, one resolved range per tick, server bucketing in that zone, and a visible "updated at" time.
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
Intlin 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.
References
- Downsampling Time Series for Visual Representation (Steinarsson, 2013) The thesis that introduced Largest-Triangle-Three-Buckets.
- MDN: OffscreenCanvas Drawing in a worker.
- MDN: Intersection Observer API Lazy mounting widgets near the viewport.
- MDN: Intl.NumberFormat Compact, currency, and percent formatting for axes and tooltips.
- TanStack Query: Query Cancellation How the
signalis passed and when it aborts. - Grafana: Dashboard JSON model A real dashboard config format, with panels, grid positions, and time settings.
- react-grid-layout The draggable, resizable grid model used by many dashboards.
- W3C WAI: Complex images (charts and graphs) Text alternatives and data tables for charts.
- W3C: Understanding WCAG 2.2 SC 1.4.11 Non-text Contrast The 3:1 contrast rule for chart lines, bars, and focus indicators.
- Highcharts: Accessibility module feature overview A worked example of keyboard navigation, screen reader sections, and data tables for charts.
- ClickHouse: Build a rollup with materialized views Keeping a time-bucketed rollup table up to date as raw events arrive.
- uPlot A small, fast Canvas library for time series, with cursor sync.
Concepts behind it
- Server-state librariesTanStack Query, SWR, RTK Query, and Apollo cache server data by key, refetch stale entries in the background, and refresh them after mutations, so components stop hand-writing loading flags, races, and invalidation.
- Request races and cancellationResponses can come back in any order, so a page can show the answer to an old question; learn to let only the latest answer reach the screen and to cancel the work nobody needs.
- Deduplication and batchingWhen many parts of a page ask for the same data at once, send one request and share the answer; when they ask for many small pieces at once, collect them into one request. Two small ideas that remove most of the wasted traffic in a component-based app.
- The URL as stateFilters, sort, page, tab, and selected item stored in the address bar instead of only in memory, so a view can be shared, bookmarked, refreshed, and walked back with the Back button: the cheapest state store the browser gives you.
- Runtime performanceKeeping the page responsive after it loads: finding long tasks on the main thread, splitting or moving that work, and avoiding needless re-renders so every click and keypress paints in under 200 ms.
- Dates, numbers, and pluralsTurning raw instants, numbers, amounts, and counts into text that reads right for each user with the browser's built-in Intl APIs, instead of hand-built strings that are correct for only one country.
- Color, contrast, and motionHow people with low vision, color blindness, motion sensitivity, or custom system themes see your UI, and the rules that keep it usable: contrast ratios, never color alone, reflow at 400% zoom, target size, and the user preference media queries.
- Config-driven UIDescribing screens as JSON that names known components, rendered through an allowlisted registry, so layouts change without an app release: safe only if configs are validated, never executed, and older clients get fallbacks.