Skip to content
AF
Cookbook

Recipe: Interval-Wise Dedupe for Multi-Source Health Samples

Last verified October 2, 2026 · 17 min read

A copy-and-run JavaScript implementation of interval-wise deduplication for health samples that arrive from several sources at once. It removes redelivered copies by source and id, sets aside instantaneous readings and manual entries, cuts the day at every sample boundary, lets the highest-priority source covering each sub-interval win with a deterministic tie-break, and credits the winner pro rata, so per-source totals are never summed. Its test suite uses the worked example from our deduplication architecture page as fixtures: a watch and a phone whose four samples a naive sum puts at 7,500 steps, a whole-day device winner at 2,500, and this resolution at 6,000. On iOS, ask HealthKit's statistics queries for quantity types instead; this is for workouts, cross-provider totals and whatever no platform merges.

Covered here:HealthKitHealth Connect

Four things write to the health store about one Saturday walk, none of them are lying, and a pipeline that sums what it reads doubles the day. Deduplicating health data from multiple sources argues where that arbitration belongs and why it has to be interval-wise rather than device-wise. This page is the sweep the argument ends in, as a dependency-free module whose test suite uses that page's worked example number for number.

First, check whether the platform already merges#

Apple documents that HealthKit's statistics queries "automatically merge the data from all of your data sources before performing the calculations", and that you can set the separateBySource option "if you want to merge the data yourself". Apple's HKStatisticsQuery reference limits those queries to quantity samples: for workouts or correlation samples, "you must perform the appropriate query and process the data yourself." So on iOS, for a quantity type such as steps, the right dedupe is an HKStatisticsCollectionQuery over the affected days, and running this file instead is a regression.

Google documents that "only the Activity and Sleep data types are deduped by Health Connect", inside the Aggregate API, using a priority list where "only end users can alter these priority lists". For every other type, "the aggregated results combine all data of the type in Health Connect from all apps which wrote the data."

What is left is yours: workouts on both platforms, totals that cross providers on your server, every Health Connect type outside Activity and Sleep, and Activity and Sleep too whenever you need a number you can reproduce. That is what this recipe is for.

What the recipe does#

  • Removes redelivery first. The same sample arriving twice — a replayed anchored query, a retried webhook, a backfill overlapping live ingest — is keyed on source plus id, and the highest version wins. Redelivery is a different problem from overlap and it is solved before the sweep sees anything.
  • Sets aside what cannot be swept. An instantaneous sample (start equals end) has no duration to pro-rate, so it is returned with a reason instead of being divided by zero. A manual entry is excluded from the ranking and returned too: it is the user's data, it just does not compete with a sensor for the same interval.
  • Ranks sources per user and per metric. The priority table is built for one metric at a time, carries aliases so one physical device that appears under two source keys competes as one source, and reports any source the table does not list instead of letting an incomplete table decide silently.
  • Cuts the day at every boundary. Every start and end becomes a boundary; between consecutive boundaries the set of covering samples is constant, and the highest-priority one wins. Intervals are half-open, so a sample ending at 09:30 and one starting at 09:30 touch without overlapping. Ties go to the shorter sample, then to a stable key, so the same input always produces the same output.
  • Attributes pro rata and sums the segments. The winner's value is credited in proportion to the share of its own duration the segment covers. Per-source totals are never added together. A day that no sample covered resolves to null, not to a zero nobody observed — see missing data and gaps for why that difference matters downstream.
  • Checks the one invariant worth paging on. The resolved total can never exceed the naive sum of the samples that entered the sweep. It can legitimately fall below the largest single source, and the recipe does not pretend otherwise.

The implementation#

The canonical file is cookbook/dedupe-health-samples.mjs in this site's repository, and cookbook/dedupe-health-samples.test.mjs runs against it on every CI run. The listing below is a verbatim copy of that file. It has no runtime dependencies, needs Node 20 or newer, and does no I/O at all: samples and a priority table go in, a resolved total and the segments that justify it come out.

/**
 * dedupe-health-samples.mjs
 *
 * WHAT THIS IMPLEMENTS
 *   Interval-wise resolution of overlapping health samples written by several
 *   sources, for one (user, metric, civil date) at a time:
 *     1. Redelivery is removed first, on a stable identity (source + id),
 *        keeping the highest version. The same sample twice is one sample.
 *     2. Instantaneous samples (start === end) are set aside. They have no
 *        duration to pro-rate, and two scales reporting three seconds apart
 *        is a coincidence-window problem, not a coverage problem.
 *     3. Manual entries are excluded from the ranking and returned, never
 *        dropped. They are the user's data; they just do not compete with a
 *        sensor for the same interval.
 *     4. The day is cut at every sample boundary. Over each atomic
 *        sub-interval the highest-priority covering source wins; ties go to
 *        the shorter sample, then to a stable key.
 *     5. The winner's value is attributed pro rata to the sub-interval, and
 *        the sub-intervals are summed. Per-source totals are never summed.
 *   Priority is per (user, metric), carries aliases so one device that
 *   appears under two source keys stays one source, and is an input: the same
 *   samples and the same table always produce the same number.
 *
 *   The two anti-patterns (sum everything; let one device win the whole day)
 *   are exported too, so the tests can show what each gets wrong on the same
 *   samples.
 *
 * WHEN NOT TO USE IT
 *   On iOS, for quantity types, ask HealthKit instead. Apple documents that
 *   statistics queries (HKStatisticsQuery, HKStatisticsCollectionQuery)
 *   "automatically merge the data from all of your data sources before
 *   performing the calculations". This file is for what no platform merges:
 *   workouts, cross-provider totals on your server, and Health Connect types
 *   outside Activity and Sleep.
 *
 * WHICH aifitnessapi.com PAGES DOCUMENT THE PATTERN
 *   https://aifitnessapi.com/architecture/deduplicate-health-data
 *   https://aifitnessapi.com/cookbook/dedupe-health-samples
 *
 * Node 20+. Zero runtime dependencies. Pure functions: no I/O, no clock.
 *
 * MIT — from aifitnessapi.com/cookbook
 */

/** Why a sample did not enter the overlap sweep. Each is returned, not lost. */
export const EXCLUSION = Object.freeze({
  INSTANTANEOUS: "instantaneous",
  MANUAL_ENTRY: "manual_entry",
});

// ---------------------------------------------------------------------------
// Source priority — per (user, metric), with aliases
// ---------------------------------------------------------------------------

/**
 * Build the priority table for ONE (user, metric). Never global, never per
 * user alone: a chest strap is the best heart-rate source a user owns and a
 * nonexistent step source.
 *
 * `ranked` lists source keys highest priority first. `aliases` maps an extra
 * key onto a ranked one, so a physical device that changed its source key
 * mid-history competes as one source instead of against itself. A source
 * missing from the table ranks after every listed source and is reported, so
 * an incomplete table is visible rather than silently decisive.
 *
 * @param {string[]} ranked
 * @param {{aliases?: Record<string, string>}} [opts]
 */
export function createSourcePriority(ranked, { aliases = {} } = {}) {
  if (!Array.isArray(ranked) || ranked.length === 0) {
    throw new TypeError("ranked must be a non-empty array of source keys");
  }
  const rank = new Map();
  ranked.forEach((key, i) => {
    if (rank.has(key)) throw new TypeError(`source ${key} is ranked twice`);
    rank.set(key, i);
  });
  const alias = new Map();
  for (const [from, to] of Object.entries(aliases)) {
    if (rank.has(from)) throw new TypeError(`alias ${from} is also ranked; pick one`);
    if (!rank.has(to)) throw new TypeError(`alias ${from} points at unranked source ${to}`);
    alias.set(from, to);
  }
  return {
    ranked: ranked.slice(),
    /** The key a source competes under after aliasing. */
    canonical(source) {
      return alias.get(source) ?? source;
    },
    /** 0 is the highest priority. Unlisted sources get ranked.length. */
    rankOf(source) {
      return rank.get(alias.get(source) ?? source) ?? ranked.length;
    },
    isRanked(source) {
      return rank.has(alias.get(source) ?? source);
    },
  };
}

// ---------------------------------------------------------------------------
// Validation and redelivery
// ---------------------------------------------------------------------------

/** Stable identity of one sample from one writer. */
export function sampleKey(sample) {
  return `${sample.source}:${sample.id}`;
}

function assertSample(s) {
  if (!s || typeof s.source !== "string" || s.source === "") {
    throw new TypeError("every sample needs a source key");
  }
  if (s.id === undefined || s.id === null || s.id === "") {
    throw new TypeError(`sample from ${s.source} has no id; redelivery cannot be detected without one`);
  }
  if (!Number.isFinite(s.start) || !Number.isFinite(s.end)) {
    throw new TypeError(`sample ${sampleKey(s)} needs finite start and end (epoch ms)`);
  }
  if (s.end < s.start) throw new RangeError(`sample ${sampleKey(s)} ends before it starts`);
  if (!Number.isFinite(s.value) || s.value < 0) {
    throw new RangeError(`sample ${sampleKey(s)} needs a finite, non-negative value`);
  }
}

/**
 * Remove redelivered copies: the same (source, id) seen more than once — an
 * anchored query replayed after a reinstall, a retried webhook, a backfill
 * overlapping live ingest. The highest `version` wins; on a tie the first one
 * seen stays, so the result does not depend on how often it was redelivered.
 *
 * @returns {{unique: object[], redelivered: string[]}}
 */
export function dedupeRedeliveries(samples) {
  const byKey = new Map();
  const redelivered = [];
  for (const s of samples) {
    assertSample(s);
    const key = sampleKey(s);
    const held = byKey.get(key);
    if (!held) {
      byKey.set(key, s);
      continue;
    }
    redelivered.push(key);
    if ((s.version ?? 0) > (held.version ?? 0)) byKey.set(key, s);
  }
  return { unique: [...byKey.values()], redelivered };
}

// ---------------------------------------------------------------------------
// The sweep
// ---------------------------------------------------------------------------

/**
 * Deterministic winner order: priority, then shorter sample (finer granularity
 * smears less), then source key, then id. A non-deterministic tie-break means
 * a re-run on unchanged data returns a different number, which turns a
 * recompute into a support ticket.
 */
function compareCandidates(a, b, priority) {
  const byRank = priority.rankOf(a.source) - priority.rankOf(b.source);
  if (byRank !== 0) return byRank;
  const byDuration = a.end - a.start - (b.end - b.start);
  if (byDuration !== 0) return byDuration;
  if (a.source !== b.source) return a.source < b.source ? -1 : 1;
  const ai = String(a.id);
  const bi = String(b.id);
  return ai === bi ? 0 : ai < bi ? -1 : 1;
}

/**
 * Resolve one (user, metric, civil date). The caller scopes the samples: the
 * civil date is a per-user question, not a UTC range, and it is decided
 * upstream of this function.
 *
 * Intervals are half-open, [start, end). A sample that ends at 09:30 and one
 * that starts at 09:30 touch; they do not overlap, and no zero-width segment
 * is ever produced.
 *
 * @param {Array<{id: string, source: string, start: number, end: number,
 *   value: number, manual?: boolean, version?: number}>} samples
 * @param {ReturnType<typeof createSourcePriority>} priority
 * @returns {{total: number|null, naiveSum: number, segments: object[],
 *   boundaries: number[], excluded: object[], redelivered: string[],
 *   unrankedSources: string[]}} `total` is null when no sample entered the sweep.
 */
export function resolveOverlaps(samples, priority) {
  if (!priority || typeof priority.rankOf !== "function") {
    throw new TypeError("priority must come from createSourcePriority()");
  }
  const { unique, redelivered } = dedupeRedeliveries(samples);

  const excluded = [];
  const candidates = [];
  for (const s of unique) {
    if (s.end === s.start) excluded.push({ key: sampleKey(s), reason: EXCLUSION.INSTANTANEOUS, sample: s });
    else if (s.manual === true) excluded.push({ key: sampleKey(s), reason: EXCLUSION.MANUAL_ENTRY, sample: s });
    else candidates.push(s);
  }

  const unrankedSources = [
    ...new Set(candidates.filter((s) => !priority.isRanked(s.source)).map((s) => s.source)),
  ].sort();

  // Every start and end, sorted and unique. Consecutive pairs are the atomic
  // sub-intervals over which the set of covering samples does not change.
  const boundaries = [...new Set(candidates.flatMap((s) => [s.start, s.end]))].sort((a, b) => a - b);

  const byStart = candidates.slice().sort((a, b) => a.start - b.start);
  const segments = [];
  let active = [];
  let next = 0;

  for (let i = 0; i < boundaries.length - 1; i++) {
    const segStart = boundaries[i];
    const segEnd = boundaries[i + 1];
    while (next < byStart.length && byStart[next].start <= segStart) active.push(byStart[next++]);
    // Half-open: a sample ending exactly at segStart does not cover what follows.
    active = active.filter((s) => s.end > segStart);
    if (active.length === 0) continue; // nobody recorded this stretch; it contributes nothing

    let winner = active[0];
    for (const s of active) if (compareCandidates(s, winner, priority) < 0) winner = s;

    segments.push({
      start: segStart,
      end: segEnd,
      winner: sampleKey(winner),
      source: priority.canonical(winner.source),
      coveredBy: active.map(sampleKey).sort(),
      // Pro rata assumes the rate is uniform inside the sample. Harmless for a
      // 10-minute sample, wrong for a 90-minute one covering a sprint and a rest.
      contribution: (winner.value * (segEnd - segStart)) / (winner.end - winner.start),
    });
  }

  // No candidate at all means nobody recorded anything: that is missing data,
  // not a measured zero, and the caller decides how absence is stored.
  const total = candidates.length === 0 ? null : segments.reduce((n, seg) => n + seg.contribution, 0);
  const naiveSum = candidates.reduce((n, s) => n + s.value, 0);
  return { total, naiveSum, segments, boundaries, excluded, redelivered, unrankedSources };
}

// ---------------------------------------------------------------------------
// The invariant worth monitoring, and the two anti-patterns
// ---------------------------------------------------------------------------

/**
 * The resolved total must never exceed the naive sum of the samples that
 * entered the sweep. If it does, some interval is being counted twice.
 *
 * Deliberately NOT checked: that the resolved total is at least the largest
 * single source's total. That is false on correct data — a priority-1 watch
 * with 500 steps beats a priority-2 phone with 3,000 for the same hour.
 */
export function checkResolution(result) {
  // Pro rata division is floating point; allow for rounding, not for a bug.
  const tolerance = 1e-9 * Math.max(1, result.naiveSum);
  const resolved = result.total ?? 0;
  return {
    ok: resolved <= result.naiveSum + tolerance,
    resolved: result.total,
    naiveSum: result.naiveSum,
    removed: result.naiveSum - resolved,
  };
}

/** Sum every sample. Counts every overlapped minute once per writer. */
export function naiveSumAntiPattern(samples) {
  return samples.reduce((n, s) => n + s.value, 0);
}

/**
 * Let the highest-priority source present that day win the whole day.
 * Deletes every stretch the winner did not record — the watch on its charger,
 * the shower, the phone that kept counting.
 */
export function wholeDayWinnerAntiPattern(samples, priority) {
  if (samples.length === 0) return 0;
  const best = Math.min(...samples.map((s) => priority.rankOf(s.source)));
  return samples.filter((s) => priority.rankOf(s.source) === best).reduce((n, s) => n + s.value, 0);
}

The worked example, as the tests run it#

Priority for this user and metric is Watch first, Phone second. The fixtures are the four samples from the architecture page's table, for 2026-07-26:

SampleSourceIntervalValue
W1Watch09:00–09:301,400
W2Watch09:30–10:001,100
P1Phone09:15–10:152,000
P2Phone14:00–15:003,000

The suite asserts the boundaries 09:00, 09:15, 09:30, 10:00, 10:15, 14:00 and 15:00; the five segment contributions 700, 700, 1,100, 500 and 3,000; and a resolved total of 6,000, against 7,500 for the naive sum and 2,500 for letting the watch win the whole day. The 1,500 removed is exactly the phone's pro rata share of the 45 minutes the watch had already counted. The fixtures are copied from the page by hand, so a change to either one has to be made to both.

How to adapt it#

Scope before you call. The function resolves one (user, metric, civil date) and trusts you to have chosen the samples. The civil date is a stored column computed where the offset is known, not a UTC range computed at read time; the reasoning is in timezones and day boundaries, and the day-boundary rollup recipe is the code.

Use machine keys for sources. On HealthKit that is HKSource.bundleIdentifier, not the display name; on Health Connect it is metadata.dataOrigin.packageName. When one physical device shows up under two keys across a platform change, map the newer key onto the older one with an alias, or the device competes against itself and a user's history splits into two sources mid-year.

Store the priority table, and stamp its version on every resolved row. It is an input to the number. The suite resolves the same four samples to 6,000 with the watch first and 5,700 with the phone first; without the table version on the row, you cannot explain that change later or recompute only the days it affected.

Map the manual flag from the writer's metadata, and treat a missing flag as unknown. Health Connect's metadata.recordingMethod has a RECORDING_METHOD_MANUAL_ENTRY value, and HealthKit has the HKMetadataKeyWasUserEntered key. Both are written by the app that saved the sample — Google's reference calls recordingMethod "client supplied", and Apple tells writers to set the key — so the absence of the flag proves nothing about a sensor.

Distrust long samples first. Pro rata attribution assumes the rate is uniform inside a sample. For a 10-minute sample that is nearly harmless; for a 90-minute one covering a sprint and a rest it misplaces value between segments. Every segment records its winner and contribution, so when a total looks wrong, the source that writes long samples is where to look.

Give instantaneous readings their own rule. Two scales reporting the same weight three seconds apart is a coincidence-window question, and there is no documented right window. Pick one, write down why, and keep it out of this sweep.

Monitor the invariant in production. checkResolution costs nothing to run on every recompute, catches real coverage bugs, and needs no physiological knowledge.

The contract the tests hold it to#

Each bullet is one test in cookbook/dedupe-health-samples.test.mjs.

  • On the worked example, the naive sum is 7,500, the whole-day winner is 2,500, and the resolved total is 6,000.
  • The boundaries and the five segment contributions match the architecture page's table exactly; the uncovered stretch from 10:15 to 14:00 is not a segment; and each segment lists every sample that covered it, so the overlap is visible and not just its outcome.
  • The 1,500 removed equals the phone's pro rata share of the 45 overlapped minutes, the invariant holds, and a total above the naive sum fails the check.
  • Touching samples do not overlap: no segment has zero width, and the sample that ends at 09:30 does not cover the segment that starts there.
  • Reversing or shuffling the input produces an identical result.
  • Equal-priority ties go to the shorter sample, then to a stable key, whatever order the samples arrive in.
  • Reordering the priority to phone-first changes the day from 6,000 to 5,700 with no change in the data.
  • A priority-1 watch with 500 steps beats a priority-2 phone with 3,000 for the same hour, the day resolves to 500, and that is not an invariant violation.
  • An unlisted source key ranks last and is reported; aliased onto a ranked source, the same samples compete under that source's rank.
  • An empty ranking, a source ranked twice, an alias pointing at an unranked source, and an alias that is also ranked are all rejected.
  • A redelivered sample is counted once, a higher version replaces it, and a late copy of the old version does not undo the edit.
  • Instantaneous samples are excluded with a reason and never divided by zero.
  • A manual entry at priority 1 does not beat a measured sample, and it comes back in the excluded list with its value intact.
  • A sample with no id, one that ends before it starts, a negative value, or a priority table not built by createSourcePriority throws rather than producing a number.
  • A day with no samples, or with only a manual entry, resolves to null rather than to zero.

What it deliberately does not do#

It does not try to reproduce HealthKit's own merge or Health Connect's aggregate. If your number has to agree with what the user sees in the Health app or in system settings, the platform query is the only route, and the deduplication page covers what that costs in reproducibility. It does not normalize units or semantics — two vendors' "active calories" must already mean the same thing before arbitrating between them is meaningful, which is what normalizing wearable data is for. And it does not decide which civil day a sample belongs to. That decision is made once, upstream, and this function trusts it.

Frequently asked questions

When should I skip a custom dedupe pass and let the platform merge instead?
Whenever the platform will give you the merged figure and you can live with its rules. On iOS, for quantity types such as steps or active energy, Apple documents that HealthKit's statistics queries automatically merge the data from all of your data sources before performing their calculations, so a statistics collection query over the affected days is the correct dedupe and a hand-rolled sweep is a regression. On Android, Google documents that only Activity and Sleep are deduped, inside the Aggregate API, using a priority list that only the end user can change. A custom pass earns its place for workouts, which Apple's statistics queries do not cover, for totals that cross providers on your server, for Health Connect types outside Activity and Sleep, and wherever you need a number you can reproduce.
How should a dedupe pass treat manually entered health samples?
Take them out of the ranking but keep them. A typed 40-minute run and a watch-recorded one are different kinds of fact, so a manual entry should never beat a sensor for the same interval, yet it is still the user's data and belongs in export and in the interface. The recipe returns manual entries in its excluded list with a reason rather than dropping them. Where the flag comes from matters: Health Connect's recordingMethod field and HealthKit's HKMetadataKeyWasUserEntered key are both supplied by the app that wrote the sample, so a missing flag means unknown rather than sensor-recorded. Typed sets and logged meals then need a merge strategy of their own, not a priority order.
Why store the source-priority table alongside each resolved daily total?
Because the priority table is an input to the number, not configuration around it. The recipe's test suite resolves the same four samples twice: with the watch ranked first the day comes to 6,000 steps, and with the phone ranked first it comes to 5,700, with no change to the underlying data. When a user reorders their sources, or you change a default ordering, every past day computed under the old table becomes a different answer to the same question. Stamping the table version on each resolved row is what lets you explain months later why a total moved, and lets a recompute target exactly the days that used the old ordering.
What does pro rata attribution assume when a health sample is only partly overlapped?
That the rate was uniform inside the sample. When the sweep awards a winning sample only part of its interval, it credits the value multiplied by the covered share of that sample's duration, so 15 minutes of a 30-minute, 1,400-step sample contributes 700. For a 10-minute sample the assumption is nearly harmless. For a 90-minute sample spanning a sprint and a rest it misplaces value between sub-intervals, which only changes the total where another source wins part of that span. The recipe keeps the assumption visible: every segment records its winner and its contribution, so a source that writes long samples is the first place to look when a resolved total seems wrong.

All 32 questions in Cookbook →

Keep reading

Elsewhere on the site

Pages that share this one’s concepts and sources, from other sections.

Next steps

Was this page useful?

Independent comparison, last reviewed October 2, 2026. Pricing, rate limits, and feature availability change often — confirm current details in each provider’s official documentation before you commit. Product and company names are trademarks of their respective owners; AIFitnessAPI is not affiliated with, endorsed by, or sponsored by any product listed here.

← All cookbook · by AIFitnessAPI