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:
HKAuthorizationStatusNotDeterminedHKAuthorizationStatusSharingDeniedHKAuthorizationStatusSharingAuthorized
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 deviceFakeVitals(permissionSheetSucceeds: false); // user dismissed the sheetFakeVitals(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.
and it is a good decision.
the Health app rather than guessing.
Info.plist usage descriptions or the app hard-crashes on request.null rather thanzero.