Performance Tuning
OctoFHIR includes built-in performance optimizations including query caching, automatic index analysis, and configurable tuning options.
For the current query/index execution model, see Search Indexing.
Query Caching
Section titled “Query Caching”Overview
Section titled “Overview”OctoFHIR caches query execution plans to improve performance for repeated searches:
- Cache key: Based on parameter structure (not values)
- Cache hit: Skip query planning, reuse prepared statement
- Expected improvement: 30-50% faster for cached queries
Configuration
Section titled “Configuration”[search]default_count = 10max_count = 1000
# Query cache settingscache_enabled = truecache_capacity = 1000 # Maximum cached queriescache_max_age_secs = 3600 # Cache entry TTLHow It Works
Section titled “How It Works”- First request: Query is parsed, planned, and cached
- Subsequent requests: Cache hit, only values are bound
- Cache eviction: LRU (Least Recently Used) when capacity reached
Cache Statistics
Section titled “Cache Statistics”Monitor cache performance via admin endpoint:
GET /admin/cache/statsResponse:
{ "query_cache": { "hits": 15420, "misses": 1230, "size": 456, "capacity": 1000, "hit_ratio": 0.926 }}Cache Invalidation
Section titled “Cache Invalidation”The cache automatically invalidates when:
- TTL expires
- Server restarts
- Manual clear via admin API
POST /admin/cache/clearIndex Optimization
Section titled “Index Optimization”Automatic Index Analysis
Section titled “Automatic Index Analysis”OctoFHIR analyzes query performance and suggests indexes:
GET /admin/indexes/suggestions?resource_type=PatientResponse:
{ "suggestions": [ { "table": "patient", "column": "resource->>'family'", "type": "btree", "reason": "Frequently searched, currently using sequential scan", "estimated_improvement": "10x", "create_statement": "CREATE INDEX idx_patient_family ON patient ((resource->>'family'));" } ]}Built-In Indexes (Default)
Section titled “Built-In Indexes (Default)”In most setups, you do not need to handcraft indexes on day one.
OctoFHIR already creates and maintains:
- Resource-table indexes (per resource type table):
- GIN on
resourceJSONB for containment paths - BTREE on lifecycle columns (
updated_at,status, etc.)
- GIN on
- Denormalized search tables:
search_idx_referencefor reference lookup, include/revinclude, chainingsearch_idx_datefor date range and precision-aware date matching
This gives good defaults for real-world FHIR traffic without manual SQL tuning.
Optional Custom Indexes
Section titled “Optional Custom Indexes”Add custom indexes only after measuring a real hotspot (via EXPLAIN plans, slow-query logs, or index suggestions API).
Common candidates:
- Heavy string prefix/contains traffic on a specific field
- Custom extension fields queried at high frequency
- Tenant-specific filter patterns in multi-tenant deployments
Example (optional, workload-driven):
-- Example: optimize frequent Patient.family prefix lookupsCREATE INDEX idx_patient_family_textON patient ((LOWER(resource->'name'->0->>'family')));GIN vs BTREE
Section titled “GIN vs BTREE”| Index Type | Use Case | Example |
|---|---|---|
| BTREE | Equality, ranges, ordered scans | search_idx_date.range_start, target_id lookups |
| GIN | JSONB containment and array/object matching | resource @> {...} for exact-style filters |
Analyzing Query Performance
Section titled “Analyzing Query Performance”Enable slow query logging:
[search]slow_query_threshold_ms = 100 # Log queries slower than 100msView slow queries:
GET /admin/queries/slow?limit=10Response:
{ "slow_queries": [ { "query_hash": "abc123", "execution_time_ms": 450, "resource_type": "Observation", "parameters": ["code", "date", "subject"], "sequential_scans": ["observation"], "suggestion": "Add index on (resource->'code'->'coding')" } ]}Connection Pooling
Section titled “Connection Pooling”Configuration
Section titled “Configuration”[storage.postgres]url = "postgres://user:pass@localhost/octofhir"pool_size = 20connect_timeout_ms = 5000idle_timeout_ms = 600000Sizing Guidelines
Section titled “Sizing Guidelines”| Concurrent Users | Pool Size |
|---|---|
| < 50 | 10-20 |
| 50-200 | 20-50 |
| 200-1000 | 50-100 |
| > 1000 | Use PgBouncer |
Monitoring
Section titled “Monitoring”GET /admin/db/stats{ "pool": { "size": 20, "available": 15, "in_use": 5, "waiting": 0 }, "queries": { "total": 125000, "avg_time_ms": 12.5 }}Read Replicas
Section titled “Read Replicas”For read-heavy workloads, route search and read operations to a PostgreSQL read replica:
[storage.postgres.read_replica]url = "postgres://user:pass@replica:5432/octofhir"pool_size = 30 # Defaults to primary pool_sizeconnect_timeout_ms = 5000 # Defaults to primary valueidle_timeout_ms = 300000 # Defaults to primary valueWhen configured, the following operations use the replica pool:
GET /fhir/{type}?...(search)GET /fhir/{type}/{id}(read)GET /fhir/{type}/{id}/_history/{vid}(vread)GET /fhir/{type}/{id}/_history(history)GET /fhir/{type}/_history(type history)GET /fhir/_history(system history)
Write operations (create, update, delete, transactions) always go to the primary.
Redis Caching
Section titled “Redis Caching”Configuration
Section titled “Configuration”[redis]enabled = trueurl = "redis://localhost:6379"pool_size = 10timeout_ms = 1000Cache Layers
Section titled “Cache Layers”- Local cache (in-memory DashMap): Fastest, per-instance
- Redis cache: Shared across instances
- Database: Source of truth
Cache Invalidation
Section titled “Cache Invalidation”When resources are modified:
- Local cache entry removed
- Redis
PUBLISHsent to invalidation channel - Other instances receive notification and clear local cache
Search Optimization
Section titled “Search Optimization”Use Specific Parameters
Section titled “Use Specific Parameters”# Slower: scans all observationsGET /Observation?status=final
# Faster: filters by code first (likely indexed)GET /Observation?code=http://loinc.org|8867-4&status=finalLimit Results
Section titled “Limit Results”# Returns up to 10 results (default)GET /Patient?family=Smith
# Explicitly limitGET /Patient?family=Smith&_count=50
# Just get the countGET /Patient?family=Smith&_summary=countUse _elements
Section titled “Use _elements”# Returns full resourcesGET /Patient?family=Smith
# Returns only needed fields (less data transfer)GET /Patient?family=Smith&_elements=id,name,birthDateAvoid Large _include
Section titled “Avoid Large _include”# Can return many resourcesGET /Patient?_revinclude=Observation:subject
# Better: paginate and fetch separatelyGET /Patient?family=Smith&_count=10GET /Observation?subject=Patient/123&_count=50Monitoring and Metrics
Section titled “Monitoring and Metrics”Prometheus Metrics
Section titled “Prometheus Metrics”OctoFHIR exposes Prometheus metrics at /metrics:
# Query cacheoctofhir_query_cache_hits_totaloctofhir_query_cache_misses_totaloctofhir_query_cache_size
# Search performanceoctofhir_search_duration_seconds{resource_type, modifier}octofhir_search_results_total{resource_type}
# Transaction performanceoctofhir_transaction_duration_secondsoctofhir_transaction_entries_totaloctofhir_transaction_rollback_total
# Databaseoctofhir_db_pool_connections_totaloctofhir_db_query_duration_secondsoctofhir_slow_query_totalOpenTelemetry
Section titled “OpenTelemetry”Enable distributed tracing:
[otel]enabled = trueendpoint = "http://jaeger:4317"service_name = "octofhir"Spans include:
http.request- Full request lifecyclesearch.execute- Search executionsearch.cache_lookup- Cache operationsterminology.expand- ValueSet expansiontransaction.execute- Transaction processing
Benchmarking
Section titled “Benchmarking”Load Testing with k6
Section titled “Load Testing with k6”import http from 'k6/http';import { check, sleep } from 'k6';
export const options = { stages: [ { duration: '30s', target: 50 }, { duration: '1m', target: 100 }, { duration: '30s', target: 0 }, ],};
export default function () { const res = http.get('http://localhost:8080/Patient?family=Smith', { headers: { 'Accept': 'application/fhir+json' }, });
check(res, { 'status is 200': (r) => r.status === 200, 'response time < 100ms': (r) => r.timings.duration < 100, });
sleep(0.1);}Run:
k6 run loadtest.jsExpected Performance
Section titled “Expected Performance”| Operation | Target | Notes |
|---|---|---|
| Simple search | < 50ms | Indexed parameter |
| Complex search | < 200ms | Multiple parameters |
| _include search | < 500ms | Depends on included count |
| Transaction (10 entries) | < 100ms | |
| ValueSet expansion | < 100ms | Cached |
| ValueSet expansion | < 2s | Uncached, large |
Troubleshooting
Section titled “Troubleshooting”Slow Queries
Section titled “Slow Queries”-
Check for missing indexes:
Terminal window GET /admin/indexes/suggestions -
Enable slow query logging:
[search]slow_query_threshold_ms = 50 -
Analyze specific query:
Terminal window POST /admin/queries/analyze{"query": "Patient?family=Smith&birthdate=lt1980"}
High Memory Usage
Section titled “High Memory Usage”-
Reduce cache sizes:
[search]cache_capacity = 500 -
Check for large result sets:
Terminal window GET /admin/queries/large_results
Connection Pool Exhaustion
Section titled “Connection Pool Exhaustion”-
Increase pool size:
[storage.postgres]pool_size = 50 -
Check for long-running queries:
Terminal window GET /admin/db/active_queries -
Consider using PgBouncer for connection pooling