opsinjs
HealthNumbers on a screen

Uncertainty, staleness and missing data

How to render an estimate, an old reading, a partial sync and an outright absence so that a reader can tell each of them apart from a fresh measurement.

What this means

An interface that renders a number makes an implicit claim that the number is the current value. That claim is carried by the rendering rather than stated in words, and it fails whenever the value is older than the metric's useful window, derived rather than measured, incomplete, or served from a cache after a failed fetch.

Five different objects can sit behind the same tile, and nothing in the default rendering separates them:

  • A sensor reading taken forty seconds ago is a direct measurement of a recent moment.
  • A reading from a device that last synced on Tuesday is a direct measurement of a moment that has since passed.
  • A model's estimate is derived from another signal rather than measured directly.
  • The last successfully fetched value, rendered because the fetch failed while the cache was still warm, reaches the screen through the failure path rather than the success path.
  • An average presented in the visual language of a reading describes a period rather than an instant.

All five render identically wherever a product has not defined a treatment for each state, because the default rendering of every one of them is a large number in a card.

The two failures that follow from this are asymmetric in their consequences. A reader who takes a stale reassuring value for a current one does not act when they should, and a reader who takes a stale worrying value for a current one acts on a problem that has already resolved. Both arise from the same omission: the value reached the screen without its recency and its origin attached.

The worst version of this comes from a purely engineering failure. A nullish value coerced to 0 and rendered in the metric's units reaches the screen without any decision to display it having been taken, and zero is a physiologically impossible value for many health metrics and a catastrophic one for others.

The rule

A displayed value carries its recency and its origin. An absence is rendered as an absence, never as a value.

The five data states

Five data states are defined once for every data surface and are rendered so that a reader can tell them apart. The generic definitions are canonical at Data states, and the table below is the health-specific reading of them.

StateWhat it meansWhat the reader must see
FreshMeasured, current, completeThe value, its unit, when it was taken
StaleReal but older than this metric's useful windowThe value, visibly de-emphasised, with its age stated and a way to refresh
PartialSome sources reported, others did notThe value, plus which sources are missing
EstimatedDerived or modelled rather than measuredAn explicit estimate label, wider rounding, and what it is derived from
AbsentNo data, or a failed fetchNo number at all; a sentence and a next action

The ten rules

The numbering below is fixed, because these rules are cited by number in the evidence note and in the component notes. An empty Enforced by cell means that no component in the catalogue checks the rule and that compliance rests with the implementing team.

#RequirementState it governsWho declares itEnforced by
1Every health value has a timestamp available to the reader. Relative time carries the recency, as in "20 minutes ago", and the absolute time is available on demand, because relative time stops being readable once the interval is long.Fresh, Stale, Partial, EstimatedThe data layer, which supplies the time of measurementRelativeTime
2Staleness is a property of the metric. A heart rate from four hours ago is stale and a cholesterol result from four months ago is current, so each metric declares its own threshold rather than inheriting a default.The boundary between Fresh and StaleThe implementing team, per metric. opsinjs declares no freshness windowRelativeTime, against the window the metric declares
3Staleness is carried by the rendering of the value. The value itself is de-emphasised, so that a reader who does not read the caption can still tell that the reading is out of date.Staleopsinjs, which specifies the de-emphasis
4A stale value is shown with its age stated. Neither rendering it at full weight nor withholding it altogether satisfies this rule.Staleopsinjs owns the treatment. The age itself comes from the data layer
5An estimate is labelled at the point of the number. The label sits with the figure rather than in a footnote, and the figure is rounded more coarsely than a measurement, per Numbers, units and precision.EstimatedThe data layer, which marks a value as derived
6A failure is a different state from an absence, and neither is a zero. "We could not load this" is a different sentence from "You have not recorded any readings yet", and both are different from 0.AbsentThe data layer, which distinguishes a failed fetch from an empty historyValue, which refuses to render an unknown as a number
7A loading placeholder never implies a value. A skeleton reserves layout and shows no plausible number, no shape that looks like a chart, and no status colour.Precedes all fiveopsinjs, which specifies the placeholder
8Status degrades with certainty. A value whose recency, completeness or provenance the app cannot establish cannot drive an attention or urgent status, and certainty is evaluated before a status is assigned rather than after.Stale, Partial, EstimatedThe implementing team, which assigns status
9A partial result is stated rather than averaged. The surface names which sources are missing, as in "3 of your 4 devices have reported today", rather than folding the gap into a figure that means something different from yesterday's.PartialThe data layer, which reports which sources contributed
10Offline is a first-class state. The surface says that the device is offline rather than presenting the last values it received as current. See Offline and stale data.Stale, AbsentThe implementing team, which owns sync and connectivity

Why (evidence)

Applying it

Do

Put the value, its provenance and its recency on one line: "68 bpm · measured 12 minutes ago", with the absolute time available on demand.

Don’t

Show "68 bpm" with the sync timestamp on a settings screen, where a reader looking at the tile cannot tell whether the reading is from now or from Tuesday.

Do

Show "Last reading 3 days ago" with the value de-emphasised and a refresh action, when the metric's freshness window is one day, so that the tile states both the age and the way to update it.

Don’t

Keep rendering the three-day-old value at full weight because the tile looks empty otherwise. A tile with no reading inside the metric's freshness window has no current value to display, and that absence is the information the reader needs.

Do

Write "Estimated from your watch. Roughly 7 hours 30 minutes.", which puts the label before the number and rounds to a precision the source can support.

Don’t

Render "7 h 32 m" in the same typography as a measured value, which claims a precision of two minutes that the device cannot resolve and leaves the reader no way to see that the figure is derived.

Do

Write the failure and the next step into one string: "We could not load your readings. Check your connection and try again."

Don’t

Render 0 or -- where the fetch failed. 0 presents a physiologically impossible value in the metric's units as though it were a measurement, and -- names neither the failure nor the next step.

Components that implement this

RelativeTime is the enforcement point for rules 1 and 2. It takes a staleness threshold and switches from relative to absolute rendering past the declared window, so a value from last month is rendered with its date rather than as "3 minutes ago" or "just now". Value refuses to render an unknown as a number at all, which is the component-level half of rule 6. The sentence and the next action that replace the number are written by the implementing team.

What this does not cover

  • Freshness windows for any metric, which are a clinical judgement and belong to the implementing organisation.
  • Caching, sync and conflict resolution, which are the implementing team's data layer. This page describes only what the surface must say about their result.
  • The generic data-state definitions, which are canonical at Data states.
  • Measurement uncertainty as an interval. Showing a value plus or minus a confidence bound is worth doing wherever the source supports it, and it is not specified here yet.
  • Device accuracy claims, which are covered at Data provenance and device accuracy.
Not written yet. Specify the presentation of an explicit uncertainty interval: a measured value with a stated error bound, in text and in a chart.

Updates to this page

Last read through against the system on 2026-09-02. Due for review every 6 months; expiry is reported by pnpm run check:freshness.

On this page