An e-ink status dashboard

RECIPE

A 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

IntervalSuitsPanel cost
10 sTrading, live incident war roomHigh — plan a clearing refresh every few minutes
60 sMost ops dashboardsComfortable
5 minBusiness metrics, battery-poweredNegligible

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 innerHTML on 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-nums stops 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.