◉RORK LABJP
●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Beta since 09/15 with no stable date yet; running projects in Expo Go has required login since 09/03●WIDGET — A write-up traces an iOS widget that renders black only in production builds●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Beta since 09/15 with no stable date yet; running projects in Expo Go has required login since 09/03●WIDGET — A write-up traces an iOS widget that renders black only in production builds●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…
Articles/Dev Tools
⬡ Dev Tools/2026-07-13Advanced

Losing HealthKit Data on Incremental Sync — Designing HKQueryAnchor Persistence

When step or sleep data double-counts or goes missing on incremental HealthKit sync, the root cause is usually HKQueryAnchor persistence. Here is a working Swift design that handles newAnchor and deletedObjects correctly and stays consistent across reinstalls and background updates.

Rork Max236HealthKit5Swift48iOS116indie developer43

✦ Premium Article

The morning after I added an incremental "only pull what changed since yesterday" step to a calming wellness app, the weekly chart on my test device was doubled. The overnight background update and the fetch that ran when I opened the app were counting the same samples twice. The cause was not the fetch method — it was how I was holding the marker that says where to resume: the anchor.

If you re-fetch everything with HKSampleQuery each time, you never duplicate, but health data can grow to hundreds of records a day, and sweeping all of it on every launch is unkind to both battery and speed. So you switch to HKAnchoredObjectQuery for incremental fetches — but that API only works correctly if you keep its promise: hand the returned anchor back on the next run. There are a few classic ways to break that promise, and as an indie developer I have stepped on most of them. Here is the design, with code that runs.

Why turning off full fetches invites accidents

HKAnchoredObjectQuery returns only the samples added or deleted since the anchor you pass in. The flip side: if you pass no anchor, everything comes back as "new" every single time. The most common mistake is holding the anchor only in an in-memory variable that resets to nil when the app restarts. Now every restart treats the entire history as new, and if your local store naively adds those up, you double-count; if it replaces, you rewrite everything for nothing.

The other trap is deletions. When a user removes a manually entered step count in the Health app, an incremental pipeline built only around additions leaves the stale value in your local store forever. Incremental sync only matches Health once it receives both what was added and what was removed.

Fetch methodPer-run costDeletions reflectedAnchor management
HKSampleQuery, full historyHigh (scales with count)Yes, if you fully replaceNot needed
HKAnchoredObjectQuery, anchor not persistedEffectively full every runFirst run onlyIncomplete
HKAnchoredObjectQuery, anchor persistedLow (deltas only)Via deletedObjectsRequired

In other words, the correctness of incremental sync is almost entirely decided by how you hold the anchor.

Handling the three values the query returns

The query's results handler hands you three things: the newly added samples, the deleted objects, and the new anchor. Start by receiving all three and passing them into a single ingestion step.

import HealthKit
 
final class StepSyncEngine {
    private let store = HKHealthStore()
    private let type = HKQuantityType(.stepCount)
 
    // Resume from last time. On the first run, anchor is nil.
    func syncIncremental(completion: @escaping (Result<Void, Error>) -> Void) {
        let anchor = AnchorStore.load(for: type)  // restore from disk (below)
 
        let query = HKAnchoredObjectQuery(
            type: type,
            predicate: nil,
            anchor: anchor,
            limit: HKObjectQueryNoLimit
        ) { [weak self] _, newSamples, deletedObjects, newAnchor, error in
            guard let self else { return }
            if let error { completion(.failure(error)); return }
 
            // Treat the three as one ingestion unit (order matters)
            self.ingest(
                added: (newSamples as? [HKQuantitySample]) ?? [],
                deleted: deletedObjects ?? []
            )
 
            // Save the anchor only AFTER ingestion succeeds
            if let newAnchor {
                AnchorStore.save(newAnchor, for: self.type)
            }
            completion(.success(()))
        }
        store.execute(query)
    }
}

The deliberate choice here is placing the anchor save after ingestion. If ingestion throws but you advance the anchor anyway, that delta never comes back. "Never advance the anchor past what you actually ingested" is the safety valve of incremental sync.

✦

Thank you for reading this far.

Continue Reading

What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.

WHAT YOU'LL LEARN
✦You can trace step and sleep double-counting or gaps back to anchor persistence and fix it at the root
✦You will get working Swift code that handles HKAnchoredObjectQuery's newAnchor and deletedObjects correctly
✦You can apply an idempotent local ingestion design that stays consistent across reinstalls and background updates
Secure payment via Stripe · Cancel anytime
✦

Unlock This Article

Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.

or
Unlock all articles with Membership →
Share

Thank You for Reading

Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

Related Articles

⬡ Dev Tools2026-04-05
Rork Max × WidgetKit & Live Activities in Practice — From Home Screen to Dynamic Island
A complete guide to implementing iOS Widgets, Lock Screen widgets, and Dynamic Island Live Activities with Rork Max. Covers WidgetKit fundamentals, Timeline update strategies, App Intents integration, and monetization.
⬡ Dev Tools2026-05-16
Migrating Firebase CocoaPods to SPM in Rork Max Apps
Firebase Apple SDK's CocoaPods distribution ends in October 2026. Here's a detailed migration log from moving 4 Rork Max iOS apps to Swift Package Manager — including dSYM failures, module errors, and the Dropbox conflict copy problem you'll definitely hit.
⬡ Dev Tools2026-05-01
Adding a Notification Service Extension to a Rork App — Rich Push, Encrypted Payloads, and Dynamic Rewriting
Wiring a Notification Service Extension into a Rork-built iOS app — image attachments, end-to-end encrypted payloads, and dynamic APNs payload rewriting — with working Swift code and the production pitfalls that bit me along the way.