DEV Community

Cover image for How I built true background step counting in React 19.2 + Capacitor on Android 14/15 (without Google Fit, which is shutting down)
Euexia
Euexia

Posted on Edited on

How I built true background step counting in React 19.2 + Capacitor on Android 14/15 (without Google Fit, which is shutting down)

Try it first (no signup): https://drive.google.com/file/d/1EKtKC2EAY_9EKt6VZ-XZj3y36orUPMzw/view?usp=sharing

euexia-demo.apk is 10.6 MB and comes pre-seeded with 8,432 steps, a 517 cal flame streak, and seven days of charts. Swipe it closed, walk 20-30 steps, then pull down the notification shade. The count keeps rising while the app is gone.

The problem

Every React/Capacitor health template I looked at did the same thing: call Google Fit, read today's steps, render a ring. It works until the app is killed. Then the WebView is gone, the polling stops, and the number freezes.

Google is also winding Fit down in favour of Health Connect, so building a new paid product on that API in 2026 is a bad bet.

The hardware can already count steps without the app running. Android exposes Sensor.TYPE_STEP_COUNTER, a low-power sensor-hub counter that keeps ticking at the firmware level. The trick is keeping a process alive to listen to it.

The architecture

Sensor.TYPE_STEP_COUNTER
        Γåô
BackgroundStepService (foreground, type=health, START_STICKY)
        Γåô
SharedPreferences ("AppSteps" / "steps")
        Γåô
StepSensorPlugin (Capacitor bridge)
        Γåô
React ΓÇö 5s polling + refresh on visibilitychange/appStateChange
        Γåô
Supabase daily_logs (Postgres + RLS)
Enter fullscreen mode Exit fullscreen mode

Four pieces make it work:

  1. A foreground service owns the sensor, not the WebView.
  2. The service writes every sensor event to SharedPreferences, so the JS bridge never races the sensor.
  3. A tiny Capacitor plugin reads that value whenever React asks.
  4. Android restarts the service because onStartCommand returns START_STICKY.

1. Manifest: declare the right foreground-service type

Android 14+ requires a type for every foreground service, and the type must have a matching permission. A pedometer is a health use case, so use health not dataSync. On Android 15, dataSync services are capped at six hours per day, which would silently stop a step counter.

<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_HEALTH" />

<service
    android:name=".BackgroundStepService"
    android:enabled="true"
    android:exported="false"
    android:foregroundServiceType="health" />
Enter fullscreen mode Exit fullscreen mode

ACTIVITY_RECOGNITION is a runtime permission on Android 10+. Request it from MainActivity before starting the service.

2. The service: register the hardware sensor and never let go

public class BackgroundStepService extends Service implements SensorEventListener {
    private static final String CHANNEL_ID = "app_step_channel";
    private static final int NOTIF_ID = 1001;

    private int todaySteps = 0;
    private int lastSensorValue = -1;
    private SharedPreferences prefs;

    @Override public void onCreate() {
        super.onCreate();
        prefs = getSharedPreferences("AppSteps", MODE_PRIVATE);
        todaySteps = prefs.getInt("steps", 0);
        lastSensorValue = prefs.getInt("lastSensorValue", -1);

        createNotificationChannel();
        if (Build.VERSION.SDK_INT >= 34) {
            startForeground(NOTIF_ID, buildNotification(todaySteps), ServiceInfo.FOREGROUND_SERVICE_TYPE_HEALTH);
        } else {
            startForeground(NOTIF_ID, buildNotification(todaySteps));
        }

        SensorManager sm = (SensorManager) getSystemService(SENSOR_SERVICE);
        Sensor step = sm.getDefaultSensor(Sensor.TYPE_STEP_COUNTER);
        sm.registerListener(this, step, SensorManager.SENSOR_DELAY_UI);
    }

    @Override public int onStartCommand(Intent i, int flags, int startId) {
        return START_STICKY; // Android restarts us if it kills the process
    }

    @Override public void onSensorChanged(SensorEvent event) {
        int raw = (int) event.values[0];

        // TYPE_STEP_COUNTER is cumulative since boot. A lower value means reboot.
        if (lastSensorValue == -1 || raw < lastSensorValue) lastSensorValue = raw;

        int delta = raw - lastSensorValue;
        lastSensorValue = raw;

        // Optional: reset the logical day at 5:00 AM, not midnight.
        rolloverIfNewDay();

        todaySteps += delta;
        prefs.edit()
             .putInt("steps", todaySteps)
             .putInt("lastSensorValue", lastSensorValue)
             .apply();

        updateNotification(todaySteps);
    }
}
Enter fullscreen mode Exit fullscreen mode

Two details that bit me:

  • TYPE_STEP_COUNTER is cumulative since boot, not "today". You have to subtract the previous raw value and persist it. Handle raw < lastValue as a reboot.
  • The service must write to storage on every event. If React tried to register its own listener, it would die with the WebView.

3. The bridge: let React ask for the number

@CapacitorPlugin(name = "StepSensor")
public class StepSensorPlugin extends Plugin {
    @PluginMethod
    public void getSteps(PluginCall call) {
        SharedPreferences prefs = getContext()
            .getSharedPreferences("AppSteps", Context.MODE_PRIVATE);
        int steps = prefs.getInt("steps", 0);

        JSObject ret = new JSObject();
        ret.put("value", steps);
        call.resolve(ret);
    }
}
Enter fullscreen mode Exit fullscreen mode

Register it in MainActivity before super.onCreate:

@Override public void onCreate(Bundle savedInstanceState) {
    registerPlugin(StepSensorPlugin.class);
    super.onCreate(savedInstanceState);
}
Enter fullscreen mode Exit fullscreen mode

4. React: poll while visible, refresh the instant you resume

const StepSensor = Capacitor.isNativePlatform()
  ? registerPlugin("StepSensor")
  : null;

const fetchNativeSteps = async () => {
  if (!StepSensor) return;
  const { value } = await StepSensor.getSteps();
  setSteps(value);
};

useEffect(() => {
  fetchNativeSteps();
  const id = setInterval(fetchNativeSteps, 5000);
  const onVisible = () => {
    if (document.visibilityState === "visible") fetchNativeSteps();
  };
  document.addEventListener("visibilitychange", onVisible);
  return () => {
    clearInterval(id);
    document.removeEventListener("visibilitychange", onVisible);
  };
}, []);
Enter fullscreen mode Exit fullscreen mode

Guard the plugin registration with Capacitor.isNativePlatform() so the browser build does not throw.

Gotchas I hit on real devices

  • Emulators do not emit hardware step events. You need a physical phone.
  • OEM battery managers (Xiaomi/MIUI, Samsung) may still kill the service. START_STICKY helps; asking users to disable battery optimisation helps more.
  • The persistent notification is mandatory on Android 8+. Do not try to hide it; Google Fit, Samsung Health, and Fitbit all show one.
  • Day rollover must happen inside the service, because the app may not be open at 5:00 AM.
  • Restarting the service on boot is a separate BOOT_COMPLETED receiver; add it if you need counting to resume before the user opens the app.

Try it, then break it

Install the demo, close it, walk, reopen it:

https://drive.google.com/file/d/1EKtKC2EAY_9EKt6VZ-XZj3y36orUPMzw/view?usp=sharing

If the count kept moving, the service is doing its job.

I packaged this architecture, the Supabase schema, auth, water/workout/streak screens, and the setup guide into a production React 19.2 + Capacitor template. If you would rather clone it than rebuild the native layer:

https://theoria0.gumroad.com/l/euexia-health-app-template

I'm the author of the template. Happy to answer architecture questions in the comments.

Top comments (0)