Loading devShakib…
HealthKit returns empty rather than an error when a user refuses read access. Here is why Apple designed it that way, and how to build a UI that survives it.
You wire up health data, run it on a real device, grant the permission sheet, and get back an empty list.
No exception. No error code. No indication of whether the user refused, or whether they genuinely have not walked anywhere this week. Just [].
This is not a bug in whichever package you are using. It is HealthKit behaving exactly as designed, and once you understand why, a whole class of confusing behaviour becomes predictable — including several things that look like package bugs and are not.
HealthKit's authorization status has exactly three values:
HKAuthorizationStatusNotDetermined
HKAuthorizationStatusSharingDenied
HKAuthorizationStatusSharingAuthorized
Read them carefully. All three describe sharing — that is, writing. There is no read equivalent, and its absence is not an oversight that Apple will get round to.
The reason is a privacy argument that is genuinely clever once you see it. Suppose there were an API that told you whether you had read access to, say, pregnancy data or blood glucose. An app could call it for every data type and learn the shape of the user's refusals. Refusals are not random — people deny access to the categories that are sensitive to them. The pattern of what someone hides is itself health information.
So Apple closes the hole at the source. Your app can ask for read access, and the user's answer is never reported back to you. You find out by trying.
This means any API in any package that claims to answer "do I have read permission?" on iOS is guessing. It can tell you what your app requested. It cannot tell you what the user granted.
Running against a real HealthKit store surfaces a distinction that matters a great deal for your UI, and that most implementations flatten:
| State | What a read actually does |
|---|---|
| Never requested | Throws. You never called request. |
| Requested, granted | Returns the data |
| Requested, refused | Returns empty — indistinguishable from no data |
The first row is the useful one. If a read throws because permission was never requested, that is your bug, and it is fixable — you forgot to call the request method, or you called it for a different set of types than you are now reading.
The third row is genuinely ambiguous and always will be. Empty means either "the user said no" or "the user has no data". You cannot distinguish them on iOS, in any package, ever.
Most health packages collapse these into one path, so a forgotten permission request looks identical to an empty week and you debug the wrong thing for an hour. Keeping them distinct is why vitals throws AuthorizationNotDeterminedException for the first case rather than quietly returning [].
Android is different — Health Connect will tell you what was granted:
switch (await vitals.readAccessOnAndroid({VitalType.steps})) {
case null: // iOS: unknowable, attempt the read and handle empty
case final access: // Android: an actual answer
}
The method name is deliberately ugly. It says on the tin that this is an Android-only capability, so nobody writes a cross-platform code path on top of an answer that only exists on one side.
Since empty is unresolvable, the interface has to be built to tolerate it. Three patterns that work:
Never say "you have no data". You do not know that. Say something that is true in both cases: "No steps to show for this week." Then offer a way forward — a link to the Health app's own privacy screen, where the user can check and change what they shared. That screen is the only place the truth lives, so send them there rather than guessing on their behalf.
Do not gate your entire onboarding on a successful read. If your flow is "request permission → read → if empty, show an error", a user who granted everything but genuinely walked nowhere yesterday gets treated as if they refused. Let them into the app.
Request narrowly, and late. Ask for the types you need for the screen the user is on, not everything at launch. A sheet asking for fifteen categories before the app has shown its value gets refused wholesale, and once refused it does not re-prompt — the user has to go into Settings, which almost nobody does.
Health integration has an unusual property: get the configuration wrong and you do not get a helpful error, you get a hard crash the moment you request access.
iOS needs three things, and none are optional.
The HealthKit capability, in Xcode or an entitlements file:
<key>com.apple.developer.healthkit</key>
<true/>
Both usage descriptions in Info.plist. Omitting either one crashes the app the moment you request that kind of access — and note it is both, even if you only ever intend to read:
<key>NSHealthShareUsageDescription</key>
<string>Why you read health data.</string>
<key>NSHealthUpdateUsageDescription</key>
<string>Why you write health data.</string>
And iOS 15 as a minimum. Sleep stages finer than "asleep", and workout totals, need iOS 16 — below that they degrade rather than failing outright, which is the better behaviour but does mean testing on the older version to see what your users actually get.
Android needs Health Connect, which requires minSdk 26. It is built into Android 14 and later; older devices need it installed from the Play Store. Check availability before showing any health UI at all, because the failure mode otherwise is a permission sheet that never appears.
A detail that seems pedantic until it costs you an afternoon: health data is full of units, and most APIs hand you a double.
final weight = await vitals.read(VitalType.bodyMass, from: a, to: b);
weight.first.value.pounds; // a Mass, not a double you hope is kilograms
Getting a Mass rather than a number means the unit conversion happens in one tested place instead of being scattered through your UI as multiplications by 2.20462. A unit mistake in health data is particularly bad because it is silent — a weight chart in the wrong unit looks like a plausible chart, not like an error, and it can persist for months before anyone notices the numbers are strange.
The same applies to counts. steps.first.count being an int rather than a double you round is a small thing that removes a small class of bugs.
A year of heart-rate samples is hundreds of thousands of points. Reading them all across the platform channel to compute a daily average in Dart is slow, memory-hungry, and entirely unnecessary — both platforms can reduce the data before it crosses:
final daily = await vitals.statistics(
VitalType.steps,
from: monthAgo,
to: now,
bucket: VitalBucket.daily,
);
Two design points hide in there that matter for correctness.
Each type knows how it should be reduced. Steps sum. Heart rate averages. Weight takes the latest. Summing a weight series or averaging a step count both produce numbers that look reasonable and mean nothing, so the default is per-type rather than a parameter you get to pass wrongly.
A bucket with no samples reports null, never 0. This is the one to insist on. A day where the user did not wear their watch is unknown, not zero steps. Conflating them drags every weekly average downwards and produces charts with phantom crashes to the floor. Any health library that returns 0 for an empty bucket is quietly corrupting your statistics.
You cannot get a CI runner to grant a HealthKit permission sheet. So health code tends to go untested, which is exactly backwards given how many branches it has.
The way out is an in-memory implementation that models the awkward states, not just the happy path:
final vitals = FakeVitals()
..seedCounts(VitalType.steps, {
DateTime(2026, 8, 24): 8210,
DateTime(2026, 8, 25): 11430,
});
And critically, the failures:
FakeVitals(available: false); // ineligible device
FakeVitals(permissionSheetSucceeds: false); // user dismissed the sheet
FakeVitals(readsAreBlocked: true); // the iOS silent denial
That third one is the important test. It is the state you cannot reproduce on demand on a real device without a factory reset, it is the one your users will hit, and it is the one that produces an empty chart with no error. Being able to assert what your UI does in that state is most of the value of testing health code at all.
One thing worth saying plainly, because health data is a domain where overclaiming does real harm.
vitals reading is verified. The iOS path is exercised against a real HealthKit store and the Dart contract has a full unit-test suite behind it.
vitals writing is not verified on either platform. The code is complete and compiles, but no write has yet been observed to succeed end to end: on the iOS Simulator save returns Not authorized even after the permission sheet is granted, and the Android round trip has not been run. It is not yet clear whether that is a Simulator limitation or a defect in the package.
So use it for reading and aggregating. If you write, verify the values land correctly in Health or Health Connect before trusting it — a unit-conversion mistake would be silent, and health records are genuinely awkward to correct after the fact. Reports from real devices are very welcome.
I would rather say that in the README and in a blog post than have someone discover it in production with a user's weight history.
health?health is the established package. It is actively maintained, it covers more data types, and if you need breadth today you should use it. That is a straightforward recommendation and I am not going to dress it up.
vitals exists for three things it does not do: nothing is cast to double, permissions are modelled on what the platforms can actually answer rather than on a convenient fiction, and the whole API runs in memory for tests.
If those are your problems, it is on pub.dev — MIT, 160/160 pub points, iOS and Android. If breadth is your problem, use the other one; that is the honest answer.
Info.plist usage descriptions or the app hard-crashes on request.null rather than zero.