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.