Connection Pooling
Configure HTTP connection pooling for optimal performance. Creating new HTTP connections for each request adds significant latency. Connection pooling reuses existing connections, reducing TCP handshake overhead and improving throughput. Set pool sizes based on expected concurrent requests, with defaultMaxPerRoute limiting connections per host and maxTotal limiting total connections.
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
public class OptimizedFhirClient {
private static FhirContext ctx;
private static PoolingHttpClientConnectionManager connectionManager;
static {
ctx = FhirContext.forR4();
// Configure connection pooling
connectionManager = new PoolingHttpClientConnectionManager();
connectionManager.setMaxTotal(200);
connectionManager.setDefaultMaxPerRoute(20);
// Set timeouts
ctx.getRestfulClientFactory().setConnectTimeout(20000);
ctx.getRestfulClientFactory().setSocketTimeout(20000);
ctx.getRestfulClientFactory().setConnectionRequestTimeout(20000);
// Set connection manager
ctx.getRestfulClientFactory().setHttpClient(
HttpClientBuilder.create()
.setConnectionManager(connectionManager)
.build()
);
}
public static IGenericClient createClient(String serverBase) {
return ctx.newRestfulGenericClient(serverBase);
}
public static void shutdown() {
if (connectionManager != null) {
connectionManager.close();
}
}
}
Caching Strategies
Implement caching to reduce server load and improve response times. Caching stores frequently accessed resources locally, avoiding network round-trips for repeated reads. Use different cache configurations for different access patterns: longer expiration for stable reference data, shorter for frequently changing clinical data. Always implement cache invalidation when resources are updated.
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import java.util.concurrent.TimeUnit;
public class FhirCacheExample {
private final IGenericClient client;
private final Cache<String, IBaseResource> resourceCache;
private final Cache<String, Bundle> searchCache;
public FhirCacheExample(IGenericClient client) {
this.client = client;
// Resource cache (1000 entries, 10 minutes)
this.resourceCache = CacheBuilder.newBuilder()
.maximumSize(1000)
.expireAfterWrite(10, TimeUnit.MINUTES)
.build();
// Search results cache (100 entries, 5 minutes)
this.searchCache = CacheBuilder.newBuilder()
.maximumSize(100)
.expireAfterWrite(5, TimeUnit.MINUTES)
.build();
}
public Patient getPatient(String patientId) {
String cacheKey = "Patient/" + patientId;
// Check cache first
IBaseResource cached = resourceCache.getIfPresent(cacheKey);
if (cached != null) {
return (Patient) cached;
}
// Fetch from server
Patient patient = client.read()
.resource(Patient.class)
.withId(patientId)
.execute();
// Store in cache
resourceCache.put(cacheKey, patient);
return patient;
}
public Bundle searchPatients(String familyName) {
String cacheKey = "Patient?family=" + familyName;
// Check cache
Bundle cached = searchCache.getIfPresent(cacheKey);
if (cached != null) {
return cached;
}
// Execute search
Bundle results = client.search()
.forResource(Patient.class)
.where(Patient.FAMILY.matches().value(familyName))
.returnBundle(Bundle.class)
.execute();
// Cache results
searchCache.put(cacheKey, results);
return results;
}
public void invalidateCache(String resourceType, String resourceId) {
String cacheKey = resourceType + "/" + resourceId;
resourceCache.invalidate(cacheKey);
// Optionally clear all search caches
searchCache.invalidateAll();
}
}
Batch Processing
Process large datasets efficiently in batches. Sending thousands of individual requests overwhelms servers and wastes network resources. Transaction bundles combine multiple operations into single requests, dramatically improving throughput. Choose batch sizes based on server capabilities and resource sizes. Implement error handling to identify and retry failed batches.
public class BatchProcessingExample {
private final IGenericClient client;
private final int batchSize = 100;
public BatchProcessingExample(IGenericClient client) {
this.client = client;
}
public void processLargeDataset(List<IBaseResource> resources) {
List<List<IBaseResource>> batches = partition(resources, batchSize);
int processed = 0;
for (List<IBaseResource> batch : batches) {
try {
processBatch(batch);
processed += batch.size();
System.out.println("Processed " + processed + "/" + resources.size());
} catch (Exception e) {
System.err.println("Batch failed: " + e.getMessage());
// Could implement retry logic here
}
}
}
private void processBatch(List<IBaseResource> resources) {
Bundle bundle = new Bundle();
bundle.setType(Bundle.BundleType.TRANSACTION);
for (IBaseResource resource : resources) {
bundle.addEntry()
.setResource((Resource) resource)
.getRequest()
.setMethod(Bundle.HTTPVerb.POST)
.setUrl(resource.fhirType());
}
client.transaction().withBundle(bundle).execute();
}
private <T> List<List<T>> partition(List<T> list, int size) {
List<List<T>> partitions = new ArrayList<>();
for (int i = 0; i < list.size(); i += size) {
partitions.add(list.subList(i, Math.min(i + size, list.size())));
}
return partitions;
}
}
Async Processing
Use asynchronous operations for better throughput. When fetching data for multiple patients or gathering related resources, sequential requests wait unnecessarily. CompletableFuture enables parallel execution of independent operations, dramatically reducing total wait time. Use thread pools to control resource consumption and implement proper shutdown to release resources.
import java.util.concurrent.*;
public class AsyncFhirOperations {
private final IGenericClient client;
private final ExecutorService executor;
public AsyncFhirOperations(IGenericClient client) {
this.client = client;
this.executor = Executors.newFixedThreadPool(10);
}
public CompletableFuture<Patient> getPatientAsync(String patientId) {
return CompletableFuture.supplyAsync(() -> {
return client.read()
.resource(Patient.class)
.withId(patientId)
.execute();
}, executor);
}
public CompletableFuture<List<Observation>> getObservationsAsync(String patientId) {
return CompletableFuture.supplyAsync(() -> {
Bundle results = client.search()
.forResource(Observation.class)
.where(Observation.PATIENT.hasId(patientId))
.returnBundle(Bundle.class)
.execute();
return results.getEntry().stream()
.map(e -> (Observation) e.getResource())
.collect(Collectors.toList());
}, executor);
}
public CompletableFuture<PatientSummary> getPatientSummary(String patientId) {
// Parallel fetch of patient and observations
CompletableFuture<Patient> patientFuture = getPatientAsync(patientId);
CompletableFuture<List<Observation>> obsFuture = getObservationsAsync(patientId);
return patientFuture.thenCombine(obsFuture, (patient, observations) -> {
return new PatientSummary(patient, observations);
});
}
public void shutdown() {
executor.shutdown();
try {
if (!executor.awaitTermination(60, TimeUnit.SECONDS)) {
executor.shutdownNow();
}
} catch (InterruptedException e) {
executor.shutdownNow();
}
}
static class PatientSummary {
private Patient patient;
private List<Observation> observations;
public PatientSummary(Patient patient, List<Observation> observations) {
this.patient = patient;
this.observations = observations;
}
public Patient getPatient() { return patient; }
public List<Observation> getObservations() { return observations; }
}
}
Error Handling Best Practices
Implement robust error handling with retries. Network failures and server issues are inevitable in distributed systems. Distinguish between retryable errors (429, 5xx) and permanent failures (4xx). Exponential backoff progressively increases delays between retries, reducing load on struggling services. Always handle specific exceptions before generic ones for appropriate recovery strategies.
import ca.uhn.fhir.rest.server.exceptions.*;
public class RobustFhirClient {
private final IGenericClient client;
private final int maxRetries = 3;
public RobustFhirClient(IGenericClient client) {
this.client = client;
}
public <T extends IBaseResource> T readWithRetry(
Class<T> resourceType,
String id) {
int attempt = 0;
while (attempt < maxRetries) {
try {
return client.read()
.resource(resourceType)
.withId(id)
.execute();
} catch (ResourceNotFoundException e) {
// Don't retry - resource doesn't exist
throw e;
} catch (AuthenticationException e) {
// Don't retry - auth issue
throw e;
} catch (BaseServerResponseException e) {
// Check if retryable
int statusCode = e.getStatusCode();
if (statusCode == 429 || // Too many requests
statusCode >= 500) { // Server error
attempt++;
if (attempt >= maxRetries) {
throw new RuntimeException(
"Failed after " + maxRetries + " attempts", e);
}
// Exponential backoff
try {
Thread.sleep((long) Math.pow(2, attempt) * 1000);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new RuntimeException("Interrupted during retry", ie);
}
} else {
throw e;
}
}
}
throw new RuntimeException("Unexpected: exceeded max retries");
}
public void safeDelete(String resourceType, String id) {
try {
client.delete()
.resourceById(resourceType, id)
.execute();
} catch (ResourceNotFoundException e) {
// Already deleted - that's fine
System.out.println("Resource already deleted: " + resourceType + "/" + id);
} catch (Exception e) {
System.err.println("Delete failed: " + e.getMessage());
throw e;
}
}
public Bundle searchWithErrorHandling(String resourceType, String searchParams) {
try {
return client.search()
.byUrl(resourceType + "?" + searchParams)
.returnBundle(Bundle.class)
.execute();
} catch (InvalidRequestException e) {
System.err.println("Invalid search parameters: " + e.getMessage());
// Return empty bundle
Bundle emptyBundle = new Bundle();
emptyBundle.setType(Bundle.BundleType.SEARCHSET);
emptyBundle.setTotal(0);
return emptyBundle;
} catch (Exception e) {
System.err.println("Search failed: " + e.getMessage());
throw e;
}
}
}
Monitoring and Metrics
Track FHIR operations for observability. Production systems need visibility into request rates, latencies, and error rates. Micrometer provides a vendor-neutral metrics facade compatible with Prometheus, Datadog, and other monitoring systems. Instrument critical operations to detect performance degradation and troubleshoot issues quickly.
import io.micrometer.core.instrument.*;
public class FhirMetricsCollector {
private final MeterRegistry registry;
private final Counter requestCounter;
private final Timer requestTimer;
private final Counter errorCounter;
public FhirMetricsCollector(MeterRegistry registry) {
this.registry = registry;
this.requestCounter = Counter.builder("fhir.requests")
.description("Total FHIR requests")
.tags("client", "hapi")
.register(registry);
this.requestTimer = Timer.builder("fhir.request.duration")
.description("FHIR request duration")
.register(registry);
this.errorCounter = Counter.builder("fhir.errors")
.description("FHIR request errors")
.register(registry);
}
public <T> T executeWithMetrics(String operation, Callable<T> callable) {
requestCounter.increment();
Timer.Sample sample = Timer.start(registry);
try {
T result = callable.call();
sample.stop(requestTimer);
return result;
} catch (Exception e) {
errorCounter.increment();
sample.stop(requestTimer);
throw new RuntimeException(e);
}
}
public Patient readPatient(IGenericClient client, String patientId) {
return executeWithMetrics("read-patient", () ->
client.read()
.resource(Patient.class)
.withId(patientId)
.execute()
);
}
}
Performance Best Practices Summary
| Category | Recommendation |
|---|---|
| FhirContext | Create once, reuse everywhere |
| Connection Pooling | Use PoolingHttpClientConnectionManager |
| Caching | Cache frequently accessed resources |
| Batch Operations | Use transaction bundles for bulk operations |
| Async Processing | Use CompletableFuture for parallel operations |
| Error Handling | Implement retry with exponential backoff |
| Timeouts | Always configure appropriate timeouts |
| Monitoring | Track metrics for all operations |
Common Anti-Patterns to Avoid
| Anti-Pattern | Better Approach |
|---|---|
| Creating FhirContext per request | Create once, share across application |
| Individual creates in a loop | Use transaction bundle |
| No timeout configuration | Set connect/socket/request timeouts |
| Catching generic Exception | Handle specific FHIR exceptions |
| No retry logic | Implement exponential backoff |
| Fetching all data at once | Use pagination and streaming |