FHIR API Integration Patterns: 4 Anti-Patterns That Break Production Pipelines

Diagram: FHIR API Integration Patterns: 4 Anti-Patterns That Break Production Pipelines. Diagram illustrating the article's core structure and decision points.

FHIR API integration reads well in demos and breaks quietly in production. The anti-patterns below show up in almost every real integration, and understanding why they fail is the difference between a pipeline that scales and one that runs into a wall at 100k requests/day.

Anti-pattern 1: Fire-and-forget POST without transaction semantics.

A common mistake is POSTing individual resources one at a time from an ETL job. The FHIR REST API supports single-resource operations, but pipeline throughput drops off a cliff (each POST is a round-trip, network + serialization + write). The fix: batch into a Bundle of type transaction or batch. Transaction is atomic (all-or-nothing); batch is independent operations grouped into one HTTP call. Batch handles ~100 resources per bundle cleanly; transaction handles ~50 before servers start rejecting for size or timing.

The difference between the two matters: transaction supports urn:uuid: placeholder references that the server resolves before write, which is how you POST a Patient + Observation + Encounter together with subject: urn:uuid:patient-abc refs. Batch does not resolve placeholders, so cross-resource references must be pre-computed.

Anti-pattern 2: Ignoring ETag on updates.

FHIR uses If-Match/ETag versioning to prevent lost updates. Clients that PUT resources without checking the current versionId will overwrite concurrent updates and cause silent data loss. In practice, the fix is: GET → check Meta.versionId → PUT with If-Match: W/"<versionId>" → handle 412 Precondition Failed by re-reading and retrying. Most SDKs make this optional; most production incidents come from teams that skipped it.

Anti-pattern 3: Client-side _include expansion.

Doing GET Patient/123 then GET Observation?subject=Patient/123 then GET Encounter?subject=Patient/123 sequentially works but wastes round-trips. GET Patient/123?_revinclude=Observation:subject&_revinclude=Encounter:subject returns a Bundle with all three in one call, and every mature FHIR server supports it. The FHIR search parameter specification covers _include, _revinclude, and iterative variants (_include:iterate) for multi-level joins.

The caveat: _include bundles can grow large (hundreds of resources per Patient), so pair with _count and pagination or your client OOMs.

Anti-pattern 4: Polling instead of Subscription.

Applications that poll GET Patient?_lastUpdated=gt<cursor> on a 30-second loop create sustained load without actually being timely. The FHIR-native answer is Subscription (R4B and R5 use the topic-based model). Subscribe to a SubscriptionTopic, receive rest-hook notifications when matching resources change, react in the handler.

The operational catch: Subscription delivery is best-effort in many implementations. Production systems combine Subscription for latency with a _lastUpdated backfill query on a slower schedule (5-15 min) to catch missed notifications. This is the same pattern the SMART on FHIR bulk data workflow recommends for reconciling event-driven updates against a full snapshot.

Operational rules of thumb

1. Batch reads with _include, batch writes with Bundle. 2. Always send If-Match on updates once your write volume >100/day. 3. Use Subscription for latency + _lastUpdated polling for correctness. 4. Retry 429 responses with exponential backoff; most FHIR servers rate-limit at the resource-type level, so distribute across types where possible.

FHIR API integration isn't hard — the resource model is well-designed and the REST semantics are consistent. What breaks pipelines is treating FHIR like a CRUD API on top of Postgres. It isn't; the primitives above are how mature integrations look.