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”).
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.
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.” | — |
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
isErrortogether with retaineddataafter a failed refetch, plusdataUpdatedAt. Show the stale banner whenisError && data. - SWR keeps
dataand setserror; 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.
Related
Permalink to "Related"- Announcing data load errors — when there is no data to show
- Announcing dashboard refreshes — many widgets, one voice
- Time element and relative timestamps — writing timestamps people can hear