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 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 that flip lands on the day boundary even if no refresh gets a chance to run.
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 that 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, so 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 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.
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 cover 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 that was 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.