Skip to content
KONIGI

Dashboards / Meta information / Freshness indicator

4 of 6

Freshness indicator

The viewer is about to act on a number and doesn't know how old it is.

Updated September 10, 2026

Problem

Someone is about to page an engineer, approve a refund, or tell a room of people what the number is. They can see the value. They cannot see whether it describes right now or twenty minutes ago, and the two lead to different decisions.

Solution

There are three different times involved, and most dashboards surface at most one of them:

  1. When the page last ran its queries
  2. When the underlying data last arrived
  3. What window the value actually covers

The first is easy and nearly useless on its own. A dashboard set to refresh every thirty seconds is giving the viewer feedback about the dashboard, not about the data, and a refresh that succeeds against a stalled pipeline produces a confidently current-looking page full of old numbers.

The second is the one that matters and the one that fails quietly. Prometheus is precise about this: an instant query returns the newest sample less than the lookback period ago, five minutes by default, tunable with --query.lookback-delta. So a value that arrived four minutes ago and one that arrived four seconds ago render identically. Nothing on the panel distinguishes them.

The failure mode at the far end is better behaved. When a target goes away, its series is marked stale, no value is returned, and it disappears from the graph at its last collected sample. Absence is at least visible. The dangerous case is the middle: a source that is degraded rather than dead, still inside the lookback window, still drawing.

Nielsen’s first heuristic is the general form. Keep people informed about what is going on, through appropriate feedback, within a reasonable amount of time. On a dashboard the thing they need informing about is the data’s age, not the page’s.

Use when

Any number someone will act on inside minutes, and any number shown on a screen the viewer didn’t personally load. Wallboards especially, because nobody in the room knows when the tab was opened.

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 timestamps read well and depend on the viewer’s clock, which on a wallboard PC nobody has looked at in a year is its own hazard. Absolute timestamps are honest and force a timezone decision. A green “live” dot is the most common lie in this pattern: it usually means a socket is open, not that data is arriving, and it survives exactly the failure it appears to rule out. And the freshness display can itself go stale, which is the joke that writes itself and still ships.

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 answers where, which is the other half of the provenance question. Error and stale state is what should happen once the answer to “how old” becomes “too old”. Time-range picker sets the window the number covers, which is the third of the three times above. Loading state is the moment before this one. KPI tile is the container that most often shows a number with no age attached to it at all.

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

Captures whose hotspots reference this pattern, grouped by product and dated. The dashed boxes are this pattern; hover any box for the note.

Grafana

The reference implementation for panel grids, template variables, and stat panels; most other tools are defined by how they differ from it.

Where The Cool Dashboards Live / Chicago L Trains September 10, 2026 Grafana Play (signed out; no version string exposed) sparse · dark · wall
A live departures board for one platform, built out of stat panels against the CTA Train Tracker API, and it is the clearest wallboard in the gallery: four rows, type large enough to read across a concourse, no chrome inside the board and nothing to hover. The next train gets three times the height of the three behind it, which is the layout saying "start here" without a word of copy. The interesting problem is the colour. Pink, Green and Orange are the CTA's own line colours, inherited from the world outside the screen and already known to every rider—which is the best possible reason to use a palette. But the status chip beside them is also green when a train is on time, so on the second row "Cottage Grove" is green because it is the Green Line and "On Time" is green because it is on time, side by side, meaning two unrelated things.
  • Wallboard mode The next train, three times the height of the rest. Readable at ten feet with no pointer.
  • Panel grid Rows two to four at a third the size. Panel size is carrying the priority.
  • Semantic status color Green for on time, next to green for the Green Line. One hue, two unrelated jobs, one row.
  • Freshness indicator A countdown rather than a clock time, which is the right call and hides how old the feed is.
  • Data table The raw feed under the board, including the rgb string each line colour came from.
Show 3 more examples Hide the rest

Grafana

The reference implementation for panel grids, template variables, and stat panels; most other tools are defined by how they differ from it.

Linux node / fleet overview September 9, 2026 Grafana Play (signed out; no version string exposed) medium · dark · desktop-web
Captured while Play's demo data source was returning nothing, which makes this a better example of empty and error states than of the fleet overview it is meant to be. Three things are worth noticing. The top-left tile is red and reads "No metrics received - Check configuration", which is an actual diagnosis and the best thing on the page. The tile beside it exists to report when data last arrived, and it says "No data", so the freshness indicator has no freshness to report and doesn't say why. And every chart below says "No data" while the network panel says "No errors". A viewer scanning this page cannot tell from the words alone whether the network is clean or whether it is as unknown as everything else, which is the exact confusion the empty-state pattern exists to prevent.

Honeycomb

Query-first; heatmaps and BubbleUp replace the dashboard-of-panels model with draw-a-region cross-filtering.

Honeycomb — Query / HEATMAP(duration_ms)
Heatmap. Linear y-axis, so the band holding most requests is six percent of the panel height and the empty top half gets the rest. Search across panels. The schema is the navigation, and it opens with a filter box rather than a tree you're expected to browse. Dashboard builder. No panel to configure—the query is the page. WHERE trace.parent_id does-not-exist is how you say root spans only. Tabs as genres. Five readings of one result: overview, BubbleUp, correlations, traces, raw. Each tab is a different question, not a different subject. Freshness indicator. When data last arrived, not when the page last ran. The one of the three ages that actually matters. Time-range picker. Absolute, with the granularity stated beside it, and arrows that step to the previous window rather than retyping it. Data source badge. Elapsed query time and 11,710,335 rows examined. The panel reporting its own cost and scope, which almost nothing else does.
Query / HEATMAP(duration_ms) September 10, 2026 Honeycomb sandbox, public dataset (signed out; no version string exposed) medium · light · desktop-web
My heatmap entry cites Honeycomb as the argument for log-scale y-buckets, so it's worth recording that this is Honeycomb's own sandbox rendering the same chart on a linear axis. The result is the failure the argument warns about: the ticks run 0 to 3500 evenly, the dense band where almost every request actually lives is squashed into the bottom sixth of the panel, and the top half is mostly empty. What survives anyway is the thing a percentile can't tell you. The solid band under a second doesn't move across the whole window, while from about 07:00 a separate purple tail climbs to 3000ms and keeps going. Two populations, one of them fine and one of them deteriorating. A p95 line over this data would have risen and said nothing about which. The other thing worth stealing: the footer reports elapsed query time and that it examined 11,710,335 rows, so the panel tells you what it cost and how much it looked at. Cookie banner and a no-signup onboarding modal were removed to take the shot; nothing of the product's own UI was.
  • Heatmap Linear y-axis, so the band holding most requests is six percent of the panel height and the empty top half gets the rest.
  • Dashboard builder No panel to configure—the query is the page. WHERE trace.parent_id does-not-exist is how you say root spans only.
  • Tabs as genres Five readings of one result: overview, BubbleUp, correlations, traces, raw. Each tab is a different question, not a different subject.
  • Search across panels The schema is the navigation, and it opens with a filter box rather than a tree you're expected to browse.
  • Time-range picker Absolute, with the granularity stated beside it, and arrows that step to the previous window rather than retyping it.
  • Data source badge Elapsed query time and 11,710,335 rows examined. The panel reporting its own cost and scope, which almost nothing else does.
  • Freshness indicator When data last arrived, not when the page last ran. The one of the three ages that actually matters.

Netdata

Per-second charts, hundreds per node, with a per-chart anomaly ribbon instead of a band on the series.

Netdata — Metrics / System
Header KPI strip. Twelve tiles in two rows, in four different layouts. It reads as twelve things rather than one strip. Sidebar and canvas. The rail is the dashboard. A generated tree of every metric family the agent found. Gauge and dial. Arcs for disk reads and writes, where the maximum is invented—this is a rate, not a capacity. Dashboard builder. A query builder inline in the panel header: group by, aggregation, node and dimension count. Search across panels. 720 charts, so search is the navigation and the tree is the filing system nobody browses. Semantic grouping. Sections generated by the collector rather than chosen. Consistent, and about nothing in particular. Compare periods. Offered per chart rather than per dashboard, which is the scoping trap: one panel shifted, the rest not. Freshness indicator. Live 1, Stale 8. Most products would have drawn all nine and said nothing.
Metrics / System September 10, 2026 Netdata Agent v2.10.0-686-nightly (public registry node, signed out) dense · dark · desktop-web
Structurally the opposite of Grafana, and worth the comparison. Nobody built this page. The right rail says "showing 720 of total 720 charts" and the tree beneath it—System, Compute, Memory, Storage, Network, Hardware, Processes, then Apps, Users, Groups, O/S Services, and every application it found—is generated from what the agent collects. The canvas is the same hierarchy rendered downward, four levels deep, headings prefixed with dashes: System, then Compute, then CPU, then the chart. There is no editorial layer at all, which means nothing is missing and nothing is prioritised. The header also does something most products won't: it reports "Live 1, Stale 8" beside the node count, so eight of the nine machines behind this view are not currently reporting and the page says so rather than drawing their last known values.
  • Sidebar and canvas The rail is the dashboard. A generated tree of every metric family the agent found.
  • Search across panels 720 charts, so search is the navigation and the tree is the filing system nobody browses.
  • Header KPI strip Twelve tiles in two rows, in four different layouts. It reads as twelve things rather than one strip.
  • Gauge and dial Arcs for disk reads and writes, where the maximum is invented—this is a rate, not a capacity.
  • Semantic grouping Sections generated by the collector rather than chosen. Consistent, and about nothing in particular.
  • Freshness indicator Live 1, Stale 8. Most products would have drawn all nine and said nothing.
  • Dashboard builder A query builder inline in the panel header: group by, aggregation, node and dimension count.
  • Compare periods Offered per chart rather than per dashboard, which is the scoping trap: one panel shifted, the rest not.