The Background Sync That Fought the Operating System
How we built a dual-platform health data pipeline for Vaidya AI — and what the OS taught us about assumptions.
Vaidya AI needs your health data. Steps, sleep, heart rate, calories — synced from HealthKit on iOS and Health Connect on Android to our backend, continuously, even when the app isn't open. That's the requirement. Simple to say. The implementation fought us at every layer.
Here's an honest account of how we built it, what broke, and what the system looks like after a 7-day field test with real users.
The platform asymmetry you can't design away
iOS and Android have fundamentally different background execution models. This is not a Flutter quirk — it's the platforms asserting completely different philosophies about when apps are allowed to run.
| iOS | Android | |
|---|---|---|
| Trigger model | OS decides (data-driven push) | App schedules (time-driven poll) |
| Mechanism | HKObserverQuery + enableBackgroundDelivery | WorkManager PeriodicWorkRequest (~30min) |
| Budget | ~30s before demotion to hourly/daily | ~10 minutes |
| Who controls timing | The OS. Always. | You set the interval, OS can defer it. |
On iOS, you register an HKObserverQuery and call enableBackgroundDelivery. The OS wakes your app when new health data arrives. You don't know when that will be. You get roughly 30 seconds. If you don't finish, the OS demotes your app and future deliveries become hourly, then daily.
On Android, you schedule a PeriodicWorkRequest via WorkManager. You ask for 30-minute intervals. The OS delivers them when it feels like it, within battery and power constraints. You have about 10 minutes to do what you need.
We wrote one shared SyncEngine in Dart. Both platforms call it with the same interface. The divergence lives only at the trigger layer — two native implementations that converge on a single Dart entry point.
The keystone: idempotency
Before writing a single line of sync logic, we made a design decision that made everything else simpler: every health record gets a deterministic client_record_id.
// steps taken on March 14th, in the 9 AM hour String clientId = 'steps_2026-03-14_09'; // heart rate sample at a specific minute String clientId = 'heart_rate_2026-03-14_09:34';
The backend does an upsert keyed on this ID. If we send the same record twice — because the background sync ran twice, because a retry happened, because the foreground app and background sync both sent it — the second write is a no-op.
Idempotency let us stop worrying about exactly-once delivery and implement at-least-once. Retries, duplicates, and out-of-order delivery all become harmless. The mathematics of the problem collapsed. We were no longer building a synchronization protocol — we were just sending data.
The two-tier architecture
There are two syncing paths, with a strict invariant between them.
Foreground sync is the source of truth. It's the only path that writes the sync cursor — the "last synced through" timestamp. It advances the cursor only after receiving a confirmed 200 from the backend. If the app crashes mid-sync, the cursor doesn't advance, and we retry from the same point on next open.
Background sync is best-effort freshness. It sends today's data only — a stateless, clock-derived window. It never reads or writes the cursor. It cannot corrupt the foreground's position.
The invariant: background sync can fail silently at any point, and the foreground will catch up. Background freshness can never compromise foreground completeness. This meant we could design the background path to be dead simple — no cursor management, no state, just "get today's data and send it."
Bug 1: The compiler dropped our entry point
We registered a headless Dart entry point for the background isolate. Added the `@pragma('vm:entry-point')` annotation. Deployed. Background sync never triggered — not once, on any device.
The Dart compiler's tree-shaker saw the entry point function was never called from anywhere in the app, and eliminated it. `@pragma('vm:entry-point')` alone does not prevent tree-shaking if the file is not imported.
Fix: import the background entry point file from main.dart. Not called — just imported. The import is enough to pull it into the build graph. The native side could then find and invoke it by URI.
// main.dart — the import is the fix // ignore: unused_import import 'background/health_sync_entrypoint.dart';
Bug 2: Hive deadlocked in the background isolate
The foreground app holds approximately 30 Hive boxes open at runtime — user data, cache, preferences. Hive is single-isolate: only one isolate can hold a write lock on a box at a time.
When the background isolate woke up and tried to open any Hive box, it hung indefinitely. The foreground isolate was suspended (app in background), but it still held the locks. The background isolate waited. The OS budget expired. The sync never ran.
There is no timeout on Hive lock acquisition. The background isolate will wait forever, which in practice means waiting until the OS kills it for exceeding budget.
Fix: the background isolate is Hive-free. It bootstraps using SharedPreferences and secure storage only. No Hive reads, no Hive writes. We made this an enforced constraint: any Hive import in background-isolate code paths fails the build.
Bug 3: The suspended-app timer
We added a budget guard: if background execution exceeded 8 seconds, cancel and schedule a retry. We implemented this as a Dart timer that fires at 8s and sets a cancellation flag.
In testing: a user opened the app, background sync started, user locked the phone after 3 seconds. The sync was mid-execution. The timer was also suspended — Dart timers don't run when the isolate is frozen. The timer fired 74 minutes later when the user next unlocked the phone, well after the background budget had already expired and the OS had killed the background task.
`budget_exceeded` fired 74 minutes after the budget was actually exceeded. The guard was measuring suspended time, not wall-clock time.
Fix: record a start timestamp at sync initiation. At each checkpoint inside the sync loop, compare `DateTime.now()` against the start time. Cancel if elapsed > 8s. No timers — just arithmetic on timestamps that the OS can't suspend.
The diagnostic system
We built `bg_diag` — a breadcrumb logger that writes timestamped state stamps to secure storage. Every background execution appends: `state=background`, `trigger_source`, `sync_started`, `records_sent`, `cursor_advanced` or `cursor_skipped`, `elapsed_ms`.
The foreground app reads and displays these on a debug screen. When we ran the 7-day field test, we could see exactly when the OS woke us, how long we ran, what we sent, and whether the cursor moved. The `state=background` stamp was how we proved the sync was genuinely running in the background and not just firing when the app was in the foreground.
What 7 days of real usage showed
- ›iOS background deliveries averaged 2–3 per day per user, roughly 30–90 minutes apart (OS decides)
- ›Android WorkManager delivered within 5 minutes of the requested 30-minute interval on most devices
- ›Zero cursor corruptions — the foreground/background isolation held perfectly
- ›Idempotent upserts absorbed every duplicate without error
- ›10,600+ health entries synced per user over 7 days with no data loss
The design principles that made it work
In order: idempotency first, then tiering, then budget awareness. Get the mathematics right before the mechanics. Idempotency meant we could stop worrying about "what if this runs twice?" and focus on "does this reliably run at all?"
The two-tier model gave us graceful degradation for free. Background sync failing silently is fine. The foreground will catch up. A system where every component needs to succeed perfectly is a system where any component can bring down the whole thing.
And debugging background code requires infrastructure — not print statements. `bg_diag` was not optional. You cannot attach a debugger to a background isolate. You need the breadcrumbs before you need them.