Skip to content
KONIGI Patterns

Dashboards / Labels and context / Freshness indicator

‹ 4 of 6 ›

Freshness indicator

People need to know how old the data is before acting on it.

Updated September 10, 2026

Problem

People can see a value but may not know whether it describes the current situation or an earlier one.

Solution

Show when the underlying data was last updated, alongside the period the value covers. Keep this distinct from the time the page last refreshed.

Use a readable age label and provide the exact timestamp and timezone when needed. Mark data as stale when it exceeds the expected delay for that source.

Only use a live indicator when it reflects arriving data. A working connection or successful page refresh does not establish that the measurements are current.

Use when

People act on recent values or read a screen they did not load themselves, especially a wallboard.

Don’t use when

Never skip it outright, but scale it down. On a monthly strategic report the cadence is understood and a loud “updated 4 seconds ago” is noise pretending to be rigour.

Trade-offs

Relative times are easy to scan but depend on reliable clocks and updating labels. Exact timestamps need a timezone. Freshness checks can fail too, so an uncertain status should not appear current.

Checklist

  • Does this show when the data arrived, or only when the page refreshed?
  • If the source went quiet five minutes ago, what does the panel look like? What about an hour?
  • Is a stale value visually distinguishable from a current one, or only from a missing one?
  • What is the expected arrival cadence, and does the indicator know it well enough to say “late”?
  • Does a “live” indicator track data arriving, or just a connection being open?
  • Relative or absolute time, and whose clock and timezone is it using?
  • On a wallboard, can someone across the room tell whether the page is current?
  • Does auto-refresh mask staleness by making the page feel alive?
  • When a query fails, does the last good value stay on screen, and is it labelled as last-good?
  • Who notices if the freshness indicator itself stops updating?

Compare

Grafana puts a refresh interval on the dashboard and reports on itself: the control tells you the page will re-query every thirty seconds, and says nothing about whether anything new arrived when it did. Netdata sidesteps the display entirely by drawing per second, so freshness is legible as motion and a frozen chart is its own alarm. Sentry works in events rather than samples, so “last seen” is a property of the issue and travels with it into the list, the detail view, and the alert. Honeycomb makes the question mostly disappear by being query-first: you asked for a window, the answer is that window, and there is no ambient “now” quietly drifting underneath the result.

Data source badge identifies where values came from. Error and stale state marks data that is too old or unavailable. Time-range picker defines the displayed period.

Loading state covers an active request. KPI tile often needs a visible data age.

Freshness indicator anatomy One time axis carrying three different ages: when the page last ran its queries, when the data last arrived, and what window the value covers. A shaded lookback region shows that a sample four seconds old and one four minutes old render identically. Three different ages, one number now lookback window the window the value covers 3 last sample arrived, 4m ago 2 page last ran its queries 1 -15m -8m 0 1 WHEN THE PAGE RAN Easy, and nearly useless alone. A refresh that succeeds against a stalled pipeline gives you a current-looking page of old numbers. 2 WHEN THE DATA ARRIVED The one that matters and the one that fails quietly. Anywhere inside the lookback window, four seconds and four minutes draw exactly the same. 3 WHAT THE VALUE COVERS The span being summarised, which is not the same as either of the other two. A dead source disappears from the chart. A degraded one keeps drawing.
Wireframe — the pattern's anatomy, not any one product's version of it

Implementation

Most freshness indicators report on the page, not the data. A refresh that succeeds against a stalled pipeline produces a confidently current-looking screen full of old numbers.

Tokens
--foreground--muted--muted-foreground--border--status-warn--state-live--state-stale--status-critical--status-unknown

last sample arrived, 4m ago· late

lookback windownowthe window the value coverslast sample arrived, 4m agopage last ran its queries-15m-8m0

FreshnessIndicator.tsxThe state line, then the three ages on one axis: when the page ran, when the data arrived, and what the value covers.

import { freshness, FRESHNESS_COLOR } from "./freshness";

/** Prometheus's default lookback: an instant query returns the newest sample
 *  younger than this, so anything inside it draws as if it were current. */
const LOOKBACK_MS = 5 * 60_000;

const ago = (ms: number) => {
  const s = Math.round(ms / 1000);
  if (s < 60) return `${s}s ago`;
  const m = Math.round(s / 60);
  return m < 60 ? `${m}m ago` : `${Math.floor(m / 60)}h ${m % 60}m ago`;
};

/**
 * Three ages on one axis. The page's own refresh is reported, but it is the
 * last of the three, because a query that succeeds against a stalled pipeline
 * is not news about the data. The state comes from when the newest sample
 * arrived against the cadence it was expected at.
 */
export function FreshnessIndicator({ lastArrival, expectedEveryMs, lastQuery, covers, lookbackMs = LOOKBACK_MS, now, width = 640 }: {
  /** When the newest sample arrived. Null when nothing has ever arrived. */
  lastArrival: Date | null;
  expectedEveryMs: number;
  /** When the page last ran its queries. Not the data's age. */
  lastQuery: Date;
  /** The span the value summarises, which is neither of the other two. */
  covers: { from: Date; to: Date };
  lookbackMs?: number;
  /** The clock to age against. Pass one to render on a server. */
  now?: Date;
  width?: number;
}) {
  const clock = now ?? new Date();
  const { state, ageMs } = freshness(lastArrival, expectedEveryMs, clock);
  const color = FRESHNESS_COLOR[state];

  // The axis runs from the start of the covered window to now.
  const span = clock.getTime() - covers.from.getTime();
  const x = (d: Date) => 80 + ((d.getTime() - covers.from.getTime()) / span) * (width - 100);
  const minutesBefore = (d: Date) => Math.round((clock.getTime() - d.getTime()) / 60_000);
  const mid = new Date(covers.from.getTime() + span / 2);
  const lookbackStart = new Date(clock.getTime() - lookbackMs);

  return (
    <div className="text-xs">
      <p className="flex items-center gap-2">
        <span className="size-2 rounded-full" style={{ background: color }} />
        <span className="text-foreground">
          {lastArrival ? `last sample arrived, ${ago(ageMs!)}` : "no sample has arrived"}
        </span>
        <span className="text-muted-foreground">· {state}</span>
      </p>

      <svg viewBox={`0 0 ${width} 176`} className="mt-2 w-full" aria-label="the three ages of this value">
        <rect x={x(lookbackStart)} y={20} width={x(clock) - x(lookbackStart)} height={120} className="fill-status-warn/10" />
        <line x1={x(clock)} y1={20} x2={x(clock)} y2={150} className="stroke-status-warn" strokeWidth={2} />
        <text x={(x(lookbackStart) + x(clock)) / 2} y={12} textAnchor="middle" className="fill-muted-foreground text-[9px]">lookback window</text>
        <text x={x(clock)} y={12} textAnchor="middle" className="fill-muted-foreground text-[9px]">now</text>

        <text x={x(covers.from)} y={38} className="fill-muted-foreground text-[9px]">the window the value covers</text>
        <rect x={x(covers.from)} y={46} width={x(covers.to) - x(covers.from)} height={14} className="fill-muted" />

        {lastArrival && (
          <>
            <circle cx={x(lastArrival)} cy={90} r={5} style={{ fill: color }} />
            <text x={x(lastArrival) + 12} y={94} className="fill-muted-foreground text-[9px]">last sample arrived, {ago(ageMs!)}</text>
          </>
        )}

        <circle cx={x(lastQuery)} cy={122} r={5} className="fill-muted-foreground" />
        <text x={x(lastQuery) - 12} y={126} textAnchor="end" className="fill-muted-foreground text-[9px]">page last ran its queries</text>

        <line x1={0} y1={140} x2={width} y2={140} className="stroke-border" />
        {[covers.from, mid, clock].map((d) => (
          <text key={d.getTime()} x={x(d)} y={160} textAnchor="middle" className="fill-muted-foreground text-[9px]">
            {minutesBefore(d) ? `-${minutesBefore(d)}m` : "0"}
          </text>
        ))}
      </svg>
    </div>
  );
}

freshness.tsTakes the arrival time, not the fetch time, and knows the expected cadence.

/**
 * Freshness is a property of the data, not of the request that fetched it.
 *
 * The common implementation reports when the page last re-queried, which stays
 * cheerful while a pipeline is stalled. This takes the arrival time of the
 * newest sample and the cadence it is expected at, so "late" is something the
 * component can work out rather than something a human notices eventually.
 */
export type Freshness = "live" | "late" | "stale" | "unknown";

export function freshness(
  lastArrival: Date | null,
  expectedEveryMs: number,
  now = new Date(),
): { state: Freshness; ageMs: number | null } {
  if (!lastArrival) return { state: "unknown", ageMs: null };
  const ageMs = now.getTime() - lastArrival.getTime();
  // One missed interval is late. Three is stale—the difference matters,
  // because late is worth a glance and stale means stop trusting the number.
  if (ageMs > expectedEveryMs * 3) return { state: "stale", ageMs };
  if (ageMs > expectedEveryMs) return { state: "late", ageMs };
  return { state: "live", ageMs };
}

export const FRESHNESS_COLOR: Record<Freshness, string> = {
  live: "hsl(var(--state-live))",
  late: "hsl(var(--state-stale))",
  stale: "hsl(var(--status-critical))",
  unknown: "hsl(var(--status-unknown))",
};

demo.tsxHow it is called: a sample four minutes old on a two-minute cadence, a page that just refreshed, and a fixed clock.

import { FreshnessIndicator } from "./FreshnessIndicator";

/**
 * The page ran its queries just now and got a value that covers the last
 * quarter hour. The newest sample is four minutes old against a two-minute
 * cadence, so the state is late: inside the lookback window, still drawing,
 * and the page alone would never say so. The clock is fixed so the ages render
 * the same on the server and in the browser.
 */
const NOW = new Date("2026-09-15T09:00:00Z");
const minutesAgo = (m: number) => new Date(NOW.getTime() - m * 60_000);

export default function Demo() {
  return (
    <FreshnessIndicator
      lastArrival={minutesAgo(4)}
      expectedEveryMs={2 * 60_000}
      lastQuery={NOW}
      covers={{ from: minutesAgo(15), to: minutesAgo(4) }}
      now={NOW}
    />
  );
}
What it renders. Identical markup in both panes, with only the token values changing.

Examples

Screenshots are grouped by product and include the capture date. Dashed boxes highlight this pattern. Notes appear below each screenshot and when you hover over a box.

Grafana

Examples of panel grids, shared selectors, stat tiles, and chart configuration.

Where The Cool Dashboards Live / Chicago L Trains September 10, 2026 Grafana Play (signed out; no version string exposed) sparse · dark · wall
The departure board gives the next train more space than later arrivals and uses large text without hover-only details. Route colors connect the display to the train lines people already know. Green also appears in the On Time status, so the same hue has two meanings in one row. The status label helps distinguish them. Countdown values are useful, but the board also needs a clear indication of feed age.
  • Wallboard mode The next departure receives the largest text and panel.
  • Panel grid Later departures use smaller rows, making their lower priority visible.
  • Semantic status color Green identifies both a route and an on-time status; the words distinguish the meanings.
  • Freshness indicator A countdown shows time until arrival but does not state the feed’s age.
  • Data table The raw feed includes the color value for each route.
Show 3 more examples Hide the rest

Grafana

Examples of panel grids, shared selectors, stat tiles, and chart configuration.

Linux node / fleet overview September 9, 2026 Grafana Play (signed out; no version string exposed) medium · dark · desktop-web
The data source was returning no measurements when this overview was captured. A red tile says no metrics were received and suggests checking configuration. The freshness tile says No data, while another panel says No errors. Those messages need clearer distinctions: missing measurements do not establish that a system has no errors.

Honeycomb

Query-driven views use heatmaps and selected regions to investigate differences between requests.

Query / HEATMAP(duration_ms) September 10, 2026 Honeycomb sandbox, public dataset (signed out; no version string exposed) medium · light · desktop-web
This sandbox heatmap uses a linear vertical scale. Most requests sit near the bottom, while a separate group of slower requests extends upward later in the period. The view exposes variation that a single summary value would hide, though a different scale could give the lower band more room. The footer reports query duration and rows examined. A cookie banner and onboarding dialog were removed for the capture; the dashboard itself was left intact.

Netdata

A large collection of system charts with per-chart controls and anomaly-rate views.

Metrics / System September 10, 2026 Netdata Agent v2.10.0-686-nightly (public registry node, signed out) dense · dark · desktop-web
The navigation reports 720 charts organized by the metrics collected from the system. The main page follows the same nested structure, giving broad coverage but little indication of which charts matter most to a particular task. Search helps navigate that volume. The header also distinguishes live and stale nodes, making missing updates visible instead of presenting every node as current.