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 that the widget was stale, and the obvious fix is
to make it refresh more aggressively. That explanation was wrong, and I want
to save you the detour, because searching for this symptom turns up a lot of
advice about App Group misconfiguration, background-delivery throttling, and
reloadAllTimelines() being ignored in the background. All of
those are real problems. None of them was this one.
The timestamp was the tell
The widget renders an "Updated" time in its footer. On those broken mornings that timestamp 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, which is a different bug with a different cause, and it immediately rules out the entire category of fixes above. Nothing about redraw scheduling can explain a redraw that happened 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 exactly, that inner expression has to be exactly zero —
which, given basal energy alone is never zero over a real day, means all
three values were zero simultaneously. Three independent HealthKit reads
returning zero at the same instant is not a data problem. It's a read
problem.
HealthKit is encrypted when the phone is locked
This part is documented, and 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 actually happen. A HealthKit observer wake, or a background app refresh, fires while the phone is sitting on a nightstand — locked, by definition, most of the time. So the reads that are most likely to fail are precisely the ones running unattended, and the ones that succeed are the ones you're standing there watching, which is why this 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 _.
Look at what happens 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 —
a perfectly ordinary Int, indistinguishable from a genuine day
with no samples in it. There is no signal left. 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 to the widget with a current timestamp, and go back to sleep. Every subsequent locked wake did the same thing again. Nothing corrected it until a foreground launch — which by definition happens with the phone unlocked — finally read something real. That accounts for every part of what I'd been seeing, including the "fixes itself the moment I open the app" part that had me chasing widget refresh in the first place.
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.
In fairness I should say 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 specific 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 as plainly as
you could want: "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." That reply points at
errorDatabaseInaccessible for this case, which is good
corroboration — though it's still Apple describing the behavior rather than
me watching a specific code come back, so I'm leaving the caveat above
standing. Worth knowing the answer exists, because 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 entirely —
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, not via that callback. A process that had never managed a single successful read would still cheerfully publish its freshly-initialized zeros. So the store that writes the widget's payload now refuses to write anything at all until a read has actually 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 zeros 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 — and those are 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 anything rendering it asks whether that day is still today. If it isn't, the widget 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 that flip lands on the day boundary even if no refresh gets a chance to run.
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 uniquely dangerous, because zero is a
legitimate value in almost every domain. It doesn't look like an error state.
It renders as a confident, plausible, correctly-formatted number, and nothing
downstream — no view, no test, no person glancing at a widget — has any way
to tell it apart from the truth. An error you swallow becomes indistinguishable
from data you trust.
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. Publishing
nothing is nearly always better than publishing a zero. A missing number
makes a user tap; a wrong one makes them trust it.