A cycle in a FHIR reference graph is not always a bug. Some clinical scenarios legitimately produce them — a Condition that references a related Condition, an Observation panel with hasMember cycling back through component observations, a CarePlan that references a related CarePlan. Others are true bugs and cause resolvers to hang or crash. The mechanism for telling them apart is the same either way: a visited-set the walker consults before recursing. The site's Bundle reference walker shows detected cycles inline. For the wider FHIR framing, more on payer-side data platforms has more.
Where Cycles Come From
- Two Conditions each referencing the other via
relatedProblem - A CarePlan with
partOfpointing at another CarePlan that points back - Observations with
hasMemberandderivedFromcross-linking - Provenance chains that loop
- Contained resources referencing the parent that contains them
The mechanism differs, the detection technique is the same. Treat every reference walk as a graph traversal that might revisit a node.
The Visited Set
The canonical detection: a walker maintains a set of (resourceType, id, version?) tuples for every resource visited. Before recursing into a reference, check the set. If the target is already visited, do not recurse — record the cycle and continue.
That is a few lines of code, and it turns unbounded recursion into a bounded walk. Every reference resolver worth deploying does this.
Depth Limits As A Backstop
Even with a visited set, a bug in the walker can produce deep recursion. A hard depth limit — twenty is generous — is the backstop. When the walker hits the limit, log, stop, and continue. That prevents the resolver from consuming stack indefinitely if the visited set has a bug.
Version-Specific References And Cycles
Two references to Patient/123/_history/5 are the same node in the graph — the resolver should treat them as one. Two references to Patient/123/_history/5 and Patient/123/_history/6 are different nodes — different versions of the same logical entity, and the walker should distinguish them.
That version-awareness is what a naive visited set often misses. Key the set by (type, id, version) when versions matter. For the mechanic, version-specific references: when you need them, when you don't is the entry.
Contained References And Cycles
Contained resources can reference their parent. Contained.Observation.subject → Patient/parent where the parent contains this Observation is technically valid and produces a cycle at walk time. The visited set has to include the parent id, not just external ids.
For the deeper contained mechanic, contained references vs external references: cost and safety covers the shape.
Reporting Cycles
A cycle should surface as a first-class result, not as an error. The walker returns the cycle path — the sequence of references that closed the loop — so the caller can decide whether it is a bug or a legitimate clinical structure.
Some deployments treat every cycle as a validation failure. Others tolerate cycles as long as they are bounded. The right choice is workload-specific; the walker just has to surface the cycle honestly.
When It Is Actually A Bug
- Unbounded chain length (cycle detected past the depth limit)
- Cycle in a workflow that logically should not have one
- Cycle involving Provenance — usually indicates a broken chain-of-custody
Detection is the mechanism; deciding is the workflow's job. For the design that ties it into a robust resolver, designing a reference resolver that survives partial outages is the entry.
The Short Version
Every FHIR resolver needs a visited set keyed by type, id, and version. Depth limits are the backstop. Report cycles as a first-class result. Some cycles are legitimate; the workflow decides.

Sources
- HL7 canonical FHIR references chapter framing graph - HL7 canonical FHIR references chapter framing graph semantics