Caching Fitness API Responses Without Serving Stale Health Data
Last verified August 12, 2026 · 8 min read
The bug arrives as a support ticket rather than an alert. A dashboard shows 4,200 steps at 00:05 and 11,000 at 00:11. A weekly total disagrees with the days beneath it. A nudge fires about a walk the user finished two hours ago. All three are caching bugs, and none shows up in your error rate.
Caching health data is not ordinary caching with tighter numbers. Both assumptions underneath a normal cache — that a value stays true until something changes it going forward, and that "now" means the same thing to your server as to your user — are false here.
This page covers responses and rollups. Sync cursors, change tokens and the dirty-day queue belong to incremental sync; a cache is a layer over that machinery, not a substitute for it.
Sort your data by mutability first#
The useful question is not how long a TTL should be, but what makes the value wrong. Three classes, three mechanisms.
| Class | Examples | Mechanism |
|---|---|---|
| Effectively immutable | Exercise catalogue rows, demo media, provider capability metadata, unit conversion tables | Long TTL, shared cache, versioned key. The only invalidation is a deploy |
| Mutable but change-signalled | Settled historical days, workout detail records, profile-derived aggregates | Event-driven invalidation, with a long TTL as backstop rather than mechanism |
| Never safely cacheable as authoritative | The current day's totals, streak state, anything a goal or notification reads, anything on a clinically framed screen | Recompute, or cache for seconds with an explicit as-of timestamp on screen |
Be concrete about the harm in that third row. A stale catalogue row shows an old thumbnail; a stale "today" total tells someone they have 2,000 steps left when they already hit their goal. Different severities, so vary the design rather than applying one TTL across the API.
TTL is a guess; an event is a fact#
A TTL estimates how long a value stays true, guessed before you know anything about it. For health data it is wrong in both directions at once: too long for someone wearing a device right now, pointlessly short for someone whose watch has been in a drawer since March.
You usually have the better signal already. A provider webhook is a change pointer, which makes it a ready-made invalidation event: when a delivery says a user's data for a day moved, evict the entries depending on that day. Delivery semantics are webhook ingestion's subject, and they matter here because an out-of-order delivery that evicts is harmless while one that writes is not. Eviction is the forgiving operation; prefer it to cache-repair.
The pattern composes cleanly: whatever marks a day dirty also enqueues the eviction. One code path, two effects. Where no push channel exists, you invalidate on sync completion instead, so cache freshness inherits the sync's and no TTL improves on it. Say so in the design rather than hiding it behind a five-minute expiry implying a guarantee you cannot make. Keep the TTL as a backstop against missed events and bugs: its job is to bound how long a mistake persists, not to be the correctness mechanism.
Caching around rate limits, without making the cache the strategy#
Per-user quotas are real and provider-specific, and a cache genuinely stretches them — see Fitbit API 429 rate limit for how one provider's quota is counted and recovered from, and read each provider's own published numbers, which differ. Three things matter more than hit rate.
Coalesce concurrent misses. A user opens the app, four widgets fetch, all four miss the same key. Single-flight per key so a cold entry costs one upstream call rather than four. This matters more than raising the TTL, because it fixes the burst that trips a quota.
Cache the failure, not just the success. When a provider returns a rate-limit rejection with a reset hint, store it as a short-lived negative entry on the same user and resource. Otherwise every request in the window retries and you spend the reset period generating rejections.
Prefer serving stale to serving nothing. A step count labelled "as of 08:12" beats an error, and beats a zero by more — a zero is indistinguishable from a genuinely inactive day, and users read it as data loss. Serving stale is fine; doing it silently is not.
The trap underneath all of it is the synchronised refresh: if every user's rollup expires on a fixed cadence, misses arrive in a wave. Jitter expiry per user and stagger any warm-up.
"Today" is not a cache key#
A cached daily total keyed as "today" is wrong once per user per day, at whatever hour their local midnight falls. It does not decay; it becomes the answer to a different question.
Key on the civil date, computed from the instant and the offset in effect at that instant, exactly as your rollups are keyed. Resolving "today" to a date happens in the request handler before the lookup, using the rule your storage uses — never inside the cache layer, never from the server's clock. Timezones and day boundaries is where that rule is argued out.
Two consequences that catch people:
- Expiry does not respect midnight. A ten-minute entry written at 23:56 local serves yesterday's total as today's for six minutes. Cap the lifetime of any current-day entry at the time remaining until the user's next local midnight.
- A daylight-saving day is not 24 hours. Computing an expiry by adding twenty-four hours to a local midnight lands an hour early or late twice a year. Compute the next local midnight as a calendar operation, then take the interval between instants.
Travel compounds both: fly east and the day is short, so an entry sized against the old zone outlives the day.
Retro-edits mean no day is ever final#
Providers rewrite history: sleep staging revised hours later, watches backfilling, platform-side condensing of old records, users correcting entries by hand. A cache tier assuming "older than N days is immutable" serves numbers your database no longer holds.
Two defences, and you want both. Put the derived-metric version in the cache key, so a formula change or a recompute makes every affected entry unreachable rather than something you hunt and delete; that versioning discipline is metric versioning and recompute's subject, and extending it into the key is nearly free. And make the recompute path an evictor: whatever writes a corrected rollup drops the matching entry in the same transaction boundary, so the cache never disagrees with the store.
Aggregates spanning many days — a monthly chart, a year-to-date total — are worse, because one retro-edit invalidates a wide range. Either key them on a monotonically increasing per-user data version so any change stales the whole family, or accept a documented staleness and say so on screen.
A cache is storage, and health data does not stop being health data in it#
This gets missed in reviews because caches are filed under performance rather than under data. Every cached response holding health values is a copy of health data, with its own location, access controls and retention behaviour. Retention schedules and deletion obligations reach it: a purge that clears the primary store but leaves warm entries is incomplete, and a later read can repopulate what you deleted from a queue or an upstream you have not yet revoked. Deletion ordering and the resurrection problem are deleting and exporting a user's health data's subject; the retention side is health data retention and deletion. The rule is that your cache appears on the erasure inventory by name, with an owner, alongside the databases.
Two more: give every entry an absolute maximum lifetime even where you invalidate on events, so an orphaned key cannot outlive its retention window; and log cache keys, never values, because a key is a reference while the payload is the health record, and observability pipelines are the least-governed place it lands.
Per-user by default; shared only for the non-personal#
Health responses are never shared-cacheable. That is our engineering position rather than a rule anyone publishes, and we would hold it as an invariant rather than a guideline: the failure is catastrophic and quiet, one user's rollup served to another and found by the wrong person.
The mechanism is almost always an HTTP-layer cache keyed on a URL with no user in it and identity carried in a header. Guard it three ways: put the internal user identifier in the key rather than trusting the request to supply it; mark any response carrying health values private and non-storable at the edge; and keep personalised health endpoints off shared edge caches entirely, because otherwise you are one misconfiguration from a breach rather than one bug from a stale number.
What is legitimately shared: the exercise catalogue, media, provider capability metadata, reference tables — anything identical for a logged-out visitor. Keep those on separate key namespaces, so no later change can promote a personal response into the shared tier.
Frequently asked questions
- Which parts of a health API response are actually safe to cache for a long time?
- The parts that are not about a person. Exercise catalogue rows, demo media, unit conversion tables and a provider's capability metadata change on your deploy cadence rather than on the user's, so they take a long lifetime and a shared cache keyed by content version. A user's settled historical days sit in a middle tier: cacheable, but only with an eviction driven by the event that says the day moved. The current day's totals, streak state and anything a goal or notification reads should not be treated as cacheable at all in the authoritative sense, because the harm from a stale value there is a wrong nudge or a goal that reads as unmet after the user already hit it.
- Should invalidation be driven by expiry times or by provider events?
- By events, with expiry kept only as a backstop. A time-to-live is a guess about how long a value stays true, made before you know anything about the value, and for health data it is simultaneously too long for a user actively wearing a device and pointlessly short for one whose watch has been in a drawer for months. A provider webhook is already a change pointer, which makes it a ready-made invalidation signal: the same code path that marks a day dirty should enqueue the eviction. Where no push channel exists you invalidate on sync completion instead, which means your cache freshness cannot be better than your sync freshness — worth stating explicitly rather than implying a guarantee with a five-minute expiry.
- Can a cache be my answer to a provider's rate limit?
- It stretches the quota, but it is not the strategy, and three other things matter more than the hit rate. Coalesce concurrent misses so four widgets opening the same dashboard cost one upstream call rather than four, because the burst is what trips the limit. Cache the rejection as well as the success, keyed to the same user and resource for the reset interval, or you will spend the whole window generating fresh rejections. And jitter expiry per user, since a fixed refresh cadence makes every miss arrive in a wave. Read each provider's own published quota rather than assuming they resemble each other.
- What goes wrong with a cached daily total when the user crosses midnight?
- It stops being an answer to the question that was asked. A total cached under a key meaning today does not decay gracefully; at local midnight it becomes yesterday's number wearing today's label. Key on the civil date computed from the instant and the offset in effect at that instant, resolve today to a date in the request handler before the lookup rather than inside the cache layer, and cap the lifetime of any current-day entry at the time remaining until that user's next local midnight. Compute that next midnight as a calendar operation rather than by adding twenty-four hours, because two days a year are twenty-three and twenty-five hours long, and a user who flies east gets a short day for the same reason.
- Does a purge of a user's health data have to reach the cache?
- Yes, and the cache is one of the stores most often left off the list, because it is filed under performance rather than under data. A cached response carrying health values is a copy of health data with its own location, its own access controls and its own retention behaviour, so it belongs on the erasure inventory by name and with an owner, alongside the databases. Two practical consequences: give every entry an absolute maximum lifetime even where you invalidate on events, so an orphaned key cannot outlive the retention window you published, and log cache keys rather than cached values, since the key is a reference while the payload is the health record itself.
Keep reading
Elsewhere on the site
Pages that share this one’s concepts and sources, from other sections.
Next steps
Was this page useful?
Independent comparison, last reviewed August 12, 2026. Pricing, rate limits, and feature availability change often — confirm current details in each provider’s official documentation before you commit. Product and company names are trademarks of their respective owners; AIFitnessAPI is not affiliated with, endorsed by, or sponsored by any product listed here.
← All architecture · by AIFitnessAPI