At 07:28 on a Thursday a real athlete asked his coaching app for his stats. It read back HRV 41, resting heart rate 47, 7.6 hours of sleep, readiness 59, and added a verdict: "47 is 3 above your norm of 44, and that's the cold still showing up. I'd push that to tomorrow." Legs and mobility, off.
Twelve minutes later, in the same conversation, the resting heart rate was quoted as 42. At 11:24 the read his watch finally settled on was HRV 52, resting HR 42, 8.8 hours of sleep, readiness 79. The best recovery of his week. He had been told to back off on the morning his numbers came back, on the strength of a figure that was still moving, sitting next to two figures that were not even from that night.
The short answer
A recovery read is not a snapshot. It is a set of fields that finish at different times, and an app can read it before it is done. Resting heart rate and a readiness score are recomputed live; HRV and sleep duration only settle once the night has been processed and synced. If today's HRV and sleep are identical to yesterday's to the decimal, you are looking at yesterday. Treat any read taken before your device's own morning report as provisional, and never let it call the session.
The rest of this is how a night gets written, how half of one ended up as yesterday on our own system, the one signature that reliably gives it away, and what to do with a number you cannot yet trust.
A night is written in pieces, and every vendor knows it
The three devices most of our athletes wear all say, in their own words, that the morning number is produced after the night, not during it.
WHOOP is the most explicit. "When you wake up in the morning, WHOOP calculates a Recovery score." Its developer API carries a score_state field on every recovery record with three values: SCORED, PENDING_SCORE meaning "WHOOP is currently evaluating the cycle", and UNSCORABLE. The vendor has modelled the not-yet state as data. An app reading WHOOP can know it is early.
Garmin measures HRV "while you sleep", builds a seven-day average from those nights, and needs three weeks of them before it will show a status at all. It delivers the result in a Morning Report that the watch "displays based on your normal wake time", carrying "sleep, overnight heart rate variability status, and more". The report is the vendor's own signal that the night is closed and the numbers are final.
Oura says its Readiness Score is "generally available the morning after you sync", and its troubleshooting notes describe what a partial night looks like: a ring that runs low on battery "may stop gathering new information, which can result in an incomplete sleep analysis and missing sleep metrics" including HRV and readiness.
So none of the vendors is at fault here. Each has a "done" signal. The problem is what an app downstream sees if it asks before that signal: nothing, a placeholder, or the last number it was given, which is yesterday's.
How half of a read became yesterday, on our system
This is the part only we can write, because it was our bug.
Our athletes' daily records are created by the phone app shortly after local midnight, when its background refresh notices the day has rolled over. The record for 28 August was created at 00:38 local. The record for the 27th had been created at 00:42. Forty minutes after midnight, a record for "today" exists, hours before the night it is supposed to describe has ended.
At creation the app asks each connected source for today's read. For this athlete that is Garmin, proxied through a small service of ours that asks Garmin Connect for a given day. That service takes "today" from its own clock, which runs on UTC. At 00:38 in London in August, the clock on that server read 23:38 on the 27th. So it asked Garmin for the 27th, got the 27th's finished night, HRV 41 and 7.6 hours, and the app filed those numbers under the 28th.
That alone would be a one-line date bug. What made it dangerous is what happened next. When the athlete opened the app at 05:45 and again at 07:28, it re-pulled. Garmin had by then recomputed the live fields: a daily resting heart rate, 47 at that point, and a readiness score, 59. As far as the logs let us reconstruct, it had not yet finished the night, so it returned nothing new for HRV or sleep, and our merge kept the values already in the row. The result was a record with all four fields present, two of them fresh and two of them yesterday's, that looked exactly like a complete read.
Our own reader has fallbacks that make this worse, and they were written with good intentions. If Garmin has no HRV for last night, we take the weekly average. If it has no resting heart rate, we take the seven-day average. If there is no readiness score, we take the most recent Body Battery value. Each one fills a blank with a plausible number so the coach is never staring at a gap on a no-wear night. Each one is also a way for an unfinished read to look finished. The daily history backfill that runs later deliberately has none of these fallbacks, because smearing an average across every day would fake a flat trend; the live read kept them.
Then the coach read the row. It compared 47 against a baseline of 44, found three beats of elevation, attributed it to a cold from earlier in the week, and called the session. Every step of that reasoning was sound. The input was a draft.
Why a freshness rule does not work
The obvious fix is a freshness rule: ignore any recovery read younger than some hour, or older than some age. Neither works, and finding out why is most of what we learned.
The record's updated_at timestamp was moving the whole time. The row was genuinely being written to at 05:45 and at 07:28, so "recently updated" was true. And at 07:28 every field was populated. There is no honest server-side answer to "has this read settled" from the row's own metadata, because the metadata says it is complete and current, and it is.
What is detectable is the carry-forward itself. Two independent measurements, HRV and sleep duration, identical to yesterday's values to the decimal, is not something two real nights produce.
We checked that against every consecutive-day pair on our roster where both days carry both fields: 326 pairs, drawn from 24 athletes' histories.
| Repeats exactly from one day to the next | Pairs | Rate |
|---|---|---|
| HRV alone | 15 of 326 | 4.6% |
| Sleep duration alone | 12 of 326 | 3.7% |
| HRV and sleep together | 1 of 326 | 0.3% |
| Resting heart rate alone | 54 of 326 | 16.6% |
The one pair that matched on both turned out not to be a carry-forward at all. Both rows were written by a history backfill in the same second, from a device's bucketed history, so it is either two genuinely matching nights or an artefact of that bucketing. Among rows the app wrote live, the rate is zero.
Resting heart rate is in that table for the opposite reason. An integer in a narrow band repeats one day in six by chance, so it cannot be part of the signature. And it was the field that had already been recomputed, which is precisely what made the read look done. A rule that required all four fields to match would have fired on nothing. The pair is the rule, and either field alone is not.
We also measured how often a record exists before the night is over. Of 139 daily records created on their own local day, 42, or 30%, were created before 06:00, and 36 were created between midnight and 03:00. Restricting to records that carry an HRV value, 30 of 80 were created in that midnight-to-three window. For the Garmin athletes specifically, 38 of 103 records existed before six in the morning. This is not an edge case in our data. It is a third of the mornings.
What the flag changes, and what it does not
When today's HRV and sleep both equal yesterday's, the coach is now told, in the same place it reads the numbers, that the read has not settled: those two fields are carried forward, and the rest are mid-refresh and will still move. It is not told to withhold the figures. The athlete who triggered this had asked "what's my stats", and refusing a direct question would be a new failure in place of the old one. It is told not to do the two things that did the damage: grade the read against a baseline, and make the day's training call on it. If the day's call turns on readiness, it asks how the athlete slept and feels, and treats the answer as the read.
We ran the real coaching turn on the real 07:28 row, three times each way. With the flag, it declined to grade the read on all three. Without it, one of three replies was clean; the other two read "resting HR 47 vs 44 baseline, up 3" and reached the same verdict as production had. Three samples each is a small test, and it measures our coach on one athlete's morning. It says the flag does what it was built for, not that the problem is solved.
Two honest limits. The detection catches one signature, the specific one we observed. A watch that revises HRV early and resting heart rate late slips straight through it, and we do not describe it as a general oracle for unfinished reads because it is not one. And the root cause, a day's record being created at midnight with the previous night in it, is a phone-side behaviour that has not yet been changed. The flag is the backstop while that is fixed properly.
What to do with a number you cannot yet trust
- Learn your device's "done" signal and wait for it. Garmin: the Morning Report. WHOOP: a recovery that has appeared, rather than a pending one. Oura: the readiness card after a morning sync. Before that, anything an app shows you is a draft or a leftover.
- If HRV and last night's sleep both match yesterday exactly, it is yesterday. Two real nights do not do that. Sync, wait, and read again.
- A number that changed between two glances is not finished. A resting heart rate that reads 47 at seven and 42 at eleven, with no training in between, was never a measurement of your night. It was a computation in progress.
- Do not let a six a.m. read decide a six p.m. session. By the time you train, the settled numbers exist. Use those, and compare them with your own recent trend rather than a single day.
- Your own report counts. How you slept and how you feel, asked plainly, is real data, and on a morning when the device has not finished it is the only data that is finished.
The wearable was right. The night was good. The problem was an app that read the story before the last page had been written and then acted on the middle of it.
Kipp checks whether your morning read has settled before it coaches from it. If your HRV and sleep are still yesterday's, it says so, gives you the figures with that caveat, and asks how you actually feel instead of calling the session on a number that is still moving.
Sources
- WHOOP calculates Recovery "when you wake up in the morning", and its API's
score_statetakes the valuesSCORED,PENDING_SCOREandUNSCORABLE. WHOOP for Developers: Recovery, WHOOP 101 - Garmin measures HRV while you sleep, needs three weeks of nights for a status, and shows overnight HRV status in a Morning Report displayed "based on your normal wake time". Garmin: Understanding the HRV Status on Your Garmin Smartwatch, Garmin manual: Heart Rate Variability Status, Garmin manual: Morning Report
- Oura's Readiness Score is available the morning after a sync, and a low battery during sleep produces an incomplete analysis with missing HRV and readiness. Oura: Readiness Score, Oura Member Care: Troubleshooting Gaps in Sleep Data
- What each device's summary score is built from, and why a score is not a report on the night alone, in our earlier pieces. Why your recovery scores disagree, Body Battery says rest, the plan says intervals
- The 07:28 and 11:24 reads, the record creation times, the 326-pair repeat rates and the three-of-three coaching check are from our own roster data and logs, measured on 6 September 2026. One athlete's morning, 24 athletes' histories, and a small live test. None of it is a population claim.
