← All posts
June 23, 2026 · healthkitsupabasebackground-sync

HealthKit → Supabase: Reliable Background Sync That Actually Works

HealthKit → Supabase: Reliable Background Sync That Actually Works

Getting HealthKit data syncing reliably to a remote database is harder than it looks. iOS has a set of background execution mechanisms, and the wrong one will give you data that’s hours or days stale. The right one gives you near-real-time delivery.

Here’s the technical difference, why most solutions use the wrong approach, and what the correct architecture looks like.

Why Background Sync Is Hard on iOS

iOS aggressively limits what apps can do in the background. This is by design — unbounded background execution would destroy battery life. Apple provides a set of controlled mechanisms, each with different constraints:

BGProcessingTask — Intended for maintenance work like database cleanup or large uploads. iOS schedules these opportunistically, typically when the device is charging and idle. In practice, you might get one invocation per day, or none. You have no control over when it runs.

BGAppRefreshTask — Short-lived background execution (30 seconds) for fetching content. iOS throttles these based on how often the user opens the app. Low-usage apps get very few invocations.

HKObserverQuery — A HealthKit-specific mechanism where HealthKit itself delivers a notification to your app when new data matching a registered query type is added. This is push delivery from HealthKit. When your Apple Watch syncs a new HRV reading to your iPhone, HealthKit calls your registered observer immediately.

The practical difference: BGProcessingTask runs when iOS decides it’s convenient. HKObserverQuery runs when HealthKit has new data. For a sync system where you want your Supabase database to reflect current readings, only one of these works.

Why Health Auto Export’s Sync Fails

Health Auto Export — the most established app in this space — uses background processing for sync rather than HealthKit observer queries. This is why users report their data in the MCP server being hours out of date.

It’s not a bug in the implementation — it’s the ceiling of what BGProcessingTask can provide. When you ask the MCP server for your HRV from this morning’s Apple Watch reading, the server returns what was synced during the last background processing window. If iOS didn’t schedule one today, you don’t have today’s data.

The same constraint applies to polling-based architectures more broadly. Any sync that works by “check for new data on a schedule” is at iOS’s mercy on what that schedule actually looks like.

The Correct Architecture: HKObserverQuery

health4ai’s iOS app registers an HKObserverQuery for each metric type it tracks. The call looks roughly like this:

let query = HKObserverQuery(sampleType: sampleType, predicate: nil) { _, completionHandler, error in
    guard error == nil else { completionHandler(); return }
    // HealthKit delivered new data — sync it now
    Task { await syncRecentSamples(for: sampleType) }
    completionHandler()
}
healthStore.execute(query)
healthStore.enableBackgroundDelivery(for: sampleType, frequency: .immediate) { _, _ in }

enableBackgroundDelivery with .immediate frequency tells HealthKit to wake the app as soon as new data arrives — not on a schedule, not when the device is charging, but immediately when the sample is recorded.

This means when your Apple Watch finishes an HRV measurement and syncs it to your iPhone, HealthKit fires the observer, your app wakes, and the reading is in Supabase within seconds. The Home screen in the app shows “Last sync” and “Next sync” timestamps — in practice, “next sync” is “as soon as your watch records something.”

The Schema

The healthkit_metrics table stores raw samples:

CREATE TABLE IF NOT EXISTS healthkit_metrics (
    id            uuid        DEFAULT gen_random_uuid() PRIMARY KEY,
    user_id       text        NOT NULL,
    metric_type   text        NOT NULL,  -- HKQuantityTypeIdentifier* or HKCategoryTypeIdentifier*
    value         float8,
    unit          text,                  -- 'ms', 'count/min', 'kcal', 'km', etc.
    source_device text,
    started_at    timestamptz NOT NULL,
    ended_at      timestamptz,
    metadata      jsonb,                 -- sleep stages, workout subtypes, etc.
    synced_at     timestamptz DEFAULT now() NOT NULL,
    UNIQUE (user_id, metric_type, started_at)
);

The metadata jsonb column stores type-specific fields — sleep stage values, workout type identifiers, heart rate zone data. The schema is intentionally flat: all metric types land in one table, differentiated by metric_type, which mirrors HealthKit’s own type identifier naming convention (HKQuantityTypeIdentifierHeartRateVariabilitySDNN, HKCategoryTypeIdentifierSleepAnalysis, etc.).

For historical data beyond 30 days, samples are aggregated into healthkit_daily_summaries:

CREATE TABLE IF NOT EXISTS healthkit_daily_summaries (
    id            uuid        DEFAULT gen_random_uuid() PRIMARY KEY,
    user_id       text        NOT NULL,
    metric_type   text        NOT NULL,
    summary_date  date        NOT NULL,
    avg_value     float8,
    min_value     float8,
    max_value     float8,
    sum_value     float8,
    sample_count  int,
    unit          text,
    UNIQUE (user_id, metric_type, summary_date)
);

The MCP server’s query layer is tier-aware: windows within 30 days read raw samples from healthkit_metrics; longer windows transparently merge daily summaries with recent raws. A query for get_hrv_trend(days=90) spans both tables without you needing to know which is which.

What 5.6M Rows of HealthKit Data Looks Like

The full backfill on first launch imports your complete HealthKit history. For someone who has worn Apple Watch for a few years, this is a significant data volume — 5+ years of readings across 30+ metric types runs into millions of rows.

The three indexes on healthkit_metrics make this usable:

CREATE INDEX IF NOT EXISTS healthkit_metrics_user_time
    ON healthkit_metrics (user_id, started_at DESC);

CREATE INDEX IF NOT EXISTS healthkit_metrics_user_type_time
    ON healthkit_metrics (user_id, metric_type, started_at DESC);

CREATE INDEX IF NOT EXISTS healthkit_metrics_metadata
    ON healthkit_metrics USING gin(metadata);

The composite (user_id, metric_type, started_at DESC) index means queries like “give me all HRV readings for this user in the last 30 days” go straight to the right rows. The GIN index on metadata supports queries into the jsonb fields — workout types, sleep stage values, zone data.

In Supabase, the free tier (500MB database) handles personal data volumes comfortably. The daily summaries table keeps the raw table from growing indefinitely — samples older than 30 days are aggregated and the originals can be pruned.

Running the Schema

psql "$DATABASE_URL" < web/public/schema.sql

That single command creates both tables, all three indexes, and a unified view (v_healthkit_daily_quantity) that unions raw and summary data. For Supabase specifically, you can also apply it via the Management API if your connection string doesn’t have direct psql access:

export SUPABASE_PAT="sbp_your_personal_access_token"
PROJECT_REF="your_project_ref"

curl -X POST \
  "https://api.supabase.com/v1/projects/$PROJECT_REF/database/query" \
  -H "Authorization: Bearer $SUPABASE_PAT" \
  -H "Content-Type: application/json" \
  -H "User-Agent: health4ai-setup" \
  -d @- < <(jq -Rs '{query: .}' < web/public/schema.sql)

Once the schema is in place and the iOS app is configured with your connection string, the sync starts immediately. The first backfill runs in the background — the app’s Home screen shows a progress indicator with the record count and how far back it’s reached. After that, HKObserverQuery keeps it current automatically.


health4ai is free through July. Everyone in the founding batch gets lifetime access at $0.
Download on the App Store →