My Apple Health import crashed six times in one evening. I had a theory for every crash, and every theory was wrong.

The cause was one word in a config file. It was never memory.

The setup: my phone app imports Apple Health into my own system, so a year of it sits next to everything else I track. React Native, an open-source HealthKit bridge, and a registry of metrics, each with its type and unit.

When the import died, it died completely. No JavaScript error, no red box, nothing in the log. The app was simply gone.

Five theories, all reasonable

Each crash looked like memory or a watchdog, so I fixed memory. Some fixes were real improvements, and one made things worse. None of them was the fix.

  1. 1Too many store-wide scans. The import read month by month: 23 metrics over about 150 months, roughly 3,450 queries. I made it read the whole range once.
  2. 2The same data, three times. Heart rate's minimum, average and maximum were three queries over the same type. I merged them, and chunked the reads so each query held a bounded slice.
  3. 3Chunks too big. Adaptive sizes per type: a month for high-frequency types, a quarter for the rest. It died earlier.
  4. 4One expensive metric. Cheapest types first, so a kill would take as little as possible with it. It died on resting heart rate, recorded about once a day, the cheapest read I had.
  5. 5Something cumulative. The death point kept moving later as reads got cheaper: step 4, then 8, then 20 of 22. That last one died on day one of a per-day sweep, before anything could accumulate.

That last data point should have killed the memory theory on the spot. A process that dies on its first tiny query isn't running out of anything.

What it actually was

My metric registry said unit: bpm for heart rate. That's a label for humans. HealthKit spells beats per minute count/min.

The native side of the bridge passed whatever string it got to HKUnit(from:). An invalid unit raises an Objective-C exception on a HealthKit queue. JavaScript can't catch it, so the process takes a SIGABRT and disappears.

How one word kills an app
  1. 1The registry says bpm
  2. 2The bridge calls HKUnit(from:)
  3. 3An Objective-C exception
  4. 4SIGABRT, no JS error

0

import crashes, one cause

a unit label, never memory

It fit every crash. Each death was simply the first metric using bpm in that run's order, and every fix in between had worked by accident: sample reads pass no unit at all, so moving a metric to that path just moved the crash to the next bpm metric.

The control case had been in the code the whole time. Workout heart rate hardcoded count/min, and it had never crashed once.

Credit where it's due: I had split the hunt four ways and run the investigations in parallel. Three converged on a real leak in the bridge, which wasn't what was killing this import. The one with the lowest prior found the unit string.

// Registry units are labels for humans. Translate them at the boundary.
const TO_HK: Record<string, string> = {
  bpm: "count/min",
  brpm: "count/min",
  "ml/kg/min": "ml/(kg*min)",
  h: "hr",
};

export function hkUnit(label: string, allowed: Set<string>): string | null {
  const unit = TO_HK[label] ?? label;
  // Not a unit HealthKit knows? Refuse the read. Never let it cross the bridge.
  return allowed.has(unit) ? unit : null;
}

Three ways to read HealthKit, and why chunking made it worse

  • Statistics. A statistics collection query. My bridge builds it with no sample predicate, so it computes over the whole store for that type, whatever range you pass. The range only picks which buckets come back.
  • Samples. A sample query with a real date predicate. Right for anything recorded about once a day.
  • Daily. One date-bounded statistics query per day. The only fit for a high-frequency type you want as a daily aggregate, like heart rate.

Read the first one again. If every statistics query scans the whole store, splitting a range into chunks doesn't bound the work. It multiplies it: I had "optimised" 16 queries into 150, and the crash arrived earlier.

read plan                  queries
per metric, per month          208
per metric, whole range         16
chunked by 31 or 92 days       150   <- my "optimisation"
per type, whole range           14

The corollary is the useful part: importing a year costs exactly what importing three months costs. The window is free; only the number of queries isn't. So the default import became a year.

Then the data went quiet

A month later came a quieter bug. Steps and heart rate kept arriving. Resting heart rate, HRV, blood oxygen, wrist temperature, weight, nights and workouts went silent. No crash, no error, just gaps.

The bridge returns sample dates as JavaScript Date objects. My code turned them into days with String(date).slice(0, 10), which gives "Mon Sep 28", not a date. Every sample read was filed under a day that doesn't exist, and dropped.

Statistics reads carry no sample dates, so they never noticed. The split was never by sensor. It was by read mode.

How to debug a crash that leaves no trace

  1. 1Leave a breadcrumb before every readWrite the type and window you're about to read to storage before each native call. A crumb that survives to the next launch means the app was killed at exactly that step. The next crash becomes evidence instead of an argument.
  2. 2Stamp the engine on screenShow the import engine's version in the UI. A JavaScript reload is invisible from the outside, and more than once I couldn't tell whether the phone was even running the fix.
  3. 3Make it resumableEach type is a unit. When it completes, its rows are saved and it's marked done, so a kill costs one read, not the whole import. Tapping again continues.
  4. 4Cap the retriesMark each step as attempted before it runs, because a killed step leaves no return value. After two kills it's set aside and the run moves on to everything behind it.
  5. 5Never call a failed read "no data"A set-aside read is reported as failed, never as silent. "Asked, and nothing came back" becomes a claim about a body further down the line, so it should only come from a read that answered. Store the silences and the failures: they're data.
  6. 6Read the crash log, then the libraryXcode's device logs give the termination reason, and a memory kill and a watchdog kill need opposite fixes. Then read the native source one call below yours. That's where this answer was.

When the app dies without a word, stop reasoning about your code and start leaving breadcrumbs.

Weekly-ishEngineering OS, in your inboxJoin Engineering OS