Design an IoT dashboard for thousands of sensors
An operator console for a factory, a building or a fleet: tens of thousands of sensors report every few seconds, and the browser must show live charts, fleet health and alarms without ever receiving the raw firehose. The hard parts are server-side aggregation and screen-driven subscriptions, pixel-aware downsampling that keeps spikes and gaps, live charts on canvas with ring buffers, honest handling of stale, late and missing data, and alarms that do not flap or storm.
The question
Design the web dashboard for an industrial IoT platform. A customer has a few sites, each with buildings, floors and zones, and between 5,000 and 50,000 sensors that report temperature, pressure, power, vibration and similar values every 1 to 10 seconds.
Operators need a live overview of fleet health, drill-down charts for any sensor over any time range, and alarms they can acknowledge. Some screens run all day on wall displays in a control room. Walk me through the data, how it reaches the browser, how you draw it, and how you deal with missing, late and alarming data.
Real-life examples: AWS IoT SiteWise Monitor, Grafana for industrial telemetry, ThingsBoard, Samsara fleet dashboards, Smart building consoles, Factory floor SCADA web views
Requirements: which IoT dashboard are we building?
IoT (Internet of Things) means physical devices with sensors that send readings over the network. A sensor measures one thing (a temperature probe, a power meter, an accelerometer on a motor). A device or asset is the physical thing that carries one or more sensors, such as a chiller with ten sensors. A gateway is a small box on site that collects readings from many devices over local protocols and forwards them to the cloud.
The dashboard is the web app operators use to watch all of this. You have seen versions of it in AWS IoT SiteWise Monitor, Grafana boards built on industrial data, ThingsBoard, Samsara fleet dashboards, and smart-building consoles that show every floor of an office tower.
Several other guides cover nearby ground, and we link them instead of repeating them. The analytics-dashboard guide covers the widget grid, one query per widget, filters and URL state. The stock-ticker guide covers flashing cells and throttled rendering of fast prices, and the live-leaderboard guide covers ranked lists that reorder while people read them.
The log-viewer guide covers streaming append-only text, and the google-maps guide covers tiles and marker clustering. The data-table guide covers big tables, the tree-view guide covers lazy, keyboard-friendly trees, and the notifications-center guide covers an alert inbox. This guide focuses on what is special about telemetry from many devices: time series, volume, gaps, staleness and alarms.
Questions to ask first
| Question | Why it changes the design | What this guide assumes |
|---|---|---|
| How many sensors, and how often do they report? | Sets the volume. 500 sensors could stream raw; 50,000 cannot. | 20,000 sensors per customer on average, up to 50,000; one sample every 1 to 10 s, typical 5 s. |
| How far back can users look? | Decides whether we need precomputed rollups. | Raw data for 30 days, 1 minute rollups for 1 year, 1 hour and 1 day rollups for 5 years. |
| How fresh must the live view be? | Decides push vs polling and the batching window. | A new reading should appear within 2 s of the server receiving it. |
| Who evaluates alarms, and who must see them? | Alarms must fire even when no browser is open. | Rules run on the server; every operator on a site sees the same alarm state. |
| Are there wall displays? | Always-on screens change memory and reconnect rules. | Yes: control room screens that run for weeks. |
| Do users write to devices? | Commands to machines bring safety rules. | Out of scope: the dashboard is read and acknowledge only. |
Functional requirements
- Fleet overview: a site tree (site, building, floor, zone, device) with roll-up health, a device list with status (online, stale, offline, alarming), and a map or floor plan.
- Device detail: live charts for each sensor of a device, with a time range picker from the last 15 minutes to the last 5 years.
- Live tiles: current value, unit and a small trend line (a sparkline) for many sensors on one screen.
- Alarms: a list of active alarms, alarm markers on charts, acknowledge with a note, synced across operators in real time.
- Gaps and staleness: missing data shows as missing, and every value says how old it is.
Non-functional requirements
- Live latency: server receive to pixel under 2 s at the 95th percentile.
- Chart load: any time range on one sensor draws in under 1 s, because the server never sends more points than the chart has pixels for.
- Main thread: live updates cost under 100 ms of main-thread work per second (10%), so scrolling and typing stay smooth.
- Long running: a wall display runs for 30 days with flat memory and reconnects on its own after any outage.
- Accessible: every chart has a text alternative, alarm states never rely on colour alone, and the site tree works with a keyboard.
Out of scope: device provisioning, firmware updates, sending commands to machines, and the rule editor for alarms. Each one is a product of its own. We also skip the dashboard builder, since analytics-dashboard covers widget layout.
Check your understanding · Exercise 1
Which answers would change the architecture of this dashboard the most? Select all that apply.
What makes it hard: the shape and the size of telemetry
Before any boxes and arrows, look at the data. Every later decision comes from two facts: telemetry is a huge number of small time series, and almost none of it can be sent to the browser as it arrives.
The shape: one time series per sensor per metric
A time series is a list of (timestamp, value) pairs for one thing. Here the thing is a sensor and a metric, such as chiller-07 / supply_temp. A single reading is a sample. Samples carry more than a number, and each extra field matters to the screen:
- Unit:
degC,kPa,kW,mm/s. The unit belongs to the series, not to each sample, and the UI converts it for display (Celsius vs Fahrenheit) without touching the stored value. - Quality: industrial systems tag each sample as good, uncertain or bad. AWS IoT SiteWise uses
GOOD,BADandUNCERTAIN; OPC UA (the common factory protocol) has the same idea. A bad sample must not be drawn like a good one. - Two timestamps: when the device measured it and when the server received it. They differ, sometimes by hours (chapter 7).
- Type: most series are numbers, but some are states (pump running or stopped, door open or closed) and draw as coloured bands, not lines.
Sampling is irregular. Some devices report on a fixed clock. Many report by exception: they send a value only when it moves more than a set amount (a deadband), so a steady temperature may send nothing for ten minutes.
For a report-by-exception device, silence means "unchanged". For a clocked device, silence means "something is wrong". The UI has to know which kind of device it is looking at.
The size: do the volume math out loud
| Quantity | Math | Result |
|---|---|---|
| Ingest rate | 20,000 sensors x 1 sample per 5 s | 4,000 samples/s |
| Ingest at the top end | 50,000 sensors x 1 sample per 1 s | 50,000 samples/s |
| Per day | 4,000 x 86,400 s | about 346 million samples |
| As JSON to one browser | 4,000 x about 80 bytes | about 320 KB/s, 28 GB a day |
| One sensor, 1 day raw at 5 s | 86,400 / 5 | 17,280 points |
| One sensor, 1 year raw | 17,280 x 365 | about 6.3 million points |
| One sensor, 5 years raw | 17,280 x 1,825 | about 31.5 million points |
Now compare that with what a screen can show. A chart 1,200 CSS pixels wide can show at most about 2,400 meaningful points (a low and a high per pixel column). A device list shows maybe 30 rows at a time, and a floor plan a few hundred icons.
People read a changing number at most a few times per second. So one screen needs a few thousand numbers to draw its charts once, then a few hundred changed values per second. The fleet produces 4,000 to 50,000 new samples every second, for every customer, forever.
That gap is the whole design. The rest of the guide answers the three questions it raises: which numbers the browser needs right now (chapter 3), how to turn millions of samples into a few thousand without lying (chapter 4), and how to draw the live part cheaply (chapter 5).
Three more traits make telemetry harder than a stock ticker. Data goes missing (devices lose power, gateways lose the network). Data arrives late and out of order (a gateway uploads an hour of buffered readings after it reconnects). And the important events are rare spikes inside long boring stretches, which is exactly what naive averaging erases.
Check your understanding · Exercise 2
An operator opens a 1-year chart of one vibration sensor that samples every 5 s, in a chart 1,000 px wide. Which statements are true? Select all that apply.
Never stream the firehose: aggregate on the server, subscribe to the screen
The naive design opens a socket and forwards every sample from the ingest pipeline to every browser. It works in the demo with 200 simulated sensors. With 20,000 real ones, each browser receives 4,000 messages per second, parses about 320 KB of JSON per second, and runs a store update for each one.
Even at 0.05 ms per message, that is 200 ms of main-thread work every second, before a single pixel is drawn. Ten operators means ten copies of the whole firehose leaving the data centre.
The fix has two halves. The server reduces data before it leaves (aggregation), and the browser asks only for what the operator can see right now (a subscription tied to the screen).
Decision What should the live channel send to each browser?
What we pick: Screen-driven subscriptions. The cost then depends on what one human can see, which is small and bounded, instead of on fleet size, which grows every quarter. Polling stays as the fallback and as the way to load history, but the live tail comes over one socket.
What a subscription names
A subscription is not just a list of ids. It names three things, because each one changes what the server sends:
- Which series: the sensors and metrics in view. For a list, that is the visible rows plus a small overscan. For a map or floor plan, it is a bounding box or a zone id, and the server resolves it to sensors.
- What form: a fleet tile needs only the latest value and status. A sparkline needs one value per 10 s bucket. A detail chart needs per-bucket min and max at the chart's resolution.
- How often: a wall display summary may be fine with 5 s batches; an open detail chart wants 1 s.
type Subscribe = {
op: "sub";
key: string; // "list:visible", "chart:chiller-07", ...
series: string[]; // or { zone: "b2-f3" } resolved on the server
form: "latest" | "spark-10s" | "minmax";
bucketMs?: number; // for minmax: chart range / plot width
everyMs: 500 | 1000 | 5000;
};
type Unsubscribe = { op: "unsub"; key: string };
Keeping it in sync as the view moves
The view changes all the time: the operator scrolls the device list, pans the floor plan, opens a chart, changes its range from 1 hour to 7 days. Sending a new subscription on every scroll event would flood the server. Three rules keep it calm:
- Debounce the change by about 150 to 250 ms after scrolling or panning stops (the debounce-throttle lesson covers the mechanics). Rows that fly past during a fling never subscribe.
- Keep a grace period: when a series leaves the screen, keep it subscribed for 5 to 10 s. Scrolling back a little then shows fresh values at once.
- Fill on subscribe: the server answers a new subscription with the current value of each series straight away, so a newly visible row never shows a blank until the next batch.
Some data must flow no matter what is on screen. Alarm changes and fleet-level counts (how many devices are offline per site) go on a small, always-on channel. They are cheap because they are already rolled up on the server, and the operator must never miss them because the right row was scrolled away.
The fleet lab below runs 300 simulated sensors that each report every 2 s, standing for a fleet of 300, 3,000 or 30,000. Switch between subscribing to everything and subscribing to what is visible, then between rendering on every message and once per frame. Watch messages per second, renders per second and the share of a simulated main-thread budget grow or stay flat as the fleet grows.
Then take gateway GW-3 offline and compare a flat list of alarms with alarms grouped by their parent. In the flat list, the two real alarms are pushed out of view by offline rows, which is the alarm storm of chapter 8 in miniature.
Check your understanding · Exercise 3
An operator scrolls quickly through a list of 20,000 devices, then stops. Which behaviours belong in a good subscription layer? Select all that apply.
Downsampling: fewer points, same picture
Now the history chart. The operator opens a pump and picks "last 30 days". At one sample per 5 s that is 518,400 raw points for one line. The naive chart fetches them all: about 10 MB of JSON (at about 20 bytes per time and value pair), a second or more of parsing, and an SVG path with half a million segments.
Worse, most of that work is invisible. The plot area is perhaps 1,200 px wide, so about 430 samples land in each pixel column.
The rule that fixes this is pixel-aware downsampling: ask the server for about one bucket per pixel column, where a bucket is a slice of time. Bucket size = time range / plot width, so for 30 days on 1,200 px each bucket covers 36 minutes. Grafana works the same way: its "max data points" query option defaults to the width of the panel in pixels. The open question is what one bucket should contain.
Decision How should the server reduce each bucket of raw samples?
What we pick: Min and max per bucket for every chart where a missed spike costs money, which is most of an industrial dashboard. They are cheap to precompute, they compose across rollups, and they never hide an excursion. We also store sum and count so the UI can draw a mean line on top. LTTB is a good choice for presentation charts and for client-side thinning of a raw window, where it is applied once to real samples.
LTTB comes from Sveinn Steinarsson's 2013 master's thesis at the University of Iceland, "Downsampling Time Series for Visual Representation". It is popular because it keeps the shape of a line well with very few points. It is a visual method, not a statistical one, and that is why min and max are the safer default for alarms and maintenance.
Min and max per pixel column is also well studied. The M4 method (Jugel and others, VLDB 2014) keeps the first, last, minimum and maximum sample of each pixel column, and the paper shows that a line chart drawn from those four points matches the chart drawn from all the raw data. Our buckets store min and max as values; add first and last when lines between columns must be exact.
The lab below draws 12 hours of one sensor (1,440 samples, one every 30 s) with two spikes, a dip and an hour with no data. Change the chart width and the method, and watch which extremes survive. At 60 columns, spike B and dip C share a bucket, so LTTB keeps only one of them. Then turn on "connect across missing data" to see a line invent an hour of readings.
Rollups: a pyramid in time
Computing min and max over 6.3 million raw rows for a 1-year chart on every request is slow. So the pipeline precomputes rollups: tables of per-bucket aggregates at fixed resolutions, written as data arrives. AWS IoT SiteWise does this as a service: its aggregates API takes a resolution of 1m, 15m, 1h or 1d and returns average, count, minimum, maximum, sum and standard deviation. It works like map tiles: each zoom level has its own precomputed layer, and the client reads the one that matches its zoom.
range / width and merges whole groups of neighbours.| Range on a 1,200 px chart | Ideal bucket (range / width) | Read from | Rows read | Buckets sent |
|---|---|---|---|---|
| 1 hour | 3 s | raw (5 s samples) | 720 | 720 raw samples |
| 1 day | 72 s | 1 m rollup, merged 2 at a time (2 min) | 1,440 | 720 |
| 30 days | 36 min | 15 m rollup, merged 3 at a time (45 min) | 2,880 | 960 |
| 1 year | about 7.3 h | 1 h rollup, merged 8 at a time (8 h) | 8,760 | 1,095 |
| 5 years | about 36.5 h | 1 d rollup, merged 2 at a time (2 d) | 1,825 | 913 |
Merging is where the choice of aggregates matters. The min of three 15 minute buckets is the min of their mins, and the same goes for max, sum and count. The average of three averages is wrong when the buckets have different counts (one had a gap), so we store sum and count and divide at the end. Merged buckets must also be whole multiples of the rollup: a 36 minute bucket would cut a 15 minute bucket in half, and nobody knows which half held its max.
type Bucket = { t: number; min: number; max: number; sum: number; n: number };
// Merge fine buckets into coarser ones of size `stepMs`, a whole multiple of the
// fine size. Empty buckets stay out: the client sees a missing bucket and draws a gap.
function merge(fine: Bucket[], stepMs: number): Bucket[] {
const out: Bucket[] = [];
for (const b of fine) {
if (b.n === 0) continue;
const t = Math.floor(b.t / stepMs) * stepMs;
const last = out[out.length - 1];
if (last && last.t === t) {
last.min = Math.min(last.min, b.min);
last.max = Math.max(last.max, b.max);
last.sum += b.sum;
last.n += b.n;
} else {
out.push({ ...b, t });
}
}
return out;
}
Two details make the chart honest. First, an empty bucket is not a zero: the server leaves it out (or sends null), and the chart breaks the line there. Second, LTTB in its original form assumes no gaps; the reference implementation lists gaps as a known limitation. If you use it, run it on each unbroken segment separately.
Check your understanding · Exercise 4
A compressor's discharge pressure briefly hit 950 kPa for 8 s last Tuesday. Which ways of drawing a 30-day chart at 1,200 px will still show that spike? Select all that apply.
Live charts: ring buffers, one draw per frame, canvas
History is one request. The live tail is a stream: every second a batch arrives with new buckets for the series on screen. The naive live chart pushes each new point into a JavaScript array, calls setState, and lets a React SVG chart re-render.
On a screen with 100 sparklines and 8 detail charts, that is hundreds of re-renders a second, arrays that grow forever, and an SVG DOM with tens of thousands of nodes. The tab stutters within minutes and runs out of memory within days.
The stock-ticker guide covers the general rule of coalescing updates into one render per animation frame, so here we focus on what charts of telemetry add: a fixed time window, many lines, and memory that must stay flat.
A ring buffer per window
A live chart shows a sliding window, such as the last 15 minutes. Old points fall off the left as new ones arrive on the right. A ring buffer is a fixed-size array with a write index that wraps around: writing point 901 into a 900-slot buffer overwrites slot 0.
Memory is allocated once and never grows. With a typed array (Float32Array, Float64Array), the values sit in one flat block of memory, with no object per point for the garbage collector to track.
class Ring {
private v: Float32Array;
private head = 0; // next slot to write
private size = 0;
constructor(readonly capacity: number) {
this.v = new Float32Array(capacity).fill(NaN); // NaN = no data
}
push(value: number) {
this.v[this.head] = value;
this.head = (this.head + 1) % this.capacity;
this.size = Math.min(this.size + 1, this.capacity);
}
// Oldest to newest, without allocating: the chart reads through this.
forEach(fn: (value: number, i: number) => void) {
const start = (this.head - this.size + this.capacity) % this.capacity;
for (let i = 0; i < this.size; i++) fn(this.v[(start + i) % this.capacity], i);
}
}
Do the memory math before choosing the type. A Float64Array uses 8 bytes per value, a Float32Array 4. Float32 keeps about 7 significant digits, which is plenty for a temperature of 21.37 or a power reading of 1,284.6 kW. It is not enough for epoch timestamps in milliseconds (13 digits, rounded to steps of about 2 minutes), which is one more reason to share a single time axis instead of storing a time per point.
| Screen | Series x points | Float64Array | Float32Array |
|---|---|---|---|
| Device page, 1 h at 1 s | 20 x 3,600 = 72,000 | 576 KB | 288 KB |
| Tile grid, 1 h sparklines at 10 s | 300 x 360 = 108,000 | 864 KB | 432 KB |
| Wall display, 24 h at 10 s | 2,000 x 8,640 = 17.3 million | about 138 MB | about 69 MB |
Same, as JS objects {t, v} | 17.3 million objects | often several hundred MB (estimate) | n/a |
Rendering: when and with what
When: socket batches write into ring buffers and mark the chart dirty. A single requestAnimationFrame loop redraws dirty charts, at most once per frame. If the tab is hidden, the visibilitychange event fires and the loop stops; browsers also pause requestAnimationFrame in background tabs. Buffers keep filling, so the chart is current the moment the tab comes back.
Decision What should draw the charts?
What we pick: Canvas 2D for everything on this dashboard, with uPlot (or a similar small canvas library) for detail charts. Thanks to downsampling, no chart ever holds more than a few thousand points, which is well inside what canvas handles in a few milliseconds. WebGL is kept for one special view, the vibration spectrogram, not for ordinary lines.
Sparklines in one canvas. A tile grid with 100 sparklines should not mean 100 canvases and 100 chart instances. Draw them all into one canvas that sits behind the grid, each in its own rectangle, measured from the tile positions once per layout change. One draw call loop per frame paints only the tiles whose data changed. The tiles themselves stay plain DOM, with the current value as real text.
Workers. Parsing binary batches and running LTTB on a raw window are pure computations, so they can run in a Web Worker and hand back typed arrays as transferable objects (moved, not copied). With OffscreenCanvas, a worker can even draw the charts itself. That helps when the main thread is busy with a large tree or table. Start with parsing in a worker, measure, and move drawing only if the profile says so.
Check your understanding · Exercise 5
A wall display shows 2,000 series with a 24-hour live window at 10 s buckets. Which choices keep it healthy for weeks? Select all that apply.
The fleet view: status, lists, maps and the site tree
The first screen an operator sees answers one question: is anything wrong, and where? That needs a status for every device and a way to roll statuses up to sites and buildings. The naive version downloads all 20,000 devices with their latest readings, computes status in the browser, and filters and sorts in memory.
That is a 5 to 10 MB payload on every load. The status logic is duplicated (and drifts) between the server's alarm engine and the browser, and the numbers in the header disagree with what another operator sees.
One status, computed in one place
| Status | Rule (example thresholds) | Shown as |
|---|---|---|
| Online | Last sample within the expected interval x 3 | Filled circle, "Online" |
| Stale | No sample for 3 to 10 intervals | Hollow circle, "Stale 2 min" |
| Offline | No sample for 10+ intervals, or the gateway reported it disconnected | Crossed circle, "Offline since 09:42" |
| Alarming | At least one firing alarm (chapter 8) | Triangle with severity, "High: 2 alarms" |
| Unknown | Never reported, or decommissioned | Dashed circle, "No data yet" |
The server computes status, because it already sees every sample and every alarm, and because all operators must agree. The browser shows it. The one thing the client adds is a local clock tick: if the last-seen time crosses the stale threshold while no message arrives, the client can show "stale" itself rather than wait for the server to say so. That matters most when the connection to the server is what broke.
The site tree and roll-up health
Industrial customers think in places: site, building, floor, zone, device. The left panel is a tree (the tree-view guide covers lazy loading and keyboard behaviour), and each node shows a roll-up: how many of its devices are in each state, plus the worst state as its badge.
Decision Where should roll-up counts for the site tree be computed?
What we pick: Server-side counters. The tree is the place where fleet size hurts most, and the counts are a pure function of state the server already owns. The client stores them per node id in a normalized map and patches them from the always-on channel.
The device list and the map
The device list is a virtualized table (see the virtualization lesson and the data-table guide). What is specific here is that sorting and filtering happen on the server: "offline devices in Building 2, oldest first" is a query, and the client asks for pages by cursor. Live updates only patch status and value cells of rows that are subscribed.
If a live change would move a row (a device goes offline while the list is sorted by status), do not reorder under the user's cursor. Show "3 devices changed status, refresh order" and let them apply it. The live-leaderboard guide covers the other choice, animating live reorders, for lists where movement is the point.
The map and the floor plan reuse what the google-maps guide teaches about clustering. The telemetry twist: a cluster's badge shows the worst status inside it and the count per status, so a red triangle on a cluster of 400 tells you to zoom in there. For floor plans, the zone polygons are the natural clusters, and the roll-up counters from the tree already give their colours.
Check your understanding · Exercise 6
Building 2 has 3,000 devices. A floor's network switch fails and 120 devices stop reporting. What should the operator see? Select all that apply.
Gaps, stale values, clock skew and late data
Telemetry is never complete. Devices reboot, gateways lose the uplink, a sensor's battery dies. A dashboard that hides this is dangerous: an operator who sees a calm flat line at 4 degrees in a cold room assumes the cold room is fine, when in fact the probe died at 4 degrees six hours ago. This chapter is about telling the truth about time.
No data is not zero
Three different things can look alike on a chart, and each needs its own drawing:
- A real zero: the power meter reads 0 kW because the line is stopped. Draw it as a value at zero.
- A gap: no samples arrived for a clocked device. Break the line, and optionally shade the gap with a label such as "no data 02:10 to 03:40".
- Unchanged: a deadband device sent nothing because nothing moved. Draw a step line that holds the last value, but only up to that device's stale threshold; after that it becomes a gap.
Chart libraries default to different things here, so be explicit. Grafana's time series panel has a "connect null values" setting with never, always, and a threshold option, which is the same choice in product form.
In our data, a missing bucket is null on the wire and NaN in the ring buffer, and the renderer never draws a segment that touches one. For raw points, we break the line when two neighbours are further apart than about 2 to 3 times the expected interval, which is what the threshold option does.
Whose clock?
Each sample has a device time (when it was measured) and a receive time (when the server got it). Device clocks drift, reset to 1970 after a power loss, or run in the wrong time zone. Receive times are trustworthy but include network delay and, after an outage, hours of buffering.
Decision Which timestamp should charts and staleness use?
What we pick: Device time for the chart, receive time for liveness, with the server correcting or flagging clocks that are clearly wrong. Each question then gets the clock that answers it truthfully: when did it happen, and when did we last hear from it.
Late, out-of-order and backfilled samples
A gateway loses its uplink at 02:10, keeps buffering, and reconnects at 03:40. It then uploads 90 minutes of readings for 400 devices in a few seconds. Those samples belong to buckets that the live stream already closed and sent as empty. Three things must happen:
- The pipeline writes the samples by device time and recomputes the affected rollup buckets (1 m, 15 m, 1 h, 1 d).
- It sends each subscribed client a small revision message: series ids plus the time range that changed, not the data itself.
- Each client checks whether that range is inside a chart it is showing. If so, it refetches just that range at its current resolution and replaces those buckets. If not, it marks its history cache for that range as stale.
Smaller lateness is handled by waiting a little before closing a bucket. If the live stream sends a 10 s bucket 2 s after it ends, most slightly late samples still make it in. Anything later arrives as a revision. This is the same trade as a watermark in stream processing: wait longer for more complete buckets, or less for fresher ones.
Think first
A cold room probe reports by exception with a 0.5 degree deadband. It has sent nothing for 40 minutes. Its last value was 3.8 degrees. What should the tile and the chart show?
It depends on the device type's rules. If the probe also sends a heartbeat (many do, say every 15 minutes), then 40 minutes of silence means three missed heartbeats: the tile shows "Stale, last value 3.8 degrees, 40 min ago" with a hollow status icon, and the chart's step line ends at the last heartbeat with a gap after it.
If there is no heartbeat, the dashboard cannot tell "unchanged" from "dead", and that is a finding to report to the customer. The honest UI shows the age next to the value in every case, so the operator can judge.
Check your understanding · Exercise 7
A gateway reconnects after 90 minutes and uploads its buffer. Which client behaviours are correct? Select all that apply.
Alarms: evaluated on the server, calm on the screen
An alarm (industrial software often says alarm; cloud tools say alert) is a rule over a series, such as "supply temperature above 8 degrees", plus the state that rule is in right now. The naive design checks thresholds in the browser as values arrive and turns tiles red.
It looks fine in a demo, and it is wrong in four ways. No alarm fires when no browser is open at 3 a.m. Two operators with different subscriptions see different alarms. A value hovering at 8.0 flips the tile red and back many times a minute, and nobody can acknowledge a state that only exists in one tab.
Decision Where should alarm rules run?
What we pick: On the server. Alarms are a safety feature with an audit trail, so they cannot depend on which tabs happen to be open. The browser renders alarm state, sends acknowledgements, and draws alarms on charts. The only thing it computes is local display, such as a threshold line.
States, hysteresis and debounce
An alarm is a small state machine (see the state-machines lesson). Two tricks keep it from flapping, which means switching on and off many times as a value hovers near the limit:
- Debounce with a pending state: the condition must hold for a duration (say 60 s) before the alarm fires. Grafana alert rules call this the pending period, and Prometheus has the same idea with its
forclause. - Hysteresis: use a different limit to clear than to fire. Fire above 8.0 degrees, clear only below 7.5. A value bouncing between 7.9 and 8.1 then fires once and stays firing, instead of flapping.
Acknowledged is a separate flag, not a separate condition: an operator says "I see it, I am on it". The alarm can be firing and acknowledged, or it can clear while still unacknowledged, which many plants require an operator to confirm. The ISA-18.2 standard for alarm management in process plants describes a similar lifecycle, with states such as unacknowledged, acknowledged, and returned to normal but unacknowledged. It calls the two tricks above deadband and on-delay.
Alarm storms
A gateway serving 400 devices loses power. Every device goes offline, and every "device offline" rule fires: 400 alarms in a second. Worse, sensors on those devices may also fire "value missing" rules.
The operator's list fills with 1,000 rows, and the one alarm that matters (a real high-pressure alarm in another building) scrolls out of view. This is an alarm storm, which alarm management guides call an alarm flood. Guidance based on ISA-18.2 often treats more than 10 new alarms in 10 minutes for one operator as a flood.
- Group by parent: the server knows the topology (device belongs to gateway). When a parent is down, children's connectivity alarms are folded under one parent alarm: "Gateway GW-14 offline, 400 devices affected".
- Suppress consequences: rules can declare dependencies, so "value missing" does not fire for a device whose gateway is down.
- Rate-limit announcements in the client: the screen reader hears "1 critical alarm: Gateway GW-14 offline, 400 devices", not 400 messages.
Acknowledging, in sync across operators
Acknowledge is a write, and two operators may click it at the same moment. The client sends the alarm id with the version it saw. The server accepts the first, rejects the second with the current state, and broadcasts the change. The UI shows "Acknowledged by Sam, 14:02" in every open console within about a second.
Optimistic UI is fine here: show acknowledged at once. But the rollback path must be clear. If the server says the alarm already cleared or someone else acknowledged it, show who and when instead of an error.
On charts, alarms become context: a dashed threshold line, a shaded band for each firing interval, and a marker with the acknowledgement note. These come from the alarm history API for the visible range, so a chart of last Tuesday shows last Tuesday's alarms.
Check your understanding · Exercise 8
A tank level hovers between 89.8% and 90.3% for ten minutes. The rule is "above 90% is high". Which setups stop the alarm from flapping? Select all that apply.
Transport: one socket, binary batches, resume after drops
We have decided what crosses the wire (screen-driven aggregates) and how often (every 0.5 to 1 s). Now the pipe itself. The choosing-realtime and websockets lessons cover the transports in general; here the questions are who the browser talks to, how compact a batch is, and what happens after the connection drops.
Decision Should the browser connect to the MQTT broker directly, or to a backend-for-frontend?
What we pick: A BFF. The browser should never see device topics, for the same reason it should never see the raw firehose: the screen needs aggregates, permissions and alarm state, and the broker has none of those. Direct MQTT is fine for a lab tool watching ten devices. The backend-for-frontend lesson covers the pattern itself.
Compact batches
Every 1 s the BFF sends one frame per connection with the changes for subscribed series. Three tricks make it small:
- Handles instead of ids: when the client subscribes, the server gives each series a small integer handle. A frame then says "handle 17" (2 bytes) instead of
"chiller-07/supply_temp"(about 25 bytes). - Deltas only: a series that did not change in this batch is left out. In a calm plant, most are.
- Binary frames: a typed layout parsed with a
DataView, no JSON parser and no strings to allocate.
// Frame: [u32 seq][u32 bucketStartSec][u16 count] then count x [u16 handle][f32 value][u8 flags]
function parse(buf: ArrayBuffer, rings: Map<number, Ring>) {
const view = new DataView(buf);
const seq = view.getUint32(0);
const count = view.getUint16(8);
let at = 10;
for (let i = 0; i < count; i++) {
const handle = view.getUint16(at);
const value = view.getFloat32(at + 2);
const flags = view.getUint8(at + 6); // bit 0: gap, bit 1: bad quality
// A real client also keeps `flags` in a parallel Uint8Array ring, so bad samples draw differently.
rings.get(handle)?.push(flags & 1 ? NaN : value);
at += 7;
}
return seq;
}
WebSocket compression (permessage-deflate) helps JSON a lot and binary a little, at a CPU cost on both ends. With binary deltas it is often not worth it; measure with real data. Batching every 1 s also means we never send more frames than the screen can use: 1 frame per second per connection, whatever the ingest rate.
Dropping, resuming, filling the gap
Connections drop: laptops sleep, Wi-Fi roams, the BFF deploys. The connection is a state machine with backoff, and the important part is what happens to data missed while it was down.
- Each frame carries a sequence number. The client remembers the last one it applied and the bucket time it covered.
- On reconnect, the client re-sends its subscriptions with
resumeFrom: lastSeq. If the BFF still holds those frames (a short replay buffer, say 60 s), it replays them and the gap never shows. - If the gap is older than the buffer, the BFF answers
resume: "too-old". The client then fetches the missed range over HTTP for the visible series only, writes it into the rings, and joins the live stream. - Retries use exponential backoff with jitter (see retries-and-backoff), capped at about 30 s, and never give up. During the outage, every value shows its age and the header says "Live updates paused, reconnecting".
Check your understanding · Exercise 9
A control room laptop sleeps for 20 minutes and wakes up. The BFF keeps a 60 s replay buffer. What should the client do? Select all that apply.
Architecture, data model and interface
With the big decisions made, we can name the parts and what each one owns, then where each piece of state lives and the API between browser and server. The guiding idea is that each boundary sits where the data gets smaller: the pipeline turns millions of samples into rollups, the BFF turns rollups into screen-sized batches, and the client turns batches into pixels.
| Part | Lives in | Responsibility |
|---|---|---|
| Ingest and rollups | Server | Store raw samples by device time, maintain 1 m, 15 m, 1 h and 1 d min, max, sum and count, emit revisions for late data. |
| Alarm engine | Server | Evaluate rules with pending periods and hysteresis, group by parent, keep alarm state and history. |
| BFF | Server | Hold per-connection subscriptions, map series to handles, send 1 s binary batches, keep tree counters and a 60 s replay buffer, check permissions. |
| Socket client | Browser, in a worker | Connect, back off, resume, parse frames into typed arrays, post them to the main thread as transferables. |
| Subscription manager | Browser, main thread | Collect what each screen region shows, debounce, keep grace periods, send keyed subscriptions. |
| Series store | Browser, main thread | Ring buffers for live windows, a history cache keyed by series, range and resolution. |
| Entity store | Browser, main thread | Normalized devices, tree nodes with counts, alarms, by id. |
| Render loop | Browser, main thread | One requestAnimationFrame loop that redraws dirty canvases and patches dirty text cells. |
| UI | Browser | Site tree, device list, map or floor plan, tile grid, detail charts, alarm panel. |
Why the socket lives in a worker: parsing is the one piece of live work that scales with incoming data, and it needs nothing from the DOM. Moving it off the main thread keeps scroll and input smooth during a burst, such as the backfill after a gateway reconnects. Why the series store is not in React state: values change every second for hundreds of series, and the only consumer is a canvas. React renders the structure (which tiles, which charts); the render loop paints the numbers.
The data flow for the main screen, end to end, makes the reduction visible at each hop.
width=1200), so the server, not the browser, decides how many points to send.Writes are few and go over HTTP: acknowledging an alarm, saving a view, adding a note. They are small, need clear errors, and must be idempotent; a socket gives none of that for free. Their results come back to every operator through the alarm channel.
Check your understanding · Exercise 10
Which responsibilities belong in the browser in this design? Select all that apply.
Data model: where each piece of state lives
The client holds five kinds of state, and each has a different lifetime and update rate. Mixing them is the usual source of slow dashboards: putting live values in the same store as device names means every value tick re-renders every component that reads a name.
type Status = "online" | "stale" | "offline" | "alarming" | "unknown";
type Device = { id: string; name: string; nodeId: string; gatewayId: string;
type: string; staleAfterMs: number; status: Status; lastSeenAt: number };
type Series = { id: string; deviceId: string; metric: string;
unit: string; kind: "number" | "state"; precision: number };
type TreeNode = { id: string; parentId: string | null; level: "site" | "building" | "floor" | "zone";
name: string; timeZone: string; counts: Record<Status, number> };
type Alarm = { id: string; seriesId: string; parentAlarmId?: string;
state: "pending" | "firing" | "resolved"; acked?: { by: string; at: number; note?: string };
severity: "low" | "high" | "critical"; since: number; version: number };
// Not in the entity store: ring buffers (Map of handle to Ring) and history
// buckets keyed by `${seriesId}|${resolution}|${from}-${to}`.
| State | Where | Updates | Why there |
|---|---|---|---|
| Devices, tree nodes, alarms | Normalized entity store | A few per second | Shared by tree, list, map and alarm panel; patched by id. |
| Live values | Ring buffers (typed arrays) | Hundreds per second | Read only by canvases and text cells in the render loop. |
| History buckets | In-memory cache with LRU eviction | On range change | Reused when the user zooms back; invalidated by revisions. |
| Selected site, device, range, resolution | URL | On navigation | Shareable and survives reload (see url-as-state). |
| Panel sizes, unit preference | Local storage | Rarely | Per operator, per machine. |
Note the timeZone on tree nodes. A site has a time zone, and that is what shift reports and alarm times are read in (chapter 11). The precision on series says how many decimals are meaningful, so the UI never shows a temperature as 21.3700008392334.
Interface: HTTP for history and writes, one socket for live
The API splits by what each call does. Anything with a range, a page or a write is HTTP, because it is cacheable, retryable and easy to debug. Anything that changes on its own is pushed over the socket.
GET /api/series/chiller-07.supply_temp/buckets?from=2026-09-21T00:00Z&to=2026-10-21T00:00Z&width=1200
200 OK
Cache-Control: private, max-age=60
{
"resolution": "15m", "bucketMs": 2700000, "unit": "degC",
"t": [1789948800000, 1789951500000, ...],
"min": [5.1, 5.0, null, ...],
"max": [6.4, 9.8, null, ...],
"sum": [...], "n": [540, 539, 0, ...]
}
| Call | Purpose | Notes |
|---|---|---|
GET /api/tree?parent=b2 | Children of a tree node with counts | Lazy, one level at a time. |
GET /api/devices?node=b2&status=offline&sort=lastSeen&cursor=... | Server-side filtered, sorted list | Cursor pagination; returns rows and a total. |
GET /api/series/:id/buckets | History at the right resolution | Above; also used for gap fill after reconnect. |
GET /api/alarms?state=firing&node=b2 | Active alarms, grouped by parent | Children included as a count, expandable. |
POST /api/alarms/:id/ack | Acknowledge | Body: version and note. 409 with current state if someone else acked first. |
WS sub and unsub | Keyed subscriptions | From chapter 3; the reply maps series to handles and includes a snapshot. |
| WS binary frame | Live batch | Sequence, bucket time, handle, value, flags. |
WS alarm, counts, revision | Always-on events | Small JSON; revisions carry series ids and a time range. |
Errors follow one shape: { "code": "ALARM_ALREADY_ACKED", "message": ..., "current": {...} }, so the client can show who acted instead of a generic failure. Every live message carries the server time it was sent, which lets the client estimate its own clock offset and compute ages correctly even when the operator's laptop clock is off.
Check your understanding · Exercise 11
Why does the history endpoint take the chart's width in pixels? Select all that apply.
Performance, wall displays and accessibility
A dashboard that is fast for 5 minutes is easy. One that is fast after 30 days on a control room wall is the real target. We set budgets, measure them in the field, and design the wall display mode for failure. Then we make sure charts, alarms and times work for every operator, whatever their eyes, keyboard or time zone.
The live budget
| Budget | Target | How we stay inside it |
|---|---|---|
| Main-thread time for live updates | Under 100 ms per second (10%) | Parse in a worker, one draw per frame, only dirty canvases, text cells patched directly. |
| Single frame of live work | Under 8 ms (half of a 16.7 ms frame at 60 Hz) | Draw at most a few thousand points per chart; spread big redraws over frames. |
| Live payload | Under 10 KB/s per screen | Visible series only, deltas, binary, 1 s batches. |
| History request | Under 1 s for any range | Rollups and one bucket per pixel; at most a few thousand rows. |
| Memory | Flat over 30 days | Fixed-size rings, LRU caches with a byte cap, no growing arrays. |
| Interaction to Next Paint | Under 200 ms at p75 | Live work never blocks input; long tree and list renders are virtualized. |
Measure in the field, not just on a developer laptop. Report from each console: frames that took over 50 ms (the Long Animation Frames API in Chromium gives scripts and durations), the size of the ring and cache memory we allocated ourselves (we know it exactly, since typed arrays have fixed sizes), messages per second, and seconds since the last frame applied. The runtime-performance lesson covers the tools.
Wall display mode
A control room screen is a browser in kiosk mode that nobody touches for weeks. Everything that is a small leak in a normal tab becomes an outage there. The memory-leaks lesson covers finding leaks; this is how we design so they cannot pile up:
- Bounded everything: rings are fixed size, the history cache has a byte cap with least-recently-used eviction, the alarm history list keeps the last 500 entries, and every event listener and subscription is removed when its view unmounts.
- Reconnect forever: backoff caps at 30 s and never stops. A big "Disconnected for 4 min" banner and ageing values make the outage visible from across the room.
- Keep the screen on:
navigator.wakeLock.request("screen"), reacquired onvisibilitychange, since the browser releases it when the page is hidden. The Screen Wake Lock API is Baseline since 2025 and needs HTTPS. - Planned reload: once a day, at a quiet hour in the site's time zone and only when no alarm is firing, the page reloads itself. This clears anything we did not foresee (browser memory growth, a stale bundle after a deploy). The URL holds the whole view, so the screen comes back exactly as it was.
- Heartbeat watchdog: if no frame has been applied for 2 minutes while the socket claims to be open, treat the connection as dead and reconnect. Half-open TCP connections are common on long-lived networks.
Think first
Why reload the page on purpose once a day if we designed everything to be bounded?
Because we only control our own memory. Browser internals, extensions, font caches, GPU resources and third-party code can grow slowly, and a reload is the one reset that clears all of them.
It also picks up new deploys. A wall display that never reloads may run a bundle that is weeks old and speaks an older version of the socket protocol. Doing it at a planned, quiet moment is far better than the browser tab crashing during a night shift.
Check your understanding · Exercise 12
A wall display's memory grows by about 40 MB a day. Which are likely causes in a dashboard like this? Select all that apply.
Accessibility: charts, alarms and the site tree
A canvas chart is a picture, and a colour-coded fleet map is a puzzle for anyone with colour blindness. Operators include people using screen readers, magnifiers and keyboards, and alarm consoles are safety tools, so this part is not optional.
Charts and states that do not depend on sight or colour
- Text alternative per chart: next to every canvas, a short text summary updated at most every 10 s: "Supply temperature, 6.2 degrees Celsius, 30 s ago. Last hour: min 5.1, max 9.8 at 13:42, rising. 1 alarm firing." Computed from the same buckets the chart draws. A "View as table" button shows the buckets as a real table.
- Not by colour only: each status has a shape, an icon and a word (chapter 6), and alarm bands on charts use a pattern as well as a colour. The visual-accessibility lesson covers contrast.
- Announcements with limits: one polite live region for new critical alarms, summarised per batch ("3 new critical alarms in Building 2") and limited to one announcement every few seconds. Never announce value ticks.
- Keyboard tree: the site tree follows the APG tree view pattern (arrow keys move and expand, typeahead jumps by name), and each node's name includes its roll-up ("Floor 3, 2 alarming, 1 offline"). The aria-patterns lesson and the tree-view guide go deeper.
- Reduced motion: with
prefers-reduced-motion, charts update without scrolling animation and alarm badges do not pulse.
Whose time zone, which unit
A remote operations centre in one city watches plants in three others. "The alarm fired at 02:00" must say whose 02:00. The default is site-local time for anything about a place (shift charts, alarm logs, daily rollups), because that is what people on site use. The operator can switch to their own time zone, and tooltips can show both.
Daily rollups are cut at site-local midnight on the server, so a "daily max" means the same day for everyone, including on days when daylight saving time makes a day 23 or 25 hours long.
Format with Intl.DateTimeFormat and Intl.NumberFormat using the operator's locale and the site's timeZone (see locale-formatting). Units are stored once per series in SI or the device's native unit and converted only for display, with a preference per operator (Celsius or Fahrenheit, bar or psi). Each value is formatted with the series' precision, so 21.37 does not become 21.3700008392334 after a trip through a Float32Array.
Check your understanding · Exercise 13
Which make a live chart usable for a screen reader user? Select all that apply.
Running it in the interview
In 60 minutes you cannot cover everything above. What the interviewer is checking is whether you spot the volume problem early and let it drive the design, and whether you are honest about missing and late data. A plan that works:
| Minutes | What you do | What to say out loud |
|---|---|---|
| 0 to 6 | Scope questions: sensor count, rate, retention, freshness, wall displays, writes. | "The answer to how many sensors decides whether the browser can ever see raw data." |
| 6 to 12 | Volume math on the board, next to pixels on a screen. | "4,000 samples a second in, a few hundred changed values a second useful on one screen. The gap is the design." |
| 12 to 22 | Architecture: ingest, rollups, alarm engine, BFF, socket worker, stores, render loop. | "Each boundary is where data gets smaller." |
| 22 to 32 | Subscriptions and downsampling: min and max, rollup pyramid, width in the request. | "Averages hide the spike that broke the pump." |
| 32 to 42 | Live rendering: ring buffers, rAF, canvas, memory math. | "2,000 series x 8,640 points x 4 bytes is 69 MB, allocated once." |
| 42 to 52 | Gaps, clocks, late data; alarms with hysteresis and grouping. | "No data is not zero, and a gateway outage is one alarm, not 400." |
| 52 to 60 | Transport details, wall displays, accessibility, questions. | Pick what the interviewer reacts to. |
Draw the volume funnel first, before any boxes. It is a small drawing that shows you understand the problem, and it gives you something to point at every time a later choice comes up. Then draw the architecture from right (devices) to left (pixels).
Follow-up questions to expect
Think first
An engineer wants a "raw mode" where the operator sees every sample of one sensor live at 100 Hz for commissioning. How would you support it?
As a separate, explicit subscription form (form: "raw") limited to a handful of series per connection and a time limit, with the BFF enforcing the cap. 100 samples per second for one series is small; the danger is only if it can be applied to many.
On the client, raw samples go into their own ring at the full rate, and the chart thins them with min and max per pixel column at draw time. Parsing stays in the worker.
Think first
The customer grows from 20,000 to 200,000 sensors. What changes in the frontend?
Almost nothing, and that is the point of screen-driven subscriptions: live traffic depends on what is visible. The changes are on the server (more ingest, more rollup work, more BFF memory for counters).
The frontend changes are at the edges: the device list relies even more on server-side filtering, the map needs server-side clustering at low zoom, and the tree needs search because nobody can browse 200,000 leaves.
Think first
A site's internet link goes down. Operators on site still need the dashboard. What would you propose?
That needs a local deployment: an edge server on site (AWS IoT SiteWise Edge and similar products exist for this) that runs ingest, rollups and the BFF for that site, and serves the same web app on the local network.
Because the client only talks to a BFF with a fixed protocol, the same frontend works against the cloud or the edge. When the link comes back, the edge uploads its data, and cloud dashboards see it as a backfill with revisions, exactly as in chapter 7.
Summary
- Do the volume math first: 4,000 to 50,000 samples per second come in, while one screen uses a few thousand numbers to draw and a few hundred changed values per second after that. Never stream the raw firehose to the browser.
- Subscribe to the screen: visible series, at the form and resolution the view needs, debounced while scrolling, with a short grace period and an immediate snapshot.
- Downsample to about one bucket per pixel. Use min and max (plus sum and count) so spikes survive and rollups merge; keep LTTB for presentation.
- Precompute rollups at 1 m, 15 m, 1 h and 1 d, pick one by range / width like a tile pyramid in time, and merge whole groups of its buckets so the client gets at most one per pixel.
- Live charts are ring buffers of typed arrays drawn on canvas once per frame, paused when hidden. Do the memory math: Float32 for values, one shared time axis.
- No data is not zero: draw gaps, show the age of every value, use device time to plot and receive time for liveness, and repair late data with revisions.
- Alarms live on the server with pending periods and hysteresis, grouped by parent to stop storms, acknowledged with versions and synced to every operator.
- Wall displays need bounded memory, reconnect forever, a wake lock, a heartbeat watchdog and a planned daily reload.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- AWS IoT SiteWise: GetAssetPropertyAggregates Rollup resolutions (1m, 15m, 1h, 1d), aggregate types and quality values.
- Grafana: Query and transform data Max data points defaults to the panel's width in pixels.
- Sveinn Steinarsson: Downsampling Time Series for Visual Representation (2013) The master's thesis that introduced LTTB.
- Jugel and others: M4, a Visualization-Oriented Time Series Data Aggregation (VLDB 2014) Min, max, first and last per pixel column, and why it draws the same line as the raw data.
- flot-downsample: reference LTTB implementation The algorithm in JavaScript, with its known limitations.
- Grafana: Time series visualization The connect null values option: never, always, or below a threshold.
- Grafana: Alert rule evaluation Pending period and keep firing for, the debounce settings of an alert rule.
- uPlot A small Canvas 2D time series chart and its benchmarks.
- MQTT Version 5.0 (OASIS) Includes the WebSocket transport section.
- MDN: Screen Wake Lock API
- MDN: OffscreenCanvas
- WAI-ARIA APG: Tree View Pattern
- web.dev: Interaction to Next Paint
Concepts behind it
- WebSocketsOne long-lived connection, opened by an HTTP handshake, on which browser and server can each send a message at any moment. The usual pick for chat, multiplayer, and collaboration, and the one where you own the protocol, reconnects, and scaling.
- Choosing a real-time techniqueHow to pick between short polling, long polling, server-sent events, WebSockets, and WebRTC for each feature, starting from who talks, how often, and how fresh the data must be, then checking scale, proxies, battery, and the fallback you will ship.
- Backend for frontend (BFF)A small server, usually owned by the frontend team, that gathers data from many backend services and returns exactly what one screen of one kind of client needs.
- List virtualizationRendering only the rows the user can see in a long list, so a list of 100,000 items costs about as much as a list of 30.
- 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.
- Memory in long-lived appsWhy single-page apps that stay open for hours fill up memory, how the garbage collector decides what to keep, the classic leaks, and how to find them with heap snapshots and keep them out with budgets.
- State machines for UIModel a flow as a fixed set of states and the events that move between them, so impossible combinations like "loading and failed at once" can't happen and every button does the right thing in every state.
- 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.
- Debounce and throttleTwo small timing tools that turn a flood of events (keystrokes, scrolls, resizes) into a few calls you can afford: debounce waits for a pause, throttle runs at a steady rate.
- Retries, timeouts, and backoffHow a client survives a flaky network and a struggling server: give every request a deadline, retry only the failures that can heal, space the retries out with exponential backoff and jitter, and stop retrying when the server or the network is clearly down.
- ARIA widget patternsThe tested recipes for custom widgets from the ARIA Authoring Practices Guide: combobox, listbox, menu, tabs, dialog, tree, grid, disclosure, and accordion, with the roles, states, and keys each one promises, and how to pick the right one.