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

LayerTouches every request?Tune first when...
JVM heap and GCYesjvm_gc_pause_seconds_max is climbing or memory usage is non-flat
HikariCP connection poolYeshikaricp_connections_pending > 0 or acquisition timeouts
PostgreSQL config + indexesYespg_stat_statements shows high mean exec time
RedisYes (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 splitPage load onlyFirst 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: 30000

Sizing 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:

MetricWhat it means
hikaricp_connections_pending > 0 for > 1sPool exhausted; either raise size or fix slow queries holding connections
hikaricp_connections_acquire p99 > 100 msDB is slow or pool is exhausted; check pg_stat_activity
hikaricp_connections_max < maximum-pool-sizePool is over-sized; reduce to reclaim PG slots
Leak warnings in logA 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 = 100

Index strategy

The platform indexes you should not remove (created automatically by the migration runner):

  • ab_meta_* lookup indexes on tenant_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 in pg_stat_statements. Do not rely on the planner to combine single-column indexes.
  • EXPLAIN ANALYZE before 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 = all

Then 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: 4

Watch:

MetricThreshold
redis.commands.duration p99< 5 ms — anything higher is network or saturated CPU
redis.evicted_keys rateShould be 0; non-zero means maxmemory too low
redis.connected_clientsShould 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 shapeWarm-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. useDebouncedValue and useDebouncedCallback live in app/hooks/useDebouncedValue.ts.
  • Keyset pagination on list pagespageSize=20&cursor=<last_id> gives page 1000 the latency of page 1; the platform already uses keyset internally for /api/dynamic/{pageKey}/list when you pass cursor.
  • 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

PatternWhy it bites
Raising HikariCP maximum-pool-size to fix slow queriesMoves the wait from the pool to PG; same latency, worse fairness
work_mem in the GB rangeMultiplies by parallel-workers; OOMs PG under realistic load
Caching what should be indexedBuys 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 prodLoses the N+1 budget signal at exactly the moment you need it
Tuning before measuringAlmost always tunes the wrong layer

Next steps

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.