●GROWTH — Rork keeps growing with 743K monthly visits and an 85% growth rate●MAX — Rork Max generates native Swift apps for iPhone, iPad, Watch, TV, Vision Pro, and iMessage●MAX — It reaches AR/LiDAR scanning, Metal 3D games, Live Activities, HealthKit, and Core ML, beyond React Native's reach●STACK — Standard Rork builds iOS and Android together in React Native (Expo), so non-engineers can ship real apps●PRICE — Plans start free, paid tiers from $25/month, and Rork Max at $200/month●MARKET — Gartner projects 75% of new apps will be low-code or no-code by the end of 2026●GROWTH — Rork keeps growing with 743K monthly visits and an 85% growth rate●MAX — Rork Max generates native Swift apps for iPhone, iPad, Watch, TV, Vision Pro, and iMessage●MAX — It reaches AR/LiDAR scanning, Metal 3D games, Live Activities, HealthKit, and Core ML, beyond React Native's reach●STACK — Standard Rork builds iOS and Android together in React Native (Expo), so non-engineers can ship real apps●PRICE — Plans start free, paid tiers from $25/month, and Rork Max at $200/month●MARKET — Gartner projects 75% of new apps will be low-code or no-code by the end of 2026
Your Rork Max Health App Misses Overnight Steps — Designing Background Delivery When HKObserverQuery Dies Silently
In a native Swift health app generated by Rork Max, data recorded while the app is closed never arrives — and it's almost always because HKObserverQuery's background delivery stopped without a word. Here's how to isolate the layer that broke and an observation layer you can drop in as-is.
I built a tiny step-tracking app with Rork Max, and in the simulator it was flawless. Push health samples in by hand and they appear on screen instantly. But after running it on a real device for a few days, I noticed something odd: the moment I opened the app in the morning, the whole run of steps from the previous evening was missing, and only after a while did the numbers reconcile. No error, ever. Updates simply weren't arriving while the app was closed.
The cause was that HKObserverQuery's background delivery was registered in name only — it never actually became active. As an indie developer working with HealthKit, this "works in the foreground, dies quietly when closed" pattern is the first thing that trips you up. This walks through adding an observation layer to a Rork Max native app — one that keeps collecting health data while the app is asleep — with the smallest diff possible: how to isolate the silent failure, and an implementation you can use as-is.
HealthKit background updates are a three-stage stack
Most people assume one HKObserverQuery is enough, but to receive updates while the app is closed you have to satisfy three independent mechanisms at once. If you don't separate them mentally, one stays missing and you're stuck on "why won't it arrive."
Stage one is authorization: read access must be granted for the type you want (for steps, HKQuantityType(.stepCount)). Stage two is background-delivery registration — calling enableBackgroundDelivery(for:frequency:) per type to tell the system "wake me when this type changes." Stage three is the observer query itself, which receives the change signal and goes to fetch the actual data.
The nasty part: even when only read access is granted, the background-delivery registration API does not return an error. Registration looks successful, yet no notification ever comes. That asymmetry is where the silent failure breeds.
First, isolate which stage broke — from logs
Chasing this by guesswork burns hours. I always start by instrumenting each of the three stages and confirming on-device from logs how far execution reaches.
You can pass .immediate for frequency, but demanding immediate delivery for a type that changes as often as steps just gets throttled by the system and thinned out anyway. For cumulative types like steps and distance I settled on .hourly. I consider it more robust in practice to reserve .immediate only for types that genuinely need responsiveness, like heart rate tied to a workout.
✦
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 isolate from logs whether the failure is in authorization, background-delivery registration, or the observer's lifetime — the three independent layers that must all hold
✦You get a thin wrapper that pairs HKObserverQuery with HKAnchoredObjectQuery and always calls the completion handler, ready to drop into Rork Max's generated code
✦You'll know exactly which Info.plist keys, capabilities, and on-device background constraints to fill in by hand — the parts Rork Max cannot generate
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.
Stop the observer from dying after one shot with the completion handler
Stage three, the observer query, is where the silent failure most often happens. The HKObserverQuery handler receives a completionHandler (HKObserverQueryCompletionHandler). If you don't call it, the system decides your processing hasn't finished and stops waking your app in the background from then on. In the foreground it works even if you forget, so you can't catch it until release.
extension HealthObservation { func startObserver() { let query = HKObserverQuery( sampleType: stepType, predicate: nil ) { [weak self] _, completionHandler, error in guard let self else { completionHandler(); return } if let error { healthLog.error("observer error: \(error.localizedDescription)") completionHandler() // always call, even on error return } // delegate the actual fetch to an anchored query Task { await self.fetchIncremental() healthLog.info("observer fired, incremental fetched") completionHandler() // call after the fetch completes } } store.execute(query) }}
Two things matter here. First, call completionHandler() exactly once on every path — success, failure, and early return. Second, the observer itself carries no data. It only hands you the signal that "something changed." The actual delta fetch is done separately with an anchored query.
Pair it with HKAnchoredObjectQuery to fetch only the delta
If you re-scan the entire history every time the observer fires, you'll exhaust your background execution time fast. HKAnchoredObjectQuery returns an anchor marking the last position, so if you persist it, next time you fetch only what was added. Neglect to persist the anchor and every run becomes a full fetch, which gets throttled — and you're back to the "occasionally missing" symptom.
extension HealthObservation { private var anchorKey: String { "health.anchor.stepCount" } private func loadAnchor() -> HKQueryAnchor? { guard let data = UserDefaults.standard.data(forKey: anchorKey) else { return nil } return try? NSKeyedUnarchiver.unarchivedObject( ofClass: HKQueryAnchor.self, from: data) } private func saveAnchor(_ anchor: HKQueryAnchor) { let data = try? NSKeyedArchiver.archivedData( withRootObject: anchor, requiringSecureCoding: true) UserDefaults.standard.set(data, forKey: anchorKey) } func fetchIncremental() async { await withCheckedContinuation { continuation in let query = HKAnchoredObjectQuery( type: stepType, predicate: nil, anchor: loadAnchor(), limit: HKObjectQueryNoLimit ) { [weak self] _, samples, _, newAnchor, error in if let error { healthLog.error("anchored error: \(error.localizedDescription)") } if let newAnchor { self?.saveAnchor(newAnchor) } let count = samples?.count ?? 0 healthLog.info("incremental samples: \(count)") // reflect samples into your own store here continuation.resume() } store.execute(query) } }}
UserDefaults is fine for the anchor, but sharing it through an App Group lets your widget render off the same delta baseline. Running several small apps, I've found that pointing the anchor's storage at shared storage from the start makes later expansion much easier.
Three gaps Rork Max's generated code can't fill
Rork Max generates the Swift query logic well enough from natural language. But making background delivery hold on a real device requires configuration outside the code, and the AI's output alone won't fill it. Three things I verify by hand every time.
First, the Info.plist purpose strings. Put NSHealthShareUsageDescription (read) and, if you write, NSHealthUpdateUsageDescription in with concrete wording that won't be rejected in review. If these are empty, the permission dialog never appears on a real device.
Second, capabilities. Enable the HealthKit capability, and if you use background delivery confirm it's active on the HealthKit side (paired with the enableBackgroundDelivery call), not via Background Modes.
Third, extra setup when workout integration is needed. To receive high-frequency updates during a workout, enable Workout processing under Background Modes. Steps alone don't need it, but you will the moment you extend to real-time heart rate.
How to reproduce "missing only while closed"
This class of bug never reproduces in the foreground. My routine: send the app fully to the background (return to the home screen), separately add real data via the iPhone Health app or a workout, then open the app tens of minutes later and check whether observer fired appeared in the logs while it was closed.
If background delivery enabled shows up but observer fired never appeared while closed, the cause is usually the observer's registration timing. An observer query has to be executed again on every app launch; if you don't re-arm it early — around the application(_:didFinishLaunchingWithOptions:) equivalent — the query won't exist when the system launches you in the background, and you drop the update. Rork Max's generated code tends to start the observer after the screen appears, so hoisting it to right after launch is the move that actually holds on a real device.
For your next step, wire the HealthObservation above so bootstrap() runs right after launch, leave it on a real device overnight, and check the logs. Building a state where the logs show at a glance which of the three stages broke is the fastest path to putting this silent failure behind you.
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.