FHIR Search at Scale: Indexing, Pagination and Performance Engineering

Written by Technical Team Last updated 25.09.2026 19 minute read

Home>Insights>FHIR Search at Scale: Indexing, Pagination and Performance Engineering

A FHIR server holding a few thousand resources can execute surprisingly sophisticated searches without much deliberate optimisation. Once the same server contains tens or hundreds of millions of Observations, Conditions, Encounters, DiagnosticReports and other clinical resources, that approach stops working. At that scale, search performance has to be engineered deliberately.

The key is to treat FHIR search as a query execution system rather than as a JSON filtering problem. Resources may be stored as JSON, XML or another representation, but a production FHIR search service should rarely answer common queries by opening resources and evaluating FHIRPath expressions at request time. Instead, it should extract searchable values as resources are written, place those values into purpose-built indexes, translate incoming FHIR searches into bounded database operations and control how much work any individual request is permitted to perform.

The architecture must account for more than basic parameters such as Patient?identifier=. Real applications introduce date ranges, code systems, chained searches, reverse chaining, sorting, _include, _revinclude, pagination and tenant or patient-level security filters. Each additional feature changes the shape and cost of the underlying database query.

Engineering FHIR search at scale therefore starts with the search index, continues through the query planner and pagination model, and ends with continuous measurement of the queries actually being executed in production.

Key point: FHIR search performance at scale depends far more on purpose-built SearchParameter indexing and query planning than on how quickly a server can parse FHIR JSON. For large FHIR databases containing millions of clinical resources, extracting searchable values at write time, using selective database indexes and bounding expensive queries are essential to keeping patient, Observation and clinical record searches fast and predictable.

Build the Search Layer Around SearchParameter Indexes

The first design decision is to separate the stored FHIR resource from the representation used to search it.

When an Observation is written, for example, the persistence layer can store the complete resource while simultaneously extracting searchable values defined by applicable FHIR SearchParameter resources. An Observation containing a subject reference, LOINC code, effective date and quantity can therefore generate index entries for subject, code, date, value-quantity and other supported parameters.

Searching then operates primarily against those extracted values rather than repeatedly parsing the full Observation.

The index model should preserve the semantics of the FHIR search parameter type. A token search cannot be treated as an arbitrary string comparison. A reference is not equivalent to text. A date requires range-aware comparison. A quantity may contain a value, unit, system and code.

A production implementation will commonly need specialised representations for:

  • Token parameters, storing searchable combinations such as system and code, as well as the indexes required for code-only searches where supported.
  • Reference parameters, storing the target resource type and logical ID in a normalised representation suitable for joins and chaining.
  • Date parameters, storing lower and upper boundaries so searches against dates, date-times and periods can be resolved correctly.
  • String parameters, storing appropriate normalised search values separately from the original human-readable text.
  • Quantity parameters, storing numerical values alongside systems and units, with canonicalised representations where unit conversion is supported.
  • URI parameters, indexed independently from ordinary strings because their matching semantics differ.
  • Composite parameters, preserving the relationship between values that belong to the same repeating structure rather than independently indexing components in a way that can create false matches.

Do the extraction at write time. When a resource is created or updated, determine which SearchParameters apply, evaluate their expressions and update the corresponding index rows or documents within the same logical persistence workflow.

This converts repeated read-time computation into a predictable write-time cost.

Avoid indexing every imaginable custom SearchParameter simply because the platform permits it. Each additional parameter increases write processing, index storage and database maintenance. On large repositories, unnecessary indexes also compete for cache and can increase the work required by the database optimiser.

Maintain an explicit catalogue of supported SearchParameters instead. Start with the searches required by known applications and interoperability contracts, add parameters deliberately, and observe their production use. This produces a smaller and more useful search surface.

Custom SearchParameters also need an operational lifecycle. Adding the SearchParameter definition alone does not make historical resources searchable through it. Existing resources must be reindexed so the new extracted values are generated.

Perform reindexing as a controlled background workload. Divide resources into batches, checkpoint progress, cap concurrency and make the operation resumable. Large reindexing jobs should not compete without limits against interactive clinical searches.

Version the indexing configuration alongside application code. If the FHIRPath expression of a SearchParameter changes, treat that as a schema migration. Record which resources were indexed under each definition and provide a repeatable process for rebuilding affected indexes.

The same approach should be used after changes to normalisation rules. Altering how references, dates, quantities or tokens are represented can create a mixture of old and new index formats unless existing data is migrated deliberately.

For very large repositories, include a lightweight resource identity key in each index record. The search index can then return internal resource identifiers first, after which the persistence layer retrieves the corresponding FHIR resources. This prevents large JSON payloads from being copied through every intermediate database operation.

Design Database Indexes Around Real FHIR Query Shapes

Once searchable values have been extracted, the next task is to optimise for combinations that applications actually request.

A single-column database index on every field is rarely enough. Consider a common clinical query:


GET /Observation?patient=123&code=http://loinc.org|718-7&date=ge2026-01-01&_sort=-date

The engine needs to narrow the search by resource type, patient, coded concept and date, then return the matches in date order. If the underlying schema forces separate large scans of reference, token and date tables followed by expensive intersections, performance can degrade sharply as the Observation population grows.

Design database access around selective predicates first.

Patient or subject is often highly selective and frequently appears in clinical searches, making it a useful early restriction. Token searches containing both system and code are generally more selective than code-only searches. Resource type should normally be available as an inexpensive restriction if multiple resource types share index structures.

The exact database implementation will differ between PostgreSQL, SQL Server, MongoDB, Elasticsearch or a custom search service, but the query-planning principle remains the same: narrow the candidate resource IDs as early as possible and avoid repeatedly joining massive intermediate result sets.

Collect production query shapes and use them to guide composite indexes. If the service repeatedly executes Observation searches by tenant, patient and date, build the persistence model so that those fields can be resolved efficiently together. If MedicationRequest queries commonly combine patient, status and authored date, test an index shaped around that access pattern.

Do not attempt to create a composite relational index for every possible combination of FHIR parameters. The combinatorial space is enormous. Optimise the high-volume paths and let less common searches use more general index structures.

Cardinality matters as much as parameter popularity. An index on a field containing only a handful of possible values may do little to reduce the candidate set. A search such as Observation?status=final can still match millions of rows. Combine low-cardinality filters with more selective predicates before retrieving resources.

Sorting requires similar attention. A FHIR query using _sort=-date is not complete once the matching IDs have been identified; those matches must also be ordered. Sorting a huge intermediate result in memory or temporary database storage can become the dominant cost of the request.

Where high-volume searches use predictable sorting patterns, build indexes capable of producing rows close to the required order. For patient timelines, for example, an implementation may benefit from an access path combining patient identity with the clinical date used for sorting.

Always add a deterministic tie-breaker to the internal sort. Clinical resources frequently share the same timestamp. Ordering only by date can therefore leave resources with equal dates in undefined order. Adding the internal resource ID, logical ID or another immutable unique field produces a total ordering such as:


effectiveDate DESC, resourceId DESC

That ordering becomes particularly important when implementing scalable pagination.

Inspect generated query plans rather than assuming that an index will be used. Examine scanned rows, join strategy, temporary sorting, memory consumption and execution time. Database statistics must be current enough for the optimiser to make sensible choices, particularly after large data loads or reindex operations.

FHIR servers that support multiple organisations or tenants should also bring partition restrictions into the query as early as possible. Do not retrieve a global result set and apply tenancy restrictions later in application code. Include the partition boundary in the indexed search path so irrelevant resources never enter the candidate set.

The same applies to security rules that can safely be represented at query level. Where authorisation limits a request to a known patient, organisation or compartment, push that boundary into the search plan before expensive chaining, sorting and resource retrieval.

Choosing a Search Backend for Large FHIR Repositories

FHIR does not require every type of search to be executed by the same database technology. At larger scales, one architectural decision is whether SearchParameter indexes should remain in the primary FHIR database, be supplemented by a dedicated search engine, or be divided between the two.

The right choice depends on the dominant FHIR search workloads. Structured patient, reference, token and date searches have different requirements from full-text or highly flexible search, while introducing a second search system creates additional consistency and operational considerations.

Architecture Where It Fits Best Main Trade-Off
Primary database indexes Structured FHIR searches dominated by tokens, references, dates, quantities and predictable combinations such as patient plus code plus date. Keeping search and resource persistence together can also simplify transactional consistency. Complex text search and very diverse query patterns can place increasing pressure on the transactional database and may require substantial database-specific index tuning.
Dedicated search engine Full-text search, large inverted indexes and workloads that benefit from search-specific capabilities. Technologies such as Lucene and Elasticsearch are used by FHIR implementations for specialised search indexing. Creates another distributed data store to operate. Index updates, failures and replication lag must be handled so that search results do not remain unexpectedly behind the authoritative FHIR repository.
Hybrid search architecture Large repositories with both conventional clinical SearchParameter queries and specialised text or complex search requirements. The primary database can remain authoritative while selected queries are routed to specialised indexes. Usually provides the greatest flexibility but also the greatest query-planning and operational complexity. The server must define which backend handles each search and how results remain consistent across them.

Engineer Pagination for Stable, Large Result Sets

Pagination is where implementations that work well on small datasets often develop severe performance problems.

FHIR clients can request a page size using _count, while the server returns paging links in the searchset Bundle. Clients should follow the supplied next link rather than attempting to construct subsequent page URLs themselves. That gives the server freedom to encode its own paging state.

A simple implementation may translate page navigation into SQL OFFSET and LIMIT. For example, page 1 retrieves rows 1–50, page 2 retrieves 51–100 and page 20,000 skips the first 999,950 rows before returning 50.

This becomes increasingly expensive because a deep offset can still require the database to identify, order and step over a huge number of preceding matches.

For large datasets, prefer cursor or keyset pagination.

Suppose the search is ordered by clinical date descending and then resource ID descending. The first page might end with: 2026-06-15T14:32:00Z, 891244

Encode those values into an opaque paging token. The next page can then effectively request records whose ordering key comes after that boundary rather than requesting “the millionth row”.

Internally, the next-page condition might resemble:


date < lastDate OR (date = lastDate AND resourceId < lastResourceId)

combined with the original search predicates.

The exact token should be opaque to the caller. Encode or reference everything required to continue the search safely, including the canonicalised query, sort definition, last sort values, page size and any tenant or security context that must remain fixed.

Protect tokens against modification. A signed token prevents a client from changing internal identifiers or paging state. If the token contains sensitive information, encrypt it rather than merely signing it.

A second model is server-side search sessions. The first query produces a search identifier and stores either the complete ordered list of matching IDs or enough state to reproduce the result. Subsequent links reference that search state.

This can provide strong consistency between pages but introduces memory, storage and lifecycle costs. Large searches may produce millions of matching IDs, so cached search sessions require expiry policies and strict resource limits.

Cursor-based searches recalculate against live indexes and avoid storing entire result sets, but concurrent writes can affect what later pages contain. The implementation must therefore choose the consistency model deliberately.

For many interactive clinical applications, stable ordering plus cursor pagination is sufficient. For workflows requiring an immutable snapshot, create explicit snapshot semantics or a server-side export process rather than pretending that ordinary live search pagination provides full transactional isolation.

Set maximum page sizes. A request for _count=100000 should not be allowed to consume unlimited memory simply because the client supplied it. Apply a server-side maximum and return a smaller page when necessary.

Also distinguish primary matches from included resources. _count applies to search matches, while _include and _revinclude can add additional resources to the Bundle. A page of 100 primary resources can therefore contain substantially more than 100 entries.

Page-size limits alone cannot control response size when includes are available. Add independent limits for included resources, traversal depth, serialised Bundle size and query execution cost.

Avoid calculating an exact Bundle.total unless the client genuinely needs it. Counting all matches can force the database to process a much larger result set than producing the first page requires. Where the supported FHIR version and server behaviour allow it, _total=none can remove that work, while estimated totals can be used where supported and appropriate.

For user interfaces, design the client so exact counts are not required for every search. Fetch a page, expose next-page navigation and calculate totals only for workflows where the number is operationally necessary.

Control Chaining, Includes and Other Expensive FHIR Search Features

FHIR search becomes considerably more complex once a query starts traversing references.

A chained search such as:


Observation?patient.name=Smith

requires the engine to resolve the Observation patient reference and restrict Observations according to a SearchParameter on the referenced Patient.

Implement this using index-level joins rather than loading Patients individually. Resolve matching Patient IDs using the Patient search index, then use those IDs to restrict the Observation reference index. Where the database supports an efficient join plan, perform the operation in the database rather than constructing an enormous in-memory ID list.

Reverse chaining with _has performs the relationship in the opposite direction. It can be extremely powerful, but the planner should estimate its cost before execution because broad reverse relationships can expand quickly.

Apply limits to chaining depth. Arbitrarily deep chains give clients the ability to create query graphs with unpredictable join costs. Define which chains are supported, expose them through the server’s conformance information and reject unsupported or excessively expensive combinations early.

Handle _include only after the primary result page has been determined. First identify the primary matches, sort them and select the page. Then resolve resources requested through _include for those matches.

This order keeps joins for included resources proportional to the page rather than the complete search result.

For example, when processing:


Observation?patient=123&_include=Observation:encounter&_count=50

select the 50 matching Observations first. Extract their Encounter references, deduplicate those references and fetch the corresponding Encounters in a set-oriented operation.

Do not execute one database query per Observation. That produces the classic N+1 query pattern and can turn a single FHIR request into dozens or hundreds of database calls.

The same principle applies to _revinclude. Collect the primary resource IDs for the current page and perform a set-based search for resources whose indexed references target those IDs.

Wildcard and iterative includes require stricter controls. FHIR resources form a graph, and uncontrolled traversal can spread from a small starting set into a large proportion of the repository. Implement maximum traversal depth, maximum included-resource count and maximum response payload size.

Query-cost controls should cover more than reference traversal. Assign cost characteristics to search operations so the server can reject or constrain pathological requests before they exhaust resources.

Useful controls include:

  • Maximum number of chained and reverse-chained joins.
  • Maximum _include and _revinclude expansions.
  • Maximum page and Bundle size.
  • Execution timeout and database statement timeout.
  • Maximum number of OR values or repeated search parameters.
  • Restrictions on unbounded or low-selectivity searches.
  • Limits on expensive sorting and exact-count operations.
  • Per-client concurrency and rate limits.

Return a meaningful OperationOutcome when a query is rejected as too expensive. This is preferable to letting it consume database capacity until infrastructure timeouts terminate it.

Separate interactive search from bulk extraction. A REST search endpoint optimised for clinicians viewing a patient timeline should not also be expected to stream every Observation produced by an organisation over five years. Route large analytical extraction to bulk-data or asynchronous export workflows where appropriate.

Use _elements or summary representations when a client requires only part of each resource and the implementation can exploit that request efficiently. Avoid parsing, serialising and transferring large resource bodies when a search results screen needs only identifiers, status, dates and display fields.

Search payload optimisation becomes particularly important for resources containing large narratives, attachments or complex extensions. Keep Binary data out of ordinary search result payloads unless explicitly required.

Measure, Benchmark and Tune FHIR Search as a Production Workload

The final layer is continuous performance engineering based on actual FHIR queries.

Record search metrics in a structured form rather than relying only on generic HTTP request duration. Parse each search into characteristics that can be aggregated without logging sensitive patient values.

Capture metrics such as resource type, SearchParameter names, search parameter types, number of predicates, chain depth, requested includes, sort fields, requested page size, returned match count, included-resource count, database duration, resource-hydration duration, serialisation duration and total response size.

Do not place patient names, NHS numbers, local identifiers or unrestricted query strings into observability platforms. Convert searches into sanitised query fingerprints.

For example, these requests:


Observation?patient=123&code=...&date=...

and


Observation?patient=456&code=...&date=...

can share the same performance fingerprint:


Observation|patient+code+date|sort:none|include:none

This lets engineering teams identify slow query patterns without exposing the search values themselves.

Measure latency percentiles rather than averages. P50 shows ordinary behaviour, but P95 and P99 reveal the searches producing the user-visible pauses and infrastructure spikes that usually matter most.

Break server latency into stages. A five-second response tells you little on its own. The same request becomes actionable if telemetry shows 80 ms parsing the FHIR query, 4.4 seconds executing SQL, 120 ms hydrating resources and 400 ms serialising a large Bundle.

Add database telemetry that correlates with the FHIR request identifier. This lets engineers move directly from a slow Observation?patient+code+date search fingerprint to the database plan responsible for it.

Benchmark with realistic data distributions. Generating ten million Observations evenly distributed across ten million synthetic patients produces different query behaviour from ten million Observations concentrated around tens of thousands of real-world patients. Index selectivity and page depth depend on the distribution of values as much as the raw resource count.

Construct performance datasets with realistic resource volumes, repeated codes, patient histories, common statuses and reference density.

Then benchmark representative operations rather than a single synthetic query. A useful FHIR search test suite should exercise simple token searches, patient-scoped searches, date ranges, multiple predicates, sorting, chained searches, reverse chains, _include, _revinclude, exact totals, first-page latency and deep pagination.

Run those workloads under concurrent write traffic as well. Search performance measured against a read-only database can conceal lock contention, cache pressure and index-maintenance costs that appear when clinical resources are continuously created and updated.

Establish performance budgets by query class. A direct identifier search, a patient timeline query and a multi-hop chained search should not necessarily have the same target latency. Define separate service-level targets and alert when a query fingerprint begins to deviate from its normal distribution.

Treat reindexing as a workload in those tests. Measure write latency and search latency while indexes are being rebuilt, and throttle reindex workers until foreground requests remain inside their required performance envelope.

Use caching selectively. Metadata such as CapabilityStatements and SearchParameter definitions can often be cached aggressively. Search results require more care because clinical resources change frequently and search behaviour may depend on authorisation context.

Where identical search result sets are cached, ensure the cache key includes every component that can change the visible result: canonical query parameters, tenant, authorisation scope, FHIR version and any other partition boundary. Never allow a cache entry created under one security context to be served under another.

For repeated high-volume clinical access patterns, optimise the index rather than relying on caching to hide an inefficient query. Cache misses will still expose the underlying problem, while an appropriately designed search path improves every request.

Finally, operate the search layer as a continuously evolving component. New integrations introduce new SearchParameters. New workflows alter query combinations. Data growth changes cardinality. Database upgrades modify optimiser behaviour. Adding a custom FHIR profile can introduce searches that were absent when the original persistence schema was designed.

Review the slowest query fingerprints regularly. Remove unused indexes when their maintenance cost exceeds their value. Add or reshape indexes for emerging high-volume access patterns. Recalculate database statistics after major data changes. Re-run representative load tests after schema, SearchParameter or FHIR server upgrades.

At scale, efficient FHIR search is created by controlling the complete query path: extract searchable values when resources are written, index them according to their FHIR semantics, narrow result sets using selective predicates, make sorting deterministic, paginate with cursors rather than increasingly deep offsets, bound expensive graph operations and observe every stage of query execution.

With those mechanisms in place, growth in the clinical repository does not have to translate directly into slower search. A server can move from millions to hundreds of millions of resources while keeping common patient-facing searches predictable, provided the search architecture is designed around the queries being executed rather than the JSON documents being stored.

Need help with FHIR implementation and integration?

Is your team looking for help with FHIR implementation and integration? Click the button below.

Get in touch