Skip to content
Back to skills

Performance Optimization

ASecurity

Spring Boot 4 / JVM performance work — measure first, then fix. Profiling (async-profiler, JFR, JMH), Micrometer, common Spring antipatterns (N+1, unbounded lists, HikariCP sizing, virtual-thread pinning), caching, GC, and SLO-driven work. Used by `/plan` for risk callouts and by `/review` to flag perf regressions.

  • 62 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added November 7, 2026
developmentgojavasqlspringcode-reviewdatabasefrontendperformance

Works with

  • cli

Security analysis

A100/100

Scanned November 7, 2026

npx -y skills add loiane/specs-driven-development-spring-angular --skill performance-optimization --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Performance Optimization?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Performance Optimization
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/loiane-performance-optimization-b50bb4ae/badge)](https://www.skillsdirectory.com/skills/loiane-performance-optimization-b50bb4ae)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: performance-optimization
description: Spring Boot 4 / JVM performance work — measure first, then fix. Profiling (async-profiler, JFR, JMH), Micrometer, common Spring antipatterns (N+1, unbounded lists, HikariCP sizing, virtual-thread pinning), caching, GC, and SLO-driven work. Used by `/plan` for risk callouts and by `/review` to flag perf regressions.
when_to_use:
  - Phase 3 (Plan) — when the design touches a hot path, a new query, a new external call, or anything with a stated SLO.
  - Phase 4 (Build) — when a task explicitly targets a performance fix or optimization.
  - Phase 7 (Review) — to flag N+1 queries, unbounded results, virtual-thread pinning, missing pagination, or other antipatterns in the diff.
  - On-demand — user asks "is this fast enough?", "why is this slow?", or "tune the connection pool".
authoritative_references:
  - https://docs.spring.io/spring-boot/reference/index.html
  - https://docs.oracle.com/en/java/javase/25/
  - .windsurf/skills/spring-boot-4-conventions/SKILL.md
  - .windsurf/skills/spring-code-review-rubric/SKILL.md (section 10)
---

# Performance optimization (Spring Boot 4 / JVM)

## Rule zero: measure first

> No optimization without a profile or a benchmark. "It feels slow" is a hypothesis, not a finding.

If a PR labeled `perf` lands without a profile artifact, a JMH result, a flame graph, or a before/after metric screenshot, **request changes**. The artifact path goes in the PR description and in `05-implementation-log.md`.

Acceptable evidence:

- async-profiler flame graph (CPU or alloc) saved as SVG/HTML.
- JDK Flight Recorder recording (`.jfr`) with the relevant view captured.
- JMH benchmark output (`*.json` from `-rf json`) with before/after.
- Micrometer histogram screenshots (p50/p95/p99) from before and after.
- Database `EXPLAIN ANALYZE` output for the query in question.

## SLO-first, not optimization-first

Every perf claim is tied to a Service Level Objective. If there is no stated SLO, the work has no exit criterion — write the SLO first.

Template (the user fills the blanks; **never invent the numbers**):

```text
Endpoint:        <method> <path>
p50 latency:     ___ ms   (current: ___ ms)
p95 latency:     ___ ms   (current: ___ ms)
p99 latency:     ___ ms   (current: ___ ms)
Throughput:      ___ rps  (current: ___ rps)
Error budget:    ___ %    (window: ___ )
Measurement:     <Micrometer metric name + dashboard link>
```

If the user has not committed to numbers, file a `Q-NNN` in `01-spec.md` or `03-design.md` and halt.

## Tools

| Tool | Use for | Notes |
|---|---|---|
| **async-profiler** | CPU and allocation flame graphs in production-like envs | `-e cpu` or `-e alloc`; safe in prod (sample-based) |
| **JFR + JDK Mission Control** | Always-on low-overhead recording; method profiling, allocation, GC, locks | Enable with `-XX:StartFlightRecording=...` |
| **JMH** | Microbenchmarks for hot loops, parsers, serialization, comparator/equality changes | Fork ≥ 2, warmup ≥ 5, measure ≥ 5; run on the deploy hardware class |
| **Micrometer + Spring Boot Actuator** | Production metrics (latency histograms, throughput, error rate) | Use `Timer` with `publishPercentileHistogram(true)`; **not** `Counter` for latency |
| **`EXPLAIN ANALYZE`** | Real query plan with row counts, buffers, timing | PostgreSQL: `EXPLAIN (ANALYZE, BUFFERS, VERBOSE) ...` |
| **`-Djdk.tracePinnedThreads=full`** | Detect virtual-thread pinning during dev/test | Enable in test profiles when investigating throughput regressions |
| **`-Xlog:gc*`** or JFR GC events | GC pause analysis | Look at p99 pause, not average |

## Antipatterns to scan for in review

### Data access

- **N+1 queries.** Entity navigation inside a loop or stream. Fix with `JOIN FETCH`, an entity graph, or a projection DTO query.
- **Unbounded list endpoints.** Any `List<T>` return type from a controller without `Pageable`. Pagination is mandatory; reject the diff.
- **Long-held `@Transactional` on read paths.** A read transaction wrapping an HTTP boundary call holds a connection. Move I/O outside the transaction.
- **Eager fetch by default.** `FetchType.EAGER` on associations forces every query to load the world. Default to `LAZY`; load explicitly.
- **Derived queries with > 3 predicates.** Spring Data parses them at startup but they hide intent and rarely use the right index. Use `@Query`.
- **No `setFetchSize` on streaming reads.** A `Stream<T>` query that materializes all rows defeats streaming. Set fetch size; iterate; close.

### Connection pool (HikariCP)

Size the pool with the formula:

```text
connections = ((core_count * 2) + effective_spindle_count)
```

For most cloud Postgres instances on SSD, `effective_spindle_count = 1`. Start with `maximumPoolSize = (cores * 2) + 1`, **never** the Hikari default of 10 in production without measuring. Verify saturation with the Actuator `hikaricp` metrics (`active`, `pending`, `usage`).

### Virtual threads (Boot 4 default-on)

- **Pinning via `synchronized`.** `synchronized` blocks holding I/O pin the carrier thread. Replace with `ReentrantLock` for any block that does I/O. Detect with `-Djdk.tracePinnedThreads=full`.
- **Large `ThreadLocal` state.** Virtual threads are cheap, so there can be millions. A `ThreadLocal<byte[]>` of 1 MB × 1M threads = 1 TB. Use scoped values (`ScopedValue`) or pool the buffer explicitly.
- **Custom `Executor` that pins to platform threads.** Don't introduce one without an ADR and a measurement showing virtual threads were the bottleneck.

### Caching

- **Cache the result, not the input.** Key on the inputs that affect the result; do not cache by request object identity.
- **TTL is mandatory.** No infinite-TTL caches in production code. State the TTL in the bean configuration.
- **Stampede protection.** Use `Caffeine` with `refreshAfterWrite` plus `AsyncLoadingCache`, or a single-flight wrapper. Without it, a TTL expiry hits the origin N times concurrently.
- **Two-tier cache (local + Redis).** Local for hot keys (microseconds), Redis for shared invalidation. Never invalidate Redis without invalidating local — use a pub/sub or short local TTL.
- **Cache size cap.** Bound `maximumSize` (Caffeine) or `maxmemory` (Redis); without a cap a cache becomes a leak.

### Payload + serialization

- **Stream large responses.** Don't materialize a 100 MB list into memory; use `StreamingResponseBody` or `ResponseBodyEmitter`.
- **Compress text responses.** Enable `server.compression.enabled=true` for `application/json`, `text/*` over a threshold (e.g. 1 KiB).
- **Avoid round-tripping JSON through `Map<String, Object>`.** It allocates per-field; use records or a typed DTO.
- **Don't log full payloads at INFO.** Logs are an allocation hot path; large payloads at INFO will dominate p99.

### GC and heap

- **Pick the collector for the workload.** Throughput-bound batch: G1. Latency-bound services with > 4 GB heap: ZGC. Generational ZGC is the default to consider on Java 25.
- **Container memory awareness.** `-XX:+UseContainerSupport` is on by default; verify with `java -Xlog:os+container=trace`. Set `-XX:MaxRAMPercentage` rather than `-Xmx` so pod resizes are honored.
- **Allocation rate, not heap size, is the GC driver.** Use JFR `Allocation in new TLAB` to find the allocator. Common offenders: per-request `ObjectMapper`, `String.format` in hot paths, autoboxing in stream pipelines.

### HTTP clients

- **Connection pool sized for the dependency.** `RestClient`/`@HttpExchange` over Apache HttpClient: set `maxTotal` and `maxPerRoute`; don't accept defaults.
- **Timeouts on every external call.** Connect, read, write, and request timeouts. Spring Boot 4 defaults are bounded but verify per client.
- **Circuit breaker on flaky dependencies.** Resilience4j with bulkhead. The breaker's open-state timeout must be shorter than the upstream's timeout.

## Cross-cutting checks for `/review`

When reviewing a diff, ask:

1. Does any controller method return `List<T>` without pagination? → blocker.
2. Does any service iterate an entity collection and call a method that triggers a query? → likely N+1; major.
3. Did Hikari `maximumPoolSize` change without a measurement? → request the profile.
4. Did the diff add `synchronized` around an I/O call? → request switch to `ReentrantLock`.
5. Did the diff add a `@Cacheable` without a TTL or a size cap? → blocker.
6. Did the diff add an external HTTP call without explicit timeouts? → blocker.
7. Does any new metric use `Counter` for a duration? → request a `Timer` with histogram.

## Before/after evidence

Every perf change appends to `05-implementation-log.md`:

```text
### T-NNN — perf

Hypothesis: <what we expected to improve>
Before:     <metric snapshot or JMH result, with units>
Change:     <what was changed, in one paragraph>
After:      <same metric, same workload, with units>
Evidence:   <path/url to flame graph, JFR file, or JMH JSON>
SLO impact: <p95 latency moved from ___ ms to ___ ms, error budget unchanged>
```

A perf change without this block fails review.

## Anti-rationalizations

| Excuse | Counter |
|---|---|
| "I know this loop is hot, let me micro-optimize." | Profile first. The hot loop is rarely where the time goes. |
| "Caffeine has a sensible default, no need to tune." | Default `maximumSize = unbounded` is a leak. Set the cap. |
| "Virtual threads make pool sizing irrelevant." | DB connection pools are still bounded. Sizing matters more, not less, because virtual threads make starvation cheaper to hit. |
| "JMH is overkill for this small change." | A 100 ns regression × 10k rps = 1 second/sec wasted. JMH is the cheapest way to know. |
| "Pagination breaks the UI." | The UI breaks because the dataset grew. Pagination is the fix; coordinate with frontend. |
| "We'll add metrics later." | If it's not measured it's not optimized. Add the `Timer` in the same PR. |

## Verification (before claiming the work is done)

- [ ] An SLO is written down with concrete numbers.
- [ ] A measurement artifact (profile, JFR, JMH, dashboard screenshot, EXPLAIN) is linked.
- [ ] A before/after delta is recorded in `05-implementation-log.md`.
- [ ] The change is gated by a feature flag if it is risky or behavior-altering.
- [ ] No new antipattern from the lists above was introduced.
- [ ] The review rubric Section 10 (Performance) checks all pass.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.