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.
Setting Up Your Project
Updated September 12, 2026. This section used to have you pipe a schema file into
psqland point the app at a connection string. That setup could not sync: the app writes through a Supabase Edge Function, which it never deployed. The steps below are the ones that work.
In a Supabase project you own:
- Open the SQL editor and run health4.ai/schema.sql. It creates the tables, turns on row-level security, and removes direct client access.
- Deploy the ingest function with the Supabase CLI:
supabase functions deploy healthkit-ingest --project-ref <your-project-ref> --no-verify-jwt
- Under Authentication → Users, add the user you will sign in as. The app signs in; it does not sign up.
- In the app, enter your Project URL and anon key, then sign in as that user.
The full walkthrough is How to Set Up health4ai with Supabase.
Once you are signed in, the first backfill runs, and the app’s Home screen shows its progress with the record count and how far back it has reached. After that, HKObserverQuery keeps it current automatically.
health4ai: Free while in early access.
Download on the App Store →