HealthKit + Celery on ECS, Part 2: The Sync Pipeline
Part 2 of the HealthKit + Celery series. Part 1 covered why we needed background processing. This post covers the actual sync pipeline.
The Data Flow
HealthKit data lives exclusively on the device. There’s no Apple API your server can call. Every sample — heart rate, steps, sleep, cycle data — has to travel from the user’s iPhone through your API into your database.
iPhone (HealthKit) → Flutter (health package) → GraphQL mutation →
Django API → Redis queue → Celery worker → PostgreSQL
The Flutter app uses the health package to bridge Dart to native HealthKit APIs. On Android, the same package bridges to Health Connect, so the backend is platform-agnostic — it just receives JSON.
Schema Design: Six Tables, One Pattern
We created a dedicated api/features/healthkit/ module with six models:
HealthKitSource — tracks devices and apps. Apple Watch, iPhone, Oura, Whoop. Each source gets a precedence value (Watch=1, iPhone=2, third-party=3+) for deduplication when the same metric arrives from multiple sources.
HealthKitSample — the high-volume table. Heart rate, steps, HRV, active energy, body temperature. Time-series data indexed on (secure_user_id, sample_type, start_time) and a unique constraint on hk_uuid — Apple’s unique identifier for each sample.
HealthKitWorkout — session data with duration, energy burned, distance.
HealthKitSleepAnalysis — sleep sessions with stage data (awake, REM, core, deep). Apple frequently reclassifies sleep stages retroactively, which is why hk_uuid-based upserts are critical here.
HealthKitClinicalRecord — low-volume clinical data stored as FHIR JSON.
HealthKitSyncState — one row per user per data type, tracking the last_anchor and last_sync_time.
The key design decision: hk_uuid is the deduplication key, not timestamps. HealthKit assigns a UUID to every sample. When Apple reclassifies a sleep stage or a user edits a workout, the UUID stays the same but the data changes. update_or_create on hk_uuid handles both inserts and updates.
The 30-Day Backfill
When a user first connects Apple Health, the Flutter app requests a 30-day backfill. For an active Apple Watch user, this can be thousands of samples across multiple data types.
The backfill flow:
- Flutter calls
health.getHealthDataFromTypes()with a 30-day window - Data is chunked into batches of ~200 samples (to stay under payload limits)
- Each chunk hits the
syncHealthKitSamplesGraphQL mutation - Django validates the batch and enqueues a Celery task per chunk
- The Celery worker runs
update_or_createfor each sample - Only after the final chunk succeeds does the worker update
HealthKitSyncState
The chunking happens client-side:
Future<void> uploadInChunks(List<HealthDataPoint> allData, String anchor) async {
const int chunkSize = 200;
for (var i = 0; i < allData.length; i += chunkSize) {
var end = (i + chunkSize < allData.length) ? i + chunkSize : allData.length;
var chunk = allData.sublist(i, end);
var payload = {
'anchor_token': anchor,
'is_final_chunk': end == allData.length,
'data': chunk.map((e) => e.toJson()).toList(),
};
await graphqlClient.mutate(syncHealthKitSamples, variables: payload);
}
}
Anchor-Based Incremental Sync
After the initial backfill, subsequent syncs use HealthKit’s anchor system. An anchor is a bookmark — it tells HealthKit “give me only what changed since this point.”
The flow:
- Flutter asks the backend: “What’s my last anchor for heart rate data?”
- Backend returns the
last_anchorfromHealthKitSyncState - Flutter calls
health.getHealthDataWithAnchor(anchor)— returns only new/changed samples - Same chunk-upload flow as backfill, but with far less data
- Celery worker updates the anchor only after successful processing
This is the self-healing property of the pipeline. If the worker crashes mid-batch, the anchor doesn’t advance. Next sync re-fetches the same window. The update_or_create on hk_uuid means re-processing is safe — existing records get updated, not duplicated.
The “Device Locked” Problem
This one is specific to Apple and it breaks naive implementations.
When an iPhone is passcode-locked, HealthKit data is encrypted at rest. If your Flutter background sync fires while the phone is in the user’s pocket (locked), the HealthKit read returns empty or throws a permission error.
Our Celery worker had to handle “empty sync” gracefully — a sync with zero samples isn’t an error, it’s the phone being locked. The anchor doesn’t advance, and the next sync when the phone is unlocked picks up where it left off.
We also couldn’t rely on workmanager for frequent background syncs on iOS. Apple throttles background execution based on user behavior — if the user rarely opens your app, iOS reduces background execution to once a day or less. The practical sync frequency is “whenever the user opens the app, plus occasionally in the background.”
Source Precedence and Deduplication
When the same metric exists from multiple sources for the same timestamp — Apple Watch recorded heart rate, and a Bluetooth chest strap also wrote to HealthKit — we need a deterministic winner.
The precedence rules:
- Apple Watch beats iPhone sensor data
- iPhone beats third-party apps
- Within the same source, prefer the most recent sync (later
hk_uuidwins)
This is implemented in the aggregation service, not the sync pipeline. The sync pipeline stores everything. The query layer applies source precedence when returning data to the client. This separation means we never lose raw data — if precedence rules change, we can re-aggregate.
The Privacy Decision
Early in the integration, our team raised concerns about bidirectional sync — specifically, writing Ourself data back to Apple Health where it becomes visible to other apps. The team decision:
“We are going to only write access for apple health now as we are realizing there are security risks sharing data back to Apple we were unaware of before.”
This simplified the architecture significantly. Read-only HealthKit access means we’re a spoke consuming from the hub, not a peer contributing back. No write mutations, no conflict resolution with other apps editing the same samples.
What the Celery Task Actually Looks Like
@shared_task(bind=True, max_retries=3, time_limit=300)
def process_health_sync(self, user_id, chunk_data, anchor_token, is_final):
try:
for item in chunk_data:
HealthKitSample.objects.update_or_create(
hk_uuid=item['uuid'],
defaults={
'secure_user_id': user_id,
'sample_type': item['type'],
'value': item['value'],
'unit': item['unit'],
'start_time': item['date_from'],
'end_time': item['date_to'],
'source_id': resolve_source(item['source']),
'metadata': item.get('metadata', {}),
}
)
if is_final:
HealthKitSyncState.objects.update_or_create(
secure_user_id=user_id,
sample_type=chunk_data[0]['type'],
defaults={'last_anchor': anchor_token}
)
except Exception as exc:
raise self.retry(exc=exc, countdown=60)
The is_final flag is important. We only update the anchor after the last chunk of a sync batch succeeds. If any earlier chunk fails, the anchor stays at the old position and the entire sync retries from that point.
Next: ECS Deployment and the Sidecar Architecture
In Part 3, I’ll cover deploying this to ECS Fargate — the sidecar container pattern, the env var crash that taught us about Django settings import order, health checks for headless workers, and the Redis configuration across DEV/UAT/PROD.