---
title: "Uncertainty, staleness and missing data"
description: "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."
url: "https://opsinjs.pensievelabs.org/health/uncertainty-and-staleness"
source: "https://opsinjs.pensievelabs.org/health/uncertainty-and-staleness.md"
section: "Health"
kind: "health"
evidence: "opinion"
reviewed: "2026-09-02"
reviewer: "design"
aliases: ["stale data", "estimated", "missing data", "unknown"]
implements: ["relative-time", "value", "metric-tile", "result-card", "skeleton", "empty-state", "disclaimer-note", "reading-input", "trend-sparkline", "timeline-entry", "dose-tracker"]
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> Nothing is missing from this page. The data simply does not live in
> the prose.

<PageTemplate kind="health" />

## What this means [#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 [#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 [#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](../foundations/data-states.mdx), and the table below is the
health-specific reading of them.

| State         | What it means                                   | What the reader must see                                                   |
| ------------- | ----------------------------------------------- | -------------------------------------------------------------------------- |
| **Fresh**     | Measured, current, complete                     | The value, its unit, when it was taken                                     |
| **Stale**     | Real but older than this metric's useful window | The value, visibly de-emphasised, with its age stated and a way to refresh |
| **Partial**   | Some sources reported, others did not           | The value, plus which sources are missing                                  |
| **Estimated** | Derived or modelled rather than measured        | An explicit estimate label, wider rounding, and what it is derived from    |
| **Absent**    | No data, or a failed fetch                      | No number at all; a sentence and a next action                             |

### The ten rules [#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.

| #  | Requirement                                                                                                                                                                                                                                      | State it governs                     | Who declares it                                                          | Enforced by                                             |
| -- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------ | ------------------------------------------------------------------------ | ------------------------------------------------------- |
| 1  | **Every 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, Estimated     | The data layer, which supplies the time of measurement                   | `RelativeTime`                                          |
| 2  | **Staleness 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 Stale | The implementing team, per metric. opsinjs declares no freshness window  | `RelativeTime`, against the window the metric declares  |
| 3  | **Staleness 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.                                                        | Stale                                | opsinjs, which specifies the de-emphasis                                 |                                                         |
| 4  | **A stale value is shown with its age stated.** Neither rendering it at full weight nor withholding it altogether satisfies this rule.                                                                                                           | Stale                                | opsinjs owns the treatment. The age itself comes from the data layer     |                                                         |
| 5  | **An 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](./numbers-units-precision.mdx). | Estimated                            | The data layer, which marks a value as derived                           |                                                         |
| 6  | **A 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`.                                      | Absent                               | The data layer, which distinguishes a failed fetch from an empty history | `Value`, which refuses to render an unknown as a number |
| 7  | **A 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 five                    | opsinjs, which specifies the placeholder                                 |                                                         |
| 8  | **Status 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, Estimated            | The implementing team, which assigns status                              |                                                         |
| 9  | **A 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.    | Partial                              | The data layer, which reports which sources contributed                  |                                                         |
| 10 | **Offline 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](../patterns/offline-and-stale-data.mdx).                             | Stale, Absent                        | The implementing team, which owns sync and connectivity                  |                                                         |

## Why (evidence) [#why-evidence]

<ResearchNote evidence="opinion" date="2026-09-02">
  **Opinion.** We cite no study here, and the argument for all ten rules is
  structural.

  Rule 8 carries the most safety weight on this page, and the mechanism that
  defeats it is structural rather than careless. An alerting pipeline that runs
  over cached values will raise an `attention` banner from a reading that is
  three days old, because the alerting code and the freshness code were written
  by different people at different times. The rule exists to make freshness a
  precondition of escalation rather than a display concern layered on
  afterwards.

  Rule 3 follows from the audience model on
  [Who this is for](./who-this-is-for.mdx): a reader at the three-second end of
  that model's range reads the number and nothing else, so a staleness treatment
  that lives only in a caption does not reach the reader it was written for.

  The five-state model itself is borrowed from ordinary data-state design and is
  not health-specific. The health-specific part is the insistence that
  "estimated" is a first-class state rather than a variety of "fresh". Consumer
  health hardware produces a great deal of derived data. A value estimated from
  an optical signal is not the same kind of object as a value from a cuff, and
  collapsing the two is a provenance failure. See
  [Data provenance and device accuracy](./data-provenance-and-device-accuracy.mdx).

  What would change our mind: a field study showing that visible staleness
  treatment reduces engagement enough to harm outcomes would move rules 3 and 4
  towards a lighter treatment. Rule 8 would not move with them, because it
  governs escalation rather than presentation.
</ResearchNote>

## Applying it [#applying-it]

<DoDont>
  <DoDont.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.
  </DoDont.Do>

  <DoDont.Dont>
    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.
  </DoDont.Dont>
</DoDont>

<DoDont>
  <DoDont.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.
  </DoDont.Do>

  <DoDont.Dont>
    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.
  </DoDont.Dont>
</DoDont>

<DoDont>
  <DoDont.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.
  </DoDont.Do>

  <DoDont.Dont>
    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.
  </DoDont.Dont>
</DoDont>

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

  <DoDont.Dont>
    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.
  </DoDont.Dont>
</DoDont>

## Components that implement this [#components-that-implement-this]

{/* Generated from `implements`. Do not restate the list by hand. */}

`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 [#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](../foundations/data-states.mdx).
* **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](./data-provenance-and-device-accuracy.mdx).

<Todo>
  Specify the presentation of an explicit uncertainty interval: a measured
  value with a stated error bound, in text and in a chart.
</Todo>

## Updates to this page [#updates-to-this-page]

<Reviewed />
