How Protocol syncs, stores, and reconciles Steps

Steps come from HealthKit on the phone, from Oura's cloud, and never from WHOOP. This is how those readings reach Supabase, how a day's canonical number is chosen, and what still can't be guaranteed.

Written from main of the Protocol repo · Updated September 3, 2026

365days
one-time backfill after the first HealthKit permission grant
HKSync.backfillDayCount
15days
per backfill chunk, uploaded and checkpointed together
backfillWindowDays
14days
rolling window re-read on every cold launch and day rollover
syncRecentDays()
120s
minimum gap between foreground syncs
foregroundSyncMinInterval
4h
staleness before the server sends a silent wake push
STALE_AFTER_HOURS
max
rule for steps when two sources report the same day
pickHighest()
Overview

Three sync modes, one endpoint

Every path into Protocol reads HealthKit with the same per-day summary function and posts to the same route. What differs is how many days each mode reads and what wakes it. Hold these three in mind and the rest of the document is detail.

Initial backfill

Runs once, after the first permission grant

Imports the trailing year so goals, streaks, and trends have history from day one. Chunked, resumable, and re-runnable when the payload gains fields.

Window
365 days
Chunk
15 days per request
Retries
3, with 1 s / 2 s backoff
How the backfill works →

Ongoing sync

Runs many times a day

A cheap single-day read keeps today live. A 14-day rolling re-read on cold launch and on the first sync after midnight settles closed days to their true totals.

Today
1 day
Rolling
14 days
Pull to refresh
3 days
Today versus the rolling window →

Foreground vs background

Who wakes the app decides what runs

Foreground: launch, re-activation, pull to refresh. Background: HealthKit observers when new samples land, and a server-sent silent push when a device goes quiet.

Foreground gap
≥ 120 s
Silent push
stale ≥ 4 h
Runtime
~30 s per wake
What runs where →
Context

Why steps are different from every other metric

Oura and WHOOP are pulled. The server holds OAuth tokens and an hourly Vercel cron fetches their APIs whether or not the phone is awake. Apple Health has no cloud API at all. HealthKit is readable only on the device, by the app, while the app has runtime. So Apple Health steps are pushed: the iOS app reads HealthKit and POSTs a day summary to the web API, and the server's only lever is to ask the phone, politely, to do that.

WHOOP does not expose steps through its API. Its sync writes steps: null on every activity row, so for steps the real contest is Apple Health versus Oura.

Sync

Every trigger that syncs Apple Health steps

Six things cause the iOS app to read HealthKit and post. They share one endpoint and one per-day summary function; they differ in which days they read and how often they may fire.

TriggerWhat runsDays readThrottleWhere
First permission grantOnboarding, the standalone Apple Health gate, or the Integrations tabbackfillIfNeeded()365Once per user per backfill versionHealthKitManager.connectThenSyncInBackground
Cold launchMainTabView's .task when already authorizedresumeIfAuthorized() → re-register observers → syncRecentDays(14)14None (once per launch)HealthKitManager.resumeIfAuthorized
Return to foregroundAny warm re-activation: app switcher, tapping the iconsyncOnForeground() → syncToday()1up to 14 on a day rolloverSkipped if last sync < 120 s agoProtocolApp scenePhase → HKSync.syncOnForeground
Pull to refreshToday tabsyncRecentDays(3), plus a server-side /api/sync/all?days=2 for Oura and WHOOP3User-initiatedTodayView.refreshable
HealthKit background deliveryiOS wakes the app when a new stepCount sample landsHKObserverQuery → syncToday()1frequency: .immediate; iOS decides actual cadenceHealthKitManager.registerBackgroundDelivery
Silent push wakeServer cron sends content-available to stale devicesdidReceiveRemoteNotification → syncToday()1up to 14 on a day rolloverServer: stale ≥ 4 h, ≥ 4 h between wakes, 24 h after 3 unansweredAppDelegate + lib/push/silentSyncWake.ts

The day-rollover promotion. syncToday() checks how many calendar days have closed since the last successful sync. If any have, it promotes itself to syncRecentDays(min(closed + 1, 14)) so yesterday settles to its true final total instead of freezing at whatever partial count the last observer callback saw. This fires at most once per day; the rest of the day stays on the cheap single-day read.

Sync · Mode 1

Initial backfill: 365 days, in 15-day chunks

After the first HealthKit permission grant the app imports the trailing year, matching the 365-day window Oura and WHOOP get on connect. It runs behind the UI so onboarding proceeds immediately; the Today tab shows an “Importing…” card driven by HKSync.backfillProgress.

  1. Reset or resume the cursorA per-user cursor in UserDefaults records how many of the oldest days are committed. If the target backfill version changed, the cursor resets to 0 and today's date is captured as a fixed anchor, so a resume on a later day maps every offset to the same calendar date.
  2. Collect one windowOldest to newest, 15 days at a time. Each day is read via the shared summary(for:) function, which produces the full day payload (steps plus every other metric). Days with no HealthKit data are dropped.
  3. Upload the window as a batchOne POST of { days: [...] }. Three attempts with 1 s, then 2 s backoff. If all three fail, the cursor stays put and the next launch resumes this exact window.
  4. Persist the cursor, advanceOnly after a successful upload. Server upserts are idempotent, so a crash between upload and cursor write is safe. A background-task assertion holds ~30 s so a window in flight finishes when the user backgrounds the app.
  5. Mark completeWhen the cursor reaches 365, the per-user version flag is set to the current backfill version and lastSyncedAt is stamped.
ConstraintValueWhy
Window per request15 daysThe server runs updateDailyScorecard per day inside a 60 s function cap; a 30-day window could breach it and fail the chunk.
Server batch ceiling120 daysMAX_BATCH_DAYS rejects larger payloads with 400.
Retries per window3Backoff 1 s, 2 s. Leaves the cursor untouched on final failure.
Completion flagper user idA device-wide flag once caused a second account on the same phone to skip its own import.
Current backfill version8Bumping re-runs the idempotent import when the payload gains fields. v8 added sleep timing and hourly glucose; steps have been in the payload since v1.

Doc drift to be aware of. A comment on HealthKitManager.connectAndSync still says “90-day backfill”, and apps/ios/ARCHITECTURE.md shows an older 8-field payload. The code constant is 365 and the payload is the rich day summary described below. Trust HKSync.swift.

Sync · Mode 2

Ongoing sync: today plus a rolling window

After the backfill, two shapes of sync keep steps current.

Single daycheap, frequent

syncToday() reads only the in-progress day and posts it as a single-day body. Fired by HealthKit observers, by every foreground re-activation (subject to the 120 s throttle), and by the silent push.

Profile characteristics (age, sex, height) ride along on each call because they're cheap and idempotent.

Rolling windowsettles closed days

syncRecentDays(14) re-reads today and the previous 13 days and posts them as one batch. Fired on every cold launch, and by syncToday() itself on the first sync after a calendar-day rollover. Pull-to-refresh uses a 3-day version.

Its job is to correct days that were frozen mid-count and to fill short gaps where the app never ran. Long gaps are the backfill's job, not this path's.

The reason the rolling window exists is worth stating plainly: the server overwrites the Apple Health row for a day on each post. It does not keep the higher of the old and new value. So a day is correct only if it was read after it closed. Re-reading yesterday on the first sync of each morning is what makes yesterday's total trustworthy.

Sync · Mode 3

Foreground versus background

Foregrounduser present

  • Cold launch: re-register observer queries (they don't survive a relaunch), resume any unfinished backfill, then a 14-day rolling sync.
  • Re-activation: syncToday(), throttled to once per 120 s so tab switches and sheet dismissals don't hammer HealthKit. Posts .healthDataSynced only when data actually changed, so the Today view reloads on real movement rather than on every app switch.
  • Pull to refresh: 3-day device sync plus a 2-day server pull for the cloud sources, each as its own unstructured task so a slow Oura pull can't delay painting the Apple result.

BackgroundiOS grants runtime

  • HealthKit background delivery: enableBackgroundDelivery(frequency: .immediate) plus an HKObserverQuery per read type. When a new sample lands, iOS grants brief runtime and the observer calls syncToday(). Requires the healthkit.background-delivery entitlement, which the app has.
  • Silent push: requires remote-notification in UIBackgroundModes, which the app also has. Roughly 30 s of runtime.
  • What does not run in background: the full backfill. It only holds a ~30 s task assertion to finish the window in flight. Progress is durable, so each foreground session makes permanent forward progress.

Observers die silently. HKObserverQuery registrations live in process memory. After iOS terminates the app for memory pressure, a reboot, or a force-quit, they are gone until the next launch, and nothing on the server can tell. This is the gap the silent push was built to cover. It is also why the daily email's stale-sync banner still tells Apple Health users to leave the app running rather than swiping it away.

Sync · Background

Silent push wake

An hourly Vercel cron at minute 20 finds Apple Health users whose data has gone stale and sends each device an invisible content-available push. The payload is exactly { "aps": { "content-available": 1 } }, with no alert, sound, or badge. Its only purpose is to hand the app runtime.

  1. Select candidatesUsers with data_source_connections.source = 'apple_health' connected, a row in device_push_tokens, and last_synced_at at least 4 hours old. Oura-only and WHOOP-only devices are never woken; the cron already has their data.
  2. Space the wakesAt least 4 hours between wakes per user, so at most six a day. iOS budgets background pushes and doesn't top the budget back up, so hourly knocking at an unresponsive device wastes what a better-timed wake could have used.
  3. Back off the unreachableA wake is “unanswered” if no sync followed it. After 3 unanswered wakes the user drops to 24-hour spacing. The count self-heals: one successful sync moves last_synced_at past every prior wake and normal spacing resumes.
  4. Send and logTokens are grouped by APNs environment (sandbox for debug builds, production for TestFlight and App Store). Dead tokens are pruned. One push_sends row per user records the campaign for the admin history.
  5. Device respondsdidReceiveRemoteNotification checks HealthKit is available and authorized, runs syncToday() (which promotes to the rolling window if days rolled over), and returns .newData only if lastSyncedAt actually moved. iOS uses that verdict to decide how generously to wake the app in future, so the app reports honestly.
ConstantValueDefined in
STALE_AFTER_HOURS4apps/web/src/lib/push/silentSyncWake.ts
MIN_HOURS_BETWEEN_WAKES4
BACKOFF_AFTER_UNANSWERED3
BACKOFF_HOURS24
WAKE_HISTORY_DAYS7
Cron schedule20 * * * *vercel.json

Push registration is deliberately decoupled from alert permission. The app registers for remote notifications even when the user declined alerts, because a device token is all a silent push needs. Declining a notification prompt should not cost someone their health sync.

The measurement that motivated it (live accounts, 2026-08-05): Oura and WHOOP were each 7 of 7 synced within 24 hours with a median of 0.2 h since last sync. Apple Health was 3 of 17, with a median of 85.2 h, about 3.5 days. The silent wake raises the floor. It is not a guarantee: iOS drops background pushes in Low Power Mode, deprioritizes rarely opened apps, and never delivers one to an app the user force-quit.

Sync · On device

How a day's step count is read

Every sync path calls the same summary(for: date) function. For steps it issues one HKStatisticsQuery on stepCount with .cumulativeSum, bounded to the calendar day in the device's current time zone.

// HKSync.summary(for:) — the steps read, simplified
let startOfDay = Calendar.current.startOfDay(for: date)
let endOfDay   = cal.date(byAdding: .day, value: 1, to: startOfDay)!
let dayPredicate = HKQuery.predicateForSamples(
    withStart: startOfDay, end: endOfDay, options: .strictStartDate)

async let steps = readSum(store, type: .init(.stepCount),
                          unit: .count(), predicate: dayPredicate)
// readSum → HKStatisticsQuery(options: .cumulativeSum)
//          sum > 0 ? sum : nil     // a zero day is sent as nil
  • iPhone and Apple Watch are merged by HealthKit, not by Protocol. Because the query does not pass .separateBySource, HealthKit applies its own overlap resolution across the user's devices, and the sum matches the number the Health app shows. Protocol never sees per-device step samples and does not try to dedupe them itself.
  • Third-party writers are included. Any app that writes stepCount samples to Health is part of the sum, exactly as the Health app would count them. There is no notOurs exclusion for steps; that predicate exists only for dietary and water reads, because Protocol writes meals and water back to Health.
  • Day boundaries use .strictStartDate: a sample counts once, on the day it starts, so multi-day interval samples are never double-counted across days.
  • Zero is never sent. A zero sum returns nil on device, and the server additionally ignores steps ≤ 0. A missing read looks like a sync gap, not a real zero.
  • The date string is device-local. YYYY-MM-DD from Calendar.current. The server trusts it; it falls back to today in US Eastern only if the string is malformed.
Storage

Three layers, one unit: the day

Steps live in a per-source table, then in a merged daily row, and are finally re-checked at read time on the web dashboard. Nothing is stored at the sample level; the unit of storage is a day.

The wire payload

The iOS app posts either one day or a batch, authenticated with the user's Supabase JWT. The user is always the session user; there is no shared-secret mode.

// POST /api/sync/apple-health  (steps-relevant fields shown)
{
  "days": [
    { "date": "2026-09-02", "steps": 11842, "active_calories": 612, "basal_calories": 1710, ... },
    { "date": "2026-09-03", "steps": 3096,  "active_calories": 148, ... }
  ]
}

Per-source rows

One row per user, per day, per source. The Apple Health handler upserts with source = 'apple_health'; the Oura sync upserts the same shape with source = 'oura'. WHOOP writes rows too, always with null steps.

create table activity_records (
  id uuid primary key,
  user_id uuid not null references auth.users(id),
  date date not null,
  source text not null check (source in ('oura','apple_health','whoop','garmin','manual')),
  steps integer,
  active_calories integer,
  total_calories integer,
  active_minutes integer,
  water_oz numeric,
  score integer,
  raw_data jsonb,          -- { "method": "native_ios" } for Apple Health
  created_at, updated_at timestamptz,
  unique(user_id, date, source)
);
-- RLS: users own their rows. The ingest route writes with the service role.

The canonical day

After every day it writes, the ingest route calls updateDailyScorecard(userId, date). That function reads all of the day's source rows, applies the per-metric rules, and upserts one row on (user_id, date). Its steps column is what the web dashboard, iOS Today tab, rings, streaks, daily email, and coach all read. The route then recomputes weekly workout counts and per-metric streaks, and stamps data_source_connections.last_synced_at for apple_health, which is the timestamp the silent-wake cron and the stale banner watch.

Read-time freshness pass (web only)

getDashboardData re-reads today's activity_records and applies the same highest-value rule via pickHighestRecord. If a source row landed after the last scorecard merge, the dashboard shows it without waiting. It also derives activitySource, the label shown next to the step count. iOS does not re-resolve values; it displays the server-merged scorecard and mirrors the priority order only to pick the source label.

TableRole for stepsKey
activity_recordsPer-source day totals. Apple Health and Oura each own their row.(user_id, date, source)
daily_scorecardsCanonical merged day. steps is the number every surface shows.(user_id, date)
user_metric_source_preferencesOptional per-metric pin: metric = 'steps', preferred_source or 'auto'.(user_id, metric)
data_source_connectionsConnected flag and last_synced_at per source. Drives staleness.(user_id, source)
device_push_tokensAPNs tokens plus environment, upserted by the app over RLS.token
push_sendsWake history for the silent_sync_wake campaign; used to count unanswered wakes.append-only
Reconciliation

Which source wins for steps

Most metrics use a fixed priority list and take the first source with data. Sleep is Oura first; HRV is WHOOP first. Steps and active calories are the exception: the highest reported value wins. The reasoning is that a phone or watch that counted 12,000 steps has a more complete view than a ring that saw 8,000, and a source that reports zero almost always missed the day rather than observed stillness.

// lib/scorecard/updateScorecard.ts — the write-time rule
function pickHighest(records, field, prefKey, defaultOrder) {
  const nonZero = records.filter(r => Number(r[field] ?? 0) > 0);
  const pool = nonZero.length > 0 ? nonZero : records;
  if (prefs[prefKey]) return pick(pool, field, buildOrder(defaultOrder, prefKey));
  return max over pool[field];
}
steps = pickHighest(activities, 'steps', 'steps',
                    ['apple_health', 'garmin', 'whoop', 'oura']);
  1. Drop zero rows when any source reported a real value.
  2. If the user pinned a source for steps in Settings, revert to priority order with that source first. The default order (used only for the pin path and for tie labels) is Apple Health, then Garmin, WHOOP, Oura.
  3. Otherwise take the maximum. Ties resolve to the earlier source in the default order.
SituationApple HealthOuraPreferenceScorecard stepsAttributed to
Both synced, phone counted more12,4188,902auto12,418apple_health
Ring counted more (phone left at desk)4,1109,377auto9,377oura
Apple Health hasn't synced today—6,540auto6,540oura
Apple row exists with 0 (filtered)06,540auto6,540oura
User pinned Oura for steps12,4188,902oura8,902oura
Only Apple Health connected7,206—auto7,206apple_health
WHOOP only——autonullno steps

The same rule runs in two places on purpose. The write-time merge makes the stored scorecard correct for iOS and email, which never re-derive. The read-time pass on web makes today feel live. Both are registered in the cross-platform parity doc (section 7, and the resolved drift item DR-3 from 2026-07-14, when the write-time side was brought into line with the dashboard).

User control. Settings shows a per-metric source picker only for metrics where two or more connected sources appear in the priority list. For a user with Apple Health and Oura, “Steps” appears with options Auto, Apple Health, Oura. Saving writes user_metric_source_preferences and the next merge honours it.

Reconciliation

Apple Health versus Oura versus WHOOP

apple_healthourawhoop
Steps availableYesYes, from daily_activityNo. API has no steps; rows carry steps: null
DirectionDevice push (app reads HealthKit, POSTs)Server pull (stored OAuth token)Server pull (stored OAuth token)
Scheduled cadenceNone the server controls. Observers, foreground, and the hourly silent wake for stale devices.Hourly cron at :50, last 7 days, every connected userSame hourly cron
Connect backfill365 days, on device, in 15-day chunks365 days via /api/sync/oura?days=365 fired from the OAuth callback365 days
Pull to refresh3-day device sync2-day server pull2-day server pull
Day attributionDevice-local calendar dayOura's day field, user-localLocal date of the record
Freshness observed3 of 17 within 24 h before the silent wake shipped7 of 7 within 24 h7 of 7 within 24 h
Default rank for steps1st4th (Garmin and WHOOP sit between, neither live for steps)n/a
Effective ruleHighest non-zero value wins unless pinnedNever contributes

For someone wearing an Apple Watch and an Oura ring, the practical outcome is: Apple Health usually wins because the phone plus watch see more of the day, Oura fills in when the app hasn't posted yet, and the number can go up when Apple's row finally lands but will not go down.

Reference

Known limits and gotchas

  • Overwrite, not max, per source. Re-posting a day replaces the Apple Health row. A day read at 3:52 pm stays at its 3:52 pm count until something reads it after midnight. The rolling window and the rollover promotion exist to make that happen; a device that never gets runtime after a day closes will keep the partial count.
  • Force-quit defeats everything background. iOS never delivers a silent push, and never fires an observer, for an app the user swiped away. Only opening the app recovers. The daily email's banner says so when a source is more than 24 hours stale (STALE_SYNC_HOURS).
  • Observers are per-process. They are re-registered on every launch and lost on every termination. There is no signal to the server when they die; last_synced_at going stale is the only symptom.
  • iOS budgets background pushes. A few an hour at most, dropped in Low Power Mode, deprioritized for rarely opened apps. The 4-hour spacing and the 24-hour backoff are there to spend that budget where it can work.
  • Time zones are the device's. Steps are bucketed into the phone's current calendar day. A user who travels across zones can get a short or long “day”, and Oura's own local day may not line up exactly.
  • Historical Oura rows can carry days Apple never posted. If Apple Health was connected later than Oura, days before the Apple backfill anchor are Oura-only and stay that way unless a version bump re-imports.
  • Stale comments. /api/sync/all still says “Apple Health is push-only (iOS shortcut)”. The Shortcut path was removed in July 2026; the native app is the only ingest and JWT is the only auth.
Reference

Code map

ConcernFile
HealthKit permissions, observers, background delivery, launch resumeapps/ios/Protocol/Protocol/Core/HealthKit/HealthKitManager.swift
Backfill, today sync, foreground throttle, rolling window, per-day summaryapps/ios/Protocol/Protocol/Core/HealthKit/HKSync.swift
Scene-phase foreground trigger, silent push handlerapps/ios/Protocol/Protocol/App/ProtocolApp.swift
APNs registration and token upsertapps/ios/Protocol/Protocol/Core/Push/PushManager.swift
Pull-to-refresh, iOS source label resolutionapps/ios/Protocol/Protocol/Features/Today/TodayView.swift
Background modes and HealthKit entitlementsapps/ios/Protocol/Protocol/Info.plist · Protocol.entitlements
Apple Health ingest routeapps/web/src/app/api/sync/apple-health/route.ts
Canonical mergeapps/web/src/lib/scorecard/updateScorecard.ts
Priority map, pickHighestRecord, pickBestRecordapps/web/src/lib/data-source-priority.ts
Read-time freshness pass and source labelapps/web/src/lib/dashboard/getDashboardData.ts
Silent wake selection and constantsapps/web/src/lib/push/silentSyncWake.ts
APNs dispatch, background payloadapps/web/src/lib/push/apns.ts
Silent wake cron routeapps/web/src/app/api/cron/silent-sync-wake/route.ts
Oura pullapps/web/src/lib/oura/sync.ts · app/api/sync/{oura,all}/route.ts
Cron schedulesvercel.json
Schemasupabase/migrations/20260301000001_initial_schema.sql · 20260301000008_data_source_priority.sql · 20260301000009_user_metric_source_preferences.sql
Settings pickerapps/web/src/components/settings/DataSourcePreferences.tsx
Parity registry (section 7)docs/cross-platform-parity.md
Written from the Protocol repository as of September 3, 2026. Constants quoted are the values in code on that date; the code map lists where to confirm each one.