Performance Tuning
Performance work on AuraBoot has a defined order. Tune the layers that affect every request first — JVM, connection pool, PostgreSQL — before tuning subsystem-specific knobs. This page documents the actual levers, their default values, and the symptoms that tell you which one is wrong. It does not aim to be exhaustive; it aims to keep you out of the wrong layer.
If you are diagnosing a specific outage rather than baselining production, read Observability first — start from traces, not from JVM heap dumps.
The layer order
| Layer | Touches every request? | Tune first when... |
|---|---|---|
| JVM heap and GC | Yes | jvm_gc_pause_seconds_max is climbing or memory usage is non-flat |
| HikariCP connection pool | Yes | hikaricp_connections_pending > 0 or acquisition timeouts |
| PostgreSQL config + indexes | Yes | pg_stat_statements shows high mean exec time |
| Redis | Yes (cache hit path) | Cache miss rate climbing or eviction count rising |
| Metadata cache (Caffeine L1) | Yes (read path) | Command pipeline warm SQL count rises |
| Frontend bundle / code split | Page load only | First Contentful Paint regresses |
Skipping the order is the single most common mistake. A "slow command" is almost always a JDBC / PG / index problem, not Java code.
JVM heap and GC
Defaults assume Spring Boot 3.5 on Java 21 with G1GC.
# Recommended baseline for a single backend node (16 GB RAM)
export JAVA_TOOL_OPTIONS="\
-Xms4g -Xmx4g \
-XX:MaxGCPauseMillis=200 \
-XX:+UseG1GC \
-XX:+ParallelRefProcEnabled \
-XX:+UnlockExperimentalVMOptions \
-XX:G1ReservePercent=15 \
-XX:InitiatingHeapOccupancyPercent=35 \
-Xlog:gc*:file=/var/log/auraboot/gc.log:time,uptime,level,tags:filecount=10,filesize=50M"Rules:
- Set
-Xms == -Xmx. Heap resizing pauses the JVM. Equal min/max removes that pause. - Heap = ~25% of RAM, not 50%+. PostgreSQL, Redis, page cache, and OS need the rest. On 16 GB nodes, 4 GB heap is the right starting point.
- G1 over ZGC for AuraBoot's workload. Most commands are not allocation-heavy; G1 with a 200 ms target is more predictable than ZGC's sub-10 ms target for our request profile.
- Monitor
jvm_gc_pause_seconds_max. If p99 > 500 ms sustained, raise heap or investigate allocation pressure before changing collector.
Heap dump on OOM is on by default; route it to a disk you can actually read:
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/var/log/auraboot/heap-dumps/HikariCP connection pool
Defaults in application.yml:
spring:
datasource:
hikari:
maximum-pool-size: 30
minimum-idle: 5
connection-timeout: 30000
max-lifetime: 1800000
idle-timeout: 600000
leak-detection-threshold: 30000Sizing formula (Brian Goetz' rule applied):
maximum-pool-size ≈ (cpu_cores × 2) + effective_spindle_count
For a single 8-core node hitting one PG instance: start at 20, raise to 40 only if hikaricp_connections_pending is consistently > 0. Beyond ~50, PG itself becomes the bottleneck — adding pool members makes queueing worse, not better.
Symptoms:
| Metric | What it means |
|---|---|
hikaricp_connections_pending > 0 for > 1s | Pool exhausted; either raise size or fix slow queries holding connections |
hikaricp_connections_acquire p99 > 100 ms | DB is slow or pool is exhausted; check pg_stat_activity |
hikaricp_connections_max < maximum-pool-size | Pool is over-sized; reduce to reclaim PG slots |
| Leak warnings in log | A code path is not closing the connection; trace via leak-detection-threshold |
PostgreSQL
Two files matter most: postgresql.conf and the indexes you build.
postgresql.conf baseline (16 GB RAM dedicated PG host)
shared_buffers = 4GB # ~25% of RAM
effective_cache_size = 12GB # OS + PG cache estimate, ~75% of RAM
work_mem = 32MB # per-operation, multiplied by parallel workers
maintenance_work_mem = 512MB
max_connections = 100 # match HikariCP totals across all app nodes
wal_buffers = 64MB
checkpoint_completion_target = 0.9
random_page_cost = 1.1 # for SSD
effective_io_concurrency = 200 # for SSD
default_statistics_target = 100Index strategy
The platform indexes you should not remove (created automatically by the migration runner):
ab_meta_*lookup indexes ontenant_id, code— these back the metadata cache warmup.ab_dyn_*table primary keys + tenant scope indexes — back every dynamic data access.
Indexes you add yourself live in plugin migration files. Two rules:
- Add a composite index for any
WHERE a = ? AND b = ?query observed inpg_stat_statements. Do not rely on the planner to combine single-column indexes. EXPLAIN ANALYZEbefore adding the index, and after. A new index that does not change the plan is technical debt.
pg_stat_statements
Enable it:
shared_preload_libraries = 'pg_stat_statements'
pg_stat_statements.track = allThen identify the top spenders:
SELECT mean_exec_time, calls, total_exec_time, query
FROM pg_stat_statements
WHERE query NOT LIKE '%pg_%'
ORDER BY total_exec_time DESC
LIMIT 20;Fix in this order: missing index → query rewrite → cache it → denormalize. Caching what should be indexed buys a 30-day reprieve and a 6-month tech-debt invoice.
Redis
AuraBoot uses Redis for distributed locks (BPM, automation), session storage, rate-limit buckets, and L2 metadata cache.
spring:
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
timeout: 2000ms
lettuce:
pool:
max-active: 16
max-idle: 8
min-idle: 4Watch:
| Metric | Threshold |
|---|---|
redis.commands.duration p99 | < 5 ms — anything higher is network or saturated CPU |
redis.evicted_keys rate | Should be 0; non-zero means maxmemory too low |
redis.connected_clients | Should equal pool.max-active × node_count + administrative |
Lock contention on automation:trigger:* | Suggests automation rules need partitioning by tenant |
Sizing: 2 GB Redis is enough for a single-tenant production until cache and session footprints exceed it. Plan based on metrics, not on prediction.
Metadata cache (Caffeine L1)
The platform's metadata layer caches command definitions, binding rules, state graphs, model definitions, user permissions, named queries, and dictionary data in JVM-local Caffeine caches (30 min TTL, 10 K max entries each). CacheWarmupRunner loads all active tenants' metadata at ApplicationReadyEvent.
All regions share one Caffeine builder — the cacheManager bean in CacheConfig sets maximumSize(10_000) + expireAfterWrite(30 min) once, then setCacheNames(...) pre-creates the region names (commandDefinitions, bindingRules, stateGraphDefinitions, modelDefinitions, and so on; sensitive permission caches use a separate permissionCacheManager with a 5 min TTL). To change capacity or TTL, edit this bean — there is no per-region YAML knob:
// CacheConfig.cacheManager()
cacheManager.setCaffeine(Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(30))
.recordStats());Symptom: command pipeline SQL count climbs after deploy. Likely cause: cache was evicted by a plugin import or migration. Fix: extend warmup, or accept the rebuild window.
Command pipeline SQL budget
Every HTTP response carries X-SQL-Count. Production budgets:
| Command shape | Warm-cache SQL count |
|---|---|
| StateTransition (no handlers) | ≤ 6 |
| Action (no handlers) | ≤ 8 |
| Action with one handler + binding rule | ≤ 12 |
| FlowStep (BPMN task) | ≤ 18 |
Anything beyond doubles the budget warrants a look in the access log filter — alerts fire at 50 (N+1 warning) and 100 (N+1 critical). Common offenders: a handler that lazy-fetches a list, or a join that has been replaced by a loop.
Frontend
Performance work on the frontend is dominated by:
- Code splitting — designers (Page / Dashboard / BPMN / Flow / Report / QueryBuilder) and heavy data plugins (xlsx, jspdf, html2canvas, recharts, reactflow) are isolated into their own chunks. Do not eagerly import them.
- Debounced inputs — search at 300 ms, filter/sort at 150 ms.
useDebouncedValueanduseDebouncedCallbacklive inapp/hooks/useDebouncedValue.ts. - Keyset pagination on list pages —
pageSize=20&cursor=<last_id>gives page 1000 the latency of page 1; the platform already uses keyset internally for/api/dynamic/{pageKey}/listwhen you passcursor. - Bundle size budget — vendor chunks > 500 KB gzipped are reviewed; the report designer (~430 KB xlsx + ~390 KB jspdf) is the current ceiling.
Common pitfalls
| Pattern | Why it bites |
|---|---|
Raising HikariCP maximum-pool-size to fix slow queries | Moves the wait from the pool to PG; same latency, worse fairness |
work_mem in the GB range | Multiplies by parallel-workers; OOMs PG under realistic load |
| Caching what should be indexed | Buys time, accrues debt; ship the index first |
| Adding ZGC because "low pause is good" | Increases CPU; not a fit for AuraBoot's allocation profile |
Turning off X-SQL-Count in prod | Loses the N+1 budget signal at exactly the moment you need it |
| Tuning before measuring | Almost always tunes the wrong layer |
Next steps
- Observability — metrics and trace setup before any tuning
- Disaster recovery — RTO/RPO targets and drill checklist
- Load balancing — Nginx / HAProxy / Cloudflare front-end patterns
- Cluster mode — when one node is no longer enough
Enterprise note — Per-stage trace spans across the Command Pipeline (Observability Pro), tenant-aware automatic query plan capture, and capacity-planning dashboards seeded with your real metrics are commercial-only. The OSS platform exposes every metric and metadata cache region described above; the difference is the pre-wired analysis surface, not the data.