Stale and Partial Data Indicators

Permalink to "Stale and Partial Data Indicators"

Dashboards and operational grids often keep showing the last good data when a refresh fails. That is usually the right call — an empty table helps nobody — but only if users know the numbers are old. Sighted users get a faint grey overlay or a small clock icon; screen reader users get nothing, and they will make decisions on yesterday’s stock levels as if they were live. Partial data has the same problem: three of five regions loaded, and the totals row silently sums only those three.

This guide shows how to make staleness and incompleteness perceivable to everyone. It belongs to empty, error & no-results states.

Spec reference

Permalink to "Spec reference"
  • SC 1.4.1 Use of Color (A) — greyed-out stale rows are colour-only; add text or an icon with a name.
  • SC 1.3.1 Info and Relationships (A) — a “stale” state on a row must be programmatically associated with it, for example by text in a cell or by aria-describedby.
  • SC 4.1.3 Status Messages (AA) — the change from live to stale, or from partial to complete, is a status message.
  • SC 1.3.3 Sensory Characteristics (A) — instructions such as “greyed rows are outdated” rely on visual characteristics and need a text equivalent.

The time element with a datetime attribute gives a machine-readable timestamp; screen readers read its text content, so the visible text must be meaningful (“Updated 09:42, 6 minutes ago”).

Three levels of staleness signalling Three stacked levels: grid level with a banner and last-updated time; row level with a text marker per stale or missing row; cell level for aggregate values computed from incomplete data. Three levels of staleness signallingGrid levelbanner: "Data may be out of date. Last updated 09:42." Announced onceRow level"Not refreshed" text and named icon on rows from a failed sourceCell leveltotals computed from partial data marked "partial" with a description
Use the broadest level that is true; do not mark every cell when the whole grid is stale.

When to use — and when not to

Permalink to "When to use — and when not to"

Use a grid-level indicator whenever the most recent refresh failed or is overdue by more than the refresh interval. Use row-level markers when data comes from several sources and only some failed — a multi-region inventory, for instance. Mark aggregates as partial whenever any contributing row is missing.

Do not mark data as stale on every refresh cycle. A grid that refreshes every 30 seconds should not announce “Updated” every 30 seconds; the timestamp is visible for those who want it, and announcements are reserved for state changes — fresh to stale and back.

The misapplication: greying out stale rows with opacity: 0.5. It is colour-only, it usually drops text below 4.5:1 contrast, and it is invisible in forced colours mode.

Opacity-only staleness versus text markers Comparison: reduced opacity is colour-only, fails contrast and disappears in forced colours; text markers with named icons are announced, keep contrast and survive forced colours. Opacity-only staleness versus text markers✗ Rows greyed with opacityColour-only (fails 1.4.1)Text drops below 4.5:1Invisible in forced coloursSilent for screen readers✓ Text marker and named iconText: "Not refreshed since 09:42"Normal contrast keptText survives every themeRead with the row

Annotated code example

Permalink to "Annotated code example"
<div class="grid-banner" id="stale-banner" hidden>
  <!-- Icon decorative; text carries meaning (SC 1.4.1) -->
  <svg viewBox="0 0 20 20" aria-hidden="true" focusable="false">…</svg>
  Data may be out of date. Refresh failed; showing data from
  <time datetime="2026-10-02T09:42">09:42</time>.
  <button type="button">Refresh now</button>
</div>

<p class="last-updated">Last updated <time id="updated-at" datetime="2026-10-02T09:42">09:42</time></p>
<p id="grid-status" role="status" class="visually-hidden"></p>

<table aria-describedby="updated-at">
  …
  <tr>
    <th scope="row">North region</th>
    <td>1,204</td>
    <!-- Row-level marker as text in a cell (SC 1.3.1) -->
    <td><span class="stale-tag">Not refreshed since 09:42</span></td>
  </tr>
  <tr class="total">
    <th scope="row">Total <span class="partial-tag">(partial: 2 of 3 regions)</span></th>
    <td>2,480</td><td></td>
  </tr>
</table>
let wasStale = false;
function onRefreshResult(ok) {
  banner.hidden = ok;
  if (!ok && !wasStale) status.textContent = 'Data may be out of date. Refresh failed.'; // once
  if (ok && wasStale)  status.textContent = 'Data refreshed. Now up to date.';            // once
  wasStale = !ok;
}

Keyboard & AT behaviour

Permalink to "Keyboard & AT behaviour"
Event Expected announcement AT deviations
Refresh fails “Data may be out of date. Refresh failed.” (polite) —
Next refresh fails too Nothing (already stale) —
Reading a stale row “North region, 1,204, Not refreshed since 09:42” —
Reading the total “Total (partial: 2 of 3 regions), 2,480” VoiceOver reads parentheses as a pause
Recovery “Data refreshed. Now up to date.” —
Announce transitions, not cycles Chain of grid states fresh, stale, still stale, fresh; only the two transitions are announced, the repeated stale cycle is silent. Announce transitions, not cyclesFreshsilent refresh every30 sStalebanner shownStill staletimestamp unchangedFreshbanner hiddenrefresh fails: announcefails again: silentsucceeds: announce
Announcements map to state changes; the visible timestamp covers everything else.

Integration context

Permalink to "Integration context"

Stale indicators are the softer sibling of announcing data load errors in grids: use the error state when there is nothing to show, and stale indicators when old data is still useful. For dashboards that refresh many widgets, coordinate the messages with announcing dashboard refreshes. Totals marked partial follow the summary-row conventions in announcing totals and summary rows.

Gotchas

Permalink to "Gotchas"

Relative times that never update. “6 minutes ago” rendered once becomes a lie. Update relative text on a timer, but do not put it inside a live region — only the visible text should change.

Time zones. “09:42” is ambiguous for distributed teams. Include the zone when users span zones, and keep the datetime attribute in ISO 8601 with offset.

Stale banners that steal focus. A banner is information, not a dialog. Never move focus to it automatically.

Design system notes

Permalink to "Design system notes"

Provide a DataFreshness component that takes lastUpdated, status and partialSources, renders the timestamp, banner and tags, and owns the transition announcements. Products then pass state rather than inventing indicators, and the component guarantees the text equivalents and forced-colours styles.

Framework notes

Permalink to "Framework notes"

Most data-fetching libraries already know when data is stale; surface that knowledge instead of tracking it twice.

  • TanStack Query exposes isError together with retained data after a failed refetch, plus dataUpdatedAt. Show the stale banner when isError && data.
  • SWR keeps data and sets error; the same rule applies.
  • Apollo Client with errorPolicy: 'all' returns partial data and errors together — map the errors to the sources that failed and mark only those rows.
const { data, isError, dataUpdatedAt } = useQuery({ queryKey: ['inventory'], queryFn: load });
const stale = isError && !!data;                         // old data still on screen
<DataFreshness lastUpdated={new Date(dataUpdatedAt)} stale={stale} />

Testing checklist

Permalink to "Testing checklist"

FAQ

Permalink to "FAQ"
Should stale data be announced every time the user focuses the grid?

No. Associate the timestamp with the grid through aria-describedby so it is available on demand, and announce only transitions. Repeating the message on focus becomes noise quickly.

Is a grey row ever acceptable?

As an additional cue, yes, if text contrast stays at 4.5:1. It must not be the only indication.

How do I mark a single cell value as estimated or partial?

Put a short text marker next to the value, or an abbreviation with a visible legend, and reference a fuller explanation with aria-describedby. Asterisks alone are not enough unless the footnote is programmatically linked.

How stale is stale?

Define it relative to the refresh interval: if data refreshes every 30 seconds, it is stale after a failed refresh or after about two missed intervals. For daily batch data, stale means “yesterday’s batch has not arrived”. Write the threshold down in the component’s documentation so every team uses the same rule.

Should stale data be disabled for editing?

Often, yes, because edits against outdated values can overwrite newer changes. If you block editing, say why in the cell’s description (“Read-only until data refreshes”) rather than silently ignoring keystrokes.

Permalink to "Related"

← Back to Empty, Error & No-Results States