DEV Community

Robin for Capawesome

Posted on Originally published at capawesome.io

Reading Step Counts on iOS and Android with Capacitor

Showing a user's daily step count sounds like a one-liner: read the step records for today and add them up. In practice that number comes out higher than the one in the Health app, because the phone, a watch and fitness apps all record the same walk. In this guide, you'll see how to read step counts in a Capacitor app from Apple HealthKit and Health Connect, get today's total and a 7-day chart, and avoid counting steps twice.

Prerequisites

  • A Capacitor 8 app.
  • The Capacitor Health plugin. Full disclosure: we build this plugin at Capawesome, and it's part of Capawesome Insiders, a paid subscription. For setup, see the Installation section.
  • Android: android.permission.health.READ_STEPS declared in your own AndroidManifest.xml, the privacy policy meta-data entry from the plugin docs, and minSdkVersion set to 26.
  • iOS: the HealthKit capability enabled and NSHealthShareUsageDescription in Info.plist.

Where steps come from

On iOS, steps live in Apple HealthKit; on Android, in Health Connect. Both hold individual samples, each with a time range, a value and the app or device that wrote it. Apple's stepCount documentation notes that "the system automatically records samples on iPhone and Apple Watch", and a running app can add its own samples for the same walk. So your morning commute may sit in the store two or three times.

Health Connect ships with Android 14 (API level 34) and later. On Android 9 to 13, it's a separate Play Store app, which matters for the next step.

Check availability

Not every device has a health store, so start with isAvailable(). It resolves with available and a reason, which is null when the store is ready and otherwise one of:

  • health-connect-not-installed: Health Connect is missing (Android 9 to 13).
  • health-connect-update-required: the installed Health Connect app is too old.
  • not-supported: no health store at all, for example Android below 9, iPadOS before 17 or visionOS.

For the first two, installHealthConnect() opens the Play Store page:

import { Health } from '@capawesome-team/capacitor-health';

const ensureHealthStore = async () => {
  const { available, reason } = await Health.isAvailable();
  if (available) {
    return true;
  }
  if (reason === 'health-connect-not-installed' || reason === 'health-connect-update-required') {
    await Health.installHealthConnect();
  }
  return false;
};
Enter fullscreen mode Exit fullscreen mode

After opening the store, check again when the user comes back, and hide the step feature completely for not-supported.

Request step permission

Once the native setup from the prerequisites is in place, request read access for DataType.Steps with requestPermissions(...). On iOS, Apple warns that "your app will crash when you request authorization" without the usage keys, so the plugin checks for them and rejects with an error instead.

import { DataType, Health } from '@capawesome-team/capacitor-health';

const requestStepAccess = async () => {
  const { permissions } = await Health.requestPermissions({
    read: [DataType.Steps],
  });
  const steps = permissions.find((permission) => permission.dataType === DataType.Steps);
  return steps?.read;
};
Enter fullscreen mode Exit fullscreen mode

The two platforms report the result differently.

On Android, requestPermissions(...) returns denied right after a rejection, while checkPermissions(...) only reports granted or prompt. After two denials, Health Connect ignores further requests, so show a button that calls openSettings() once you see denied.

On iOS, a read permission is never reported as granted. You get prompt before the first request and unknown afterwards, because HealthKit hides read decisions for privacy. Apple's authorizationStatus(for:) documentation says a denied read looks as if "there is no data of the requested type in the HealthKit store." So request access, run the query, and treat an empty result as "no data or no access".

Today's total

Today's step count is one aggregate(...) call from local midnight until now:

import { DataType, Health } from '@capawesome-team/capacitor-health';

const getTodaySteps = async () => {
  const startOfToday = new Date();
  startOfToday.setHours(0, 0, 0, 0);
  const { buckets } = await Health.aggregate({
    dataType: DataType.Steps,
    startDate: startOfToday.toISOString(),
    endDate: new Date().toISOString(),
    bucket: 'day',
    operations: ['sum'],
  });
  return buckets[0]?.values[0]?.value ?? null;
};
Enter fullscreen mode Exit fullscreen mode

setHours(0, 0, 0, 0) sets midnight in the device's time zone. toISOString() writes UTC, so in Berlin during summer time the start of October 8 becomes 2026-10-07T22:00:00.000Z, which is the same instant.

You get one bucket with one value per operation, as a plain number in count. The value is null when there's no data. Keep null apart from 0 in your UI, since on iOS null can also mean the user denied access.

Daily and weekly buckets

For a 7-day chart, keep the day bucket and start at local midnight six days ago. Buckets align to startDate, and day, week and month follow the device's time zone. If you start at "now minus seven days" (as the README sample does), a call at 14:37 gives buckets from 14:37 to 14:37 that mix two calendar days. Starting at midnight fixes that:

import { DataType, Health } from '@capawesome-team/capacitor-health';

const getWeekChartData = async () => {
  const start = new Date();
  start.setHours(0, 0, 0, 0);
  start.setDate(start.getDate() - 6);
  const { buckets } = await Health.aggregate({
    dataType: DataType.Steps,
    startDate: start.toISOString(),
    endDate: new Date().toISOString(),
    bucket: 'day',
    operations: ['sum'],
  });
  return buckets.map((bucket) => ({
    label: new Date(bucket.startDate).toLocaleDateString(undefined, { weekday: 'short' }),
    steps: bucket.values[0]?.value ?? 0,
  }));
};
Enter fullscreen mode Exit fullscreen mode

setDate() works in local time, so the start stays at midnight across a daylight saving change, which subtracting 6 * 24 hours would not. For longer views, switch to week or month and start at midnight on the first day of your week or month.

Steps support only sum. Asking for average, maximum or minimum rejects with INVALID_AGGREGATION, so compute a daily average from the day buckets yourself.

The double-counting trap

Summing the records from readRecords(...) is the tempting shortcut:

import { DataType, Health } from '@capawesome-team/capacitor-health';

const sumStepRecords = async (startDate: string, endDate: string) => {
  const { records } = await Health.readRecords({
    dataType: DataType.Steps,
    startDate,
    endDate,
  });
  return records.reduce((total, record) => total + (record.value ?? 0), 0);
};
Enter fullscreen mode Exit fullscreen mode

For someone with an iPhone and an Apple Watch, both devices write samples for the same minutes, and this function counts them twice.

aggregate(...) avoids that because the platform does the math. On iOS, HealthKit computes totals with statistics queries, and Apple's HKStatistics documentation describes the default:

> By default, these queries automatically merge the data from all of your data sources before performing the calculations.

On Android, the Health Connect aggregate data guide (last updated September 23, 2026) states that "the Aggregate API accounts for any duplicate data and keeps only the data from the app with the highest priority." Steps are an Activity type, which that rule covers, and only the user can change the priority order.

Raw records are still useful for showing where steps came from. Each record has sourceBundleId and sourceName (always null on Android):

import { DataType, Health } from '@capawesome-team/capacitor-health';

const getStepsBySource = async (startDate: string, endDate: string) => {
  const { records } = await Health.readRecords({
    dataType: DataType.Steps,
    startDate,
    endDate,
  });
  const totals = new Map();
  for (const record of records) {
    const source = record.sourceName ?? record.sourceBundleId ?? 'Unknown';
    totals.set(source, (totals.get(source) ?? 0) + (record.value ?? 0));
  }
  return totals;
};
Enter fullscreen mode Exit fullscreen mode

Show these as what each source recorded, never as parts of a total: they won't add up to the aggregate(...) result.

Platform limits

  • History on Android: Health Connect allows reads from up to 30 days before the first permission grant. Google's read data guide (last updated September 24, 2026) names READ_HEALTH_DATA_HISTORY for older data. Support is planned in the plugin; until then, keep Android ranges inside that window.
  • No background reads: the plugin has no background or observer API, so data refreshes when the user opens the app.
  • On-device steps on Android 14+: with SDK Extension 20 or higher, Health Connect counts steps on the device once any app holds READ_STEPS. aggregate(...) includes them, and since a June 2026 update they're credited to a device-specific package name instead of android.

Conclusion

Use aggregate(...) for every step number you display (today's total, a weekly chart, a monthly goal) and start each range at local midnight. Reach for readRecords(...) only when users want to see which device or app recorded their steps.

The full post on our blog, including an FAQ on Google Fit and older history, is at How to read step counts in a Capacitor app, and if you have questions, drop them in the comments.

Top comments (0)