Open the Health app on your iPhone, go to Browse → Activity → Workouts, and scroll to a day you know you trained once. If you have ever used a phone app to record a session that your watch was also recording, there is a fair chance you are looking at it twice.
This is not a bug in Health. It is what Health is: a store that any app may write to, which faithfully keeps everything anyone wrote. Two apps recording one ride produce two honest records of one ride. Health has no opinion about which is real, and no reason to have one.
The trouble starts when something downstream adds them up.
Why a duplicate is worse than it looks#
A duplicated workout is not a cosmetic problem in a training app. It is a load problem, and load compounds.
Training stress for a day is the sum of that day’s sessions. Fitness and fatigue are exponentially weighted averages of daily load over forty-two and seven days respectively, and form is the difference between them. Readiness reads form as one of its inputs.
So one duplicated Saturday ride does this:
- Saturday’s training stress doubles.
- Fatigue jumps and stays elevated for about a week.
- Fitness rises and stays wrong for about six weeks.
- Form — fitness minus fatigue — is wrong for as long as either is.
- Readiness, which reads form, is wrong every morning in between.
- And the plan, which reads all of it, prescribes recovery you do not need.
The failure mode is the nasty kind: nothing errors, nothing looks broken, every number is internally consistent, and all of them are wrong. The athlete’s only symptom is that the app keeps insisting they are more tired than they feel — which is exactly the situation where a training app most wants to be believed, and least deserves to be.
That is why deduplication in this app is not an ingestion nicety. It is a correctness requirement, and it lives in the engine layer with the rest of the maths rather than being sprinkled through the sync code.
What makes two rows the same session#
There is no shared identifier to lean on. Each app writes its own record with its own identity, and the two know nothing about each other. So the decision has to be made from the shape of the sessions themselves.
Shindo’s rule has three parts, and all three must hold.
The sports must be compatible. Equal sports match. “Other” matches anything, because it is the bucket both of our ingestion mappings collapse an unrecognized activity type into — so a session one source names precisely and another does not must still be matchable, or the fallback category becomes a duplicate factory.
They must genuinely overlap in wall-clock time. The overlap must be at least half the shorter session’s duration. Two devices recording one run agree on the start within seconds to minutes, so half is a generous floor. Two genuinely different sessions on the same day — an easy spin at eight in the morning, intervals at six in the evening — share nothing remotely like that much.
Their durations must be comparable, within whichever is larger of five minutes or thirty-five percent of the shorter session.
That pair of tolerances looks sloppy and is not. A flat five-minute window would hold a four-hour ride to an unreachably tight standard, because two sources routinely disagree about a long session by more than that — one reports moving time, the other elapsed. A pure percentage would let a twenty-minute session match a thirteen-minute one. Taking the larger of the two gives a window that is generous where sources actually disagree and tight where they do not.
What the rule deliberately ignores#
Distance. Heart rate. Calories. Elevation. Every rich signal you would reach for first.
Two sources describing one session disagree about all of them by a few percent — GPS smoothing differs, sampling rates differ, calorie models differ wildly. Adding distance to the criterion makes it stricter, and strictness has a direction here:
- Too loose merges two different sessions. You lose one workout’s load. Bad, visible, and bounded.
- Too strict misses a duplicate. You double a day’s load and corrupt six weeks of fitness. Bad, invisible, and compounding.
Those are not symmetric, so the criterion is built to fail in the first direction. Sport, overlap and duration are the three things two recordings of one session agree about; everything else is where they disagree, and putting a signal into a matching rule because it is available rather than because it is reliable is how you end up with a rule that is precise and wrong.
Two questions, two thresholds#
Here is the part I found genuinely interesting to get right, and the reason this ended up as one engine rather than two ad-hoc checks in the sync code.
There are two moments where duplicates are handled, and they are not the same question.
At import time: should this new row be stored at all? A candidate is refused when an existing row we trust at least as much already covers that session. Equal trust is enough to refuse — with two indistinguishable copies, keeping the one already stored is as good as any other answer, and ties break deterministically so the outcome does not depend on the order Health handed us the data.
Afterwards: is a row we already stored now redundant? This is the retroactive sweep, and it deletes only when a strictly better-trusted copy of the same session exists. Two rows of equal trust are left alone, on purpose.
The asymmetry is the whole design. Refusing to import an ambiguous duplicate costs nothing — the session is already represented. Deleting one of two equally-trusted rows is a coin flip on somebody’s real training history, and a coin flip that can only be resolved by data we do not have. So one path is allowed to act on a tie and the other is not.
Trust itself is a fixed ranking over sources, and the ordering encodes two judgements: a richer record outranks a thinner mirror of the same session, because the mirror is a strict subset of it; and a session you entered by hand outranks a mirror, because it is an assertion you made deliberately. Development seed data ranks last, so it can never shadow a real session.
The boring engineering bit that stops it being slow#
The naive version of this compares every incoming workout against every stored one. For an athlete with three years of history that is a full-table scan on every sync, on the main thread, while somebody is looking at the screen.
Instead, rows are bucketed by the calendar day the session starts on, and a lookup touches that day plus one day either side. One day, because the only realistic way two records of one session land in different day buckets is a session that crosses midnight. Each lookup then compares against the zero to three sessions the athlete actually did within a day of that time, rather than against everything they have ever done.
The engine itself is pure — no database handle, no deletion, no side effects. It answers “which of these are redundant?” and hands the answer to the sync layer, which is the only thing allowed to act on it. That split is what makes every tolerance above unit-testable without a database, and it is the same rule the rest of the app’s maths follows.
How to check your own data#
Worth doing once, whatever app you use.
- In the Health app: Browse → Activity → Workouts. Pick a week you remember clearly.
- Look for two entries at the same time of day with similar durations. Tap one — the source is at the bottom of the detail view.
- If you find a pair, check what your weekly load looked like that week in whatever app sums it. If that app has no duplicate handling, its numbers for the following six weeks were inflated.
The most common cause is a phone app and a watch both saving the same session. It also shows up after switching apps, when the new one imports history that the old one had already written.
If you want to clean it up, deleting the redundant entry in Health is the durable fix — though be aware that some apps only write to Health and never read back from it, so their own copy of your history may not change.
Shindo applies this rule at import and again as a retroactive sweep, so history that was already imported gets repaired rather than staying wrong. The load, fitness, fatigue and form definitions it protects are on the methodology page, and the reasoning behind keeping decisions like this in a pure engine rather than in the sync code is the subject of the plan-validator post.
Read next