An e-ink status dashboard
RECIPEA dashboard is the most common reason to put an e-paper panel on a wall, and the most common way to get it wrong. The failure mode is never the layout — it is a refresh loop that asks the panel for more than it can deliver, so the display spends its life mid-flash.
The shape of the problem
A wall dashboard is read from three metres away, glanced at rather than studied, and never interacted with. That is a gift: no hover states to lose, no touch targets to size, no scrolling. What it does demand is that the display be trustworthy — a frozen dashboard showing four-hour-old numbers is worse than a blank one, because nobody can tell.
So the build has three requirements: readable at distance, cheap to update, and obviously stale when the data stops arriving.
Layout
Put every volatile number in one band. Update cost on e-paper is the bounding box of what changed, so metrics scattered across the page turn each refresh into a full-panel update. A single row of figures at the top and a static frame below is the cheapest arrangement that still looks deliberate.
<div class="ink-page">
<e-flex justify="space-between" align="baseline">
<e-title level="1">Platform</e-title>
<!-- Ages itself from the timestamp and flips to a stale state
after 90 s without a refresh. -->
<e-last-updated id="stamp"
datetime="2026-08-18T09:14:00Z"
stale-after="90"></e-last-updated>
</e-flex>
<e-divider></e-divider>
<!-- The whole volatile band. Keyed items, so a value change patches
one cell instead of rebuilding the grid. -->
<e-status-board id="board" columns="4" label="Service health"
data='[
{"key":"api", "label":"API", "value":"OK", "status":"ok"},
{"key":"queue", "label":"Queue", "value":12, "status":"warning", "detail":"depth"},
{"key":"errors", "label":"Errors/5m", "value":3, "status":"ok"},
{"key":"deploys","label":"Deploys", "value":"idle","status":"ok"}
]'></e-status-board>
<e-grid cols="2" gap="24">
<e-sparkline label="Requests/min"
values="[820,910,880,1040,1180,1120,1260]"></e-sparkline>
<e-meter label="Disk" value="68" unit="%" low="50" high="85"></e-meter>
</e-grid>
</div>
The refresh loop
One timer, one paint. Fetch everything, wait for all of it, then write once — never a write per response.
const board = document.getElementById('board');
const stamp = document.getElementById('stamp');
async function refresh() {
try {
const [health, metrics] = await Promise.all([
fetch('/api/health').then((r) => r.json()),
fetch('/api/metrics').then((r) => r.json()),
]);
// One attribute write. The component diffs by key from here.
board.setAttribute('data', JSON.stringify([
{ key: 'api', label: 'API', value: health.api ? 'OK' : 'DOWN',
status: health.api ? 'ok' : 'error' },
{ key: 'queue', label: 'Queue', value: metrics.queueDepth,
status: metrics.queueDepth > 50 ? 'warning' : 'ok', detail: 'depth' },
{ key: 'errors', label: 'Errors/5m', value: metrics.errors5m,
status: metrics.errors5m > 10 ? 'error' : 'ok' },
{ key: 'deploys', label: 'Deploys', value: health.deploying ? 'running' : 'idle',
status: 'ok' },
]));
// Only advanced on success — this is what makes staleness honest.
stamp.setAttribute('datetime', new Date().toISOString());
} catch {
// Deliberately nothing. The timestamp stays where it was and
// <e-last-updated> crosses into its stale state on its own.
}
}
refresh();
setInterval(refresh, 60_000);
The catch block doing nothing is the most important line in the recipe. Because the timestamp is only advanced on a successful fetch, a backend outage makes the display visibly stale without any error-handling UI at all. The failure state is the absence of an update, which is exactly what it is.
Choosing the interval
| Interval | Suits | Panel cost |
|---|---|---|
| 10 s | Trading, live incident war room | High — plan a clearing refresh every few minutes |
| 60 s | Most ops dashboards | Comfortable |
| 5 min | Business metrics, battery-powered | Negligible |
A minute is the sweet spot for a mains-powered wall panel. Below about 10 seconds the panel is refreshing more often than anyone reads it, and the accumulated ghosting from back-to-back fast updates starts to need clearing refreshes that flash the whole display.
Clearing ghosting
Fast partial updates leave residue. On a long-running kiosk this accumulates over hours until the panel looks grubby. The fix is a periodic full repaint, scheduled at a moment nobody is watching.
// A full-page repaint gives the controller a whole-panel damaged
// region, which selects a clearing waveform and resets the residue.
// Hourly at :00 is usually invisible in practice.
setInterval(() => {
document.body.style.visibility = 'hidden';
void document.body.offsetHeight; // force the reflow
document.body.style.visibility = '';
}, 60 * 60_000);
Pitfalls
- Do not rebuild the DOM per poll. Setting
innerHTMLon the container produces a full-panel update for a two-digit change. - Do not stagger writes. Four endpoints answering at different moments become four separate panel updates, each with its own bounding box.
- Give numbers a fixed-width slot.
font-variant-numeric: tabular-numsstops a value going from 9 to 10 reflowing the row and widening the rectangle. - Do not use colour as the only status cue.
<e-status-board>carries status as text and pattern for exactly this reason. - Watch memory on a kiosk. A display running for months needs its listeners torn down on navigation — EPaper components do this through their own cleanup contract.