Why your HealthKit widget shows zero every morning
My iPhone widget was wrong every morning. Calorie Burndown shows how much of a calorie deficit you have left for the day, and every morning it showed the entire goal still remaining — nothing eaten, nothing burned, as if the day hadn't started. Opening the app fixed it instantly and it stayed correct for the rest of the day. The next morning it was wrong again.
The obvious explanation is a stale widget, and the obvious fix is a more
aggressive refresh. Both are wrong, and searching the symptom sends you
further that way: App Group misconfiguration, background-delivery throttling,
reloadAllTimelines() ignored in the background. All real
problems. None of them this one.
The timestamp was the tell
The widget renders an "Updated" time in its footer. On those broken mornings it read a couple of minutes old — not from last night. So a write really had happened, recently. The data wasn't stale. It was freshly wrong, and nothing about redraw scheduling explains a redraw that arrived on time with bad numbers in it.
The second clue was arithmetic. The number on the widget is
goal - (basal + active - consumed). For the remaining deficit to
equal the whole goal, that inner expression has to be exactly zero — and
basal energy alone is never zero over a real day, so all three values were
zero at once. Three independent HealthKit reads returning zero
simultaneously is not a data problem. It's a read problem.
HealthKit is encrypted when the phone is locked
This part is documented. I'd read the page before without connecting it to the symptom. From Apple's Protecting user privacy:
The user’s device stores all HealthKit data locally. For security, the device encrypts the HealthKit store when the user locks the device. As a result, your app may not be able to read data from the store when it runs in the background. However, your app can still write to the store, even when the phone is locked.
Now put that next to when background wakes happen. A HealthKit observer wake, or a background app refresh, fires while the phone sits on a nightstand — locked. So the reads most likely to fail are the ones running unattended, and the ones that succeed are the ones you're standing there watching. That is why it never reproduced while I was looking at it.
ELI5: what's a background wake?
An app on iOS isn't running most of the time. Instead of letting it sit there burning battery, the system freezes it and wakes it briefly when something relevant happens — new health data was recorded, or it's simply been a while. The app gets a few seconds to do its work and is put back to sleep.
The catch is that you don't choose when this happens. The system does, and it mostly happens while the phone is untouched — which for a phone means locked.
What the documentation doesn't tell you is the shape of that failure, and the shape is the whole bug.
A failed query and an empty day are the same object
Here is the sum helper I had, reduced to the part that matters:
// Before: the error is discarded.
let query = HKStatisticsQuery(
quantityType: type,
quantitySamplePredicate: predicate,
options: .cumulativeSum
) { _, result, _ in
let value = result?.sumQuantity()?.doubleValue(for: unit) ?? 0
continuation.resume(returning: Int(value.rounded()))
}
That third closure parameter is the error, and I had it bound to
_. On a locked device the query fails, so result is
nil, so sumQuantity() is never reached, so the
?? 0 fires and the function returns 0 — an
ordinary Int, indistinguishable from a genuine day with no
samples in it. Every caller downstream got a number that looked like data.
ELI5: what does ?? 0 do?
In Swift, a value that might be missing has to say so in its type, and
you can't use it until you've dealt with the missing case. The
?? operator is the quickest way to deal with it: "use this
value, or if it's missing, use that one instead." So ?? 0
means "or zero."
It's a convenience that quietly answers a question you may not have meant
to answer. "I don't have a number" and "the number is zero" are very
different statements, and ?? 0 turns the first into the
second without comment.
So the overnight sequence was: wake while locked, fail every read, coerce all three energy values to zero, compute a day in which nothing had been eaten or burned, publish it with a current timestamp, and go back to sleep. Every subsequent locked wake did it again. Nothing corrected it until a foreground launch — which happens with the phone unlocked — finally read something real. That includes the "fixes itself the moment I open the app" part that had me chasing widget refresh to begin with.
The fix is to stop throwing the error away, and to distinguish the one error that is a legitimate zero:
// After: nil means "we don't know", 0 means "we know, and it's none".
{ _, result, error in
if let error {
// errorNoData is HealthKit's way of saying the range is empty,
// which is a real, publishable 0.
guard (error as? HKError)?.code != .errorNoData else {
continuation.resume(returning: 0)
return
}
continuation.resume(returning: nil) // Int?
return
}
let value = result?.sumQuantity()?.doubleValue(for: unit) ?? 0
continuation.resume(returning: Int(value.rounded()))
}
Apple does define a specific code for this case —
HKError.Code.errorDatabaseInaccessible,
documented as "The HealthKit data is unavailable because it’s protected and
the device is locked" — and you could match on it directly. I deliberately
didn't. I treat
errorNoData
as a real zero and everything else as a failure, because I only want
to enumerate the errors that are safe to publish, not the ones that aren't.
I have not confirmed on-device which code actually comes back here — reproducing it needs a locked phone during a real background wake — so that detail is inference from the documentation rather than something I watched happen.
Since writing that, I found a
May 2026 Developer Forums answer
from an Apple DTS engineer that settles the general rule: "For privacy
reasons, your app is not allowed to read health data while a device is
locked, though it can still save data, which is then saved into a temporary
file and merged with HealthKit's data when the user unlocks their
device." It points at errorDatabaseInaccessible for this
case. Good corroboration, still Apple describing the behavior rather than a
code I watched come back, so the caveat stands. Worth knowing it exists: it
took me a while to find, and it isn't in the framework documentation.
One guard wasn't enough
Making the read return Int? only moves the problem up a level.
The refresh routine now returns a Bool and, on failure, leaves every
published value untouched and skips its "I have new data" callback — it does
nothing rather than committing zeros.
That still wasn't sufficient, and this is the part I'd have missed if I hadn't gone looking for other entry points. The callback is not the only way data reaches the widget: the app's launch task and the background refresh manager both call the sync routine directly.
A process that had never managed one successful read would still publish its freshly-initialized zeros. So the store that writes the widget's payload now refuses to write at all until a read has succeeded in that process — tracked as a nullable "last successful refresh" date, where nil means "these values are still just their initial zeros," which is not the same claim as "the user has consumed nothing today."
The general form: if you fix this at the read, audit every path that publishes. A default-initialized model object is full of zeros too, and those are just as plausible as the ones a failed query produces.
The related bug it exposed
Refusing to publish garbage means the widget can now show yesterday's numbers, which is correct but needs saying out loud. The payload already carried an "updated at" date, but that answers when it was written, not which day it describes — different questions the moment a write can be skipped.
So the payload now also carries the start of the day its numbers are about, and the phone widget asks whether that day is still today. If it isn't, it draws in a muted color with a dated footer and a "tap to refresh" line, instead of asserting yesterday's figures in the confident green of a goal you've already met. It also emits a timeline entry at the next midnight, so the flip lands on the day boundary even if no refresh runs.
That sentence originally read "anything rendering it," which was the intent rather than the state of the code. Two weeks after this was published I found the watch app and the watch complication both carried the new field faithfully through to their own render paths and then never looked at it. Both would present yesterday's numbers in confident green under a bare "Updated 3:02 AM" — the exact presentation the phone widget had just been fixed to stop making.
Worse, the code choosing between a relayed phone payload and a locally computed one still ordered on write time alone, so a payload describing yesterday but stamped "now" strictly outranked a current one. Both are fixed now. I'm leaving the correction visible rather than quietly restating it, because the failure is the more useful half: adding the field was the easy part, and a field only helps where somebody actually reads it.
ELI5: what's a timeline entry?
A home-screen widget doesn't get to run whenever it likes — it would drain the battery. Instead the app hands the system a short script ahead of time: "show this now, and at midnight show that instead." Each of those is a timeline entry.
So a widget can change on schedule without the app being awake at all. The flip is written in advance and the system performs it.
Both fields decode leniently so payloads written by older builds still load. That mattered more than usual here, because the phone relays the same payload to a watch that may be running a different build.
What I'd tell you to check first
If a value in your app is wrong in a way that looks like "nothing happened
today," find every place you turned a failure into a default with
??, try?, or an ignored error parameter — and ask
what that default renders as.
Zero is the dangerous one, because zero is a legitimate value in almost every domain. It renders as a confident, plausible, correctly-formatted number, and nothing downstream — no view, no test, no person glancing at a widget — can tell it apart from the truth.
Concretely, for HealthKit: an empty result and a failed query are the same
object unless you look at the error parameter, the most common
real-world cause of failure is a locked device, and a locked device is the
normal condition for exactly the background work you can't watch.
One thing I've learned since publishing this: a locked device is not the only way onto that failure path, and on a phone somebody is holding it isn't the likeliest one. Permission that was never granted is invisible by design — the page quoted above also says an app "isn’t aware when the user denies permission to read data," so those reads come back empty. Permission that was granted and then revoked in the Health app does not behave that way: those reads fail.
I watched four of them fail on a single cold launch, with the app never having recorded a successful read at all. The guards above covered that case without being asked to, which is the first time I've seen them work anywhere but a test. Nothing published zeros, and the widget went on showing the numbers from the last good read.
I first ended this by saying that publishing nothing is nearly always better than publishing a zero. That's too broad, and at the time my own code broke it deliberately: the per-day helpers behind the thirty-day history chart coerced a failed read to zero, on the grounds that a backfill would rather draw one flat day than refuse to draw the month.
The narrower rule is the one worth carrying — a default is safe exactly when whatever consumes it can tell "none" apart from "don't know." I thought a history chart could: one flat day sits in thirty days of context and reads as the anomaly it is. A single-number widget can't, because the number is the entire interface and there is nothing beside it to disagree with. Same query, same failure, opposite call — and what decides it isn't the data, it's what renders it. A missing number makes a user tap; a wrong one makes them trust it.
Those helpers are gone now, and how they went is a better argument for the rule than my exception to it was. Sixteen days after this was published, the week card on the main screen read "0/7 days hit" over a week that had been 7/7, with a weekly figure of exactly the daily goal times seven — while the live numbers on the same screen were correct.
The single flat day I had been picturing is a partial failure. The usual reason these reads fail is a locked device, and a locked device fails all thirty days at once, so what the chart actually gets is thirty well-formed zeros with no context left to read them against. The backfill now abandons the whole load if any one day fails. The rule held; I had checked it against the wrong failure. A consumer can only tell "none" from "don't know" if the failures arrive one at a time, and nothing guarantees they will.