Performance Testing with Apache JMeter

This section documents Spring Boot 4.1.x and Spring Framework 7.0.x on the Java 17/21+ baseline, as described by the official Spring Boot reference documentation and each sub-project’s own site (Spring Data, Spring for Apache Kafka, Micrometer, Project Reactor, springdoc-openapi, Spring gRPC, and the others listed in this section’s Bibliography) — which are the references these pages are written and verified against.

This content was generated with the assistance of AI and should be verified against those official docs before being relied on in production. Spring Boot and its ecosystem continue to evolve; the examples here target the current 4.1.x / 7.0.x releases.

This section’s bibliography lists the reference material consulted while preparing these pages.

Apache JMeter is the most common tool for load- and performance-testing a Spring Boot service’s HTTP (or JMS, JDBC, gRPC) endpoints from outside the JVM. This page covers building a test plan, parameterizing it for reusable scenarios, correlating its results with the application’s own runtime metrics, and running it as part of a Maven build.

Test plan structure

A JMeter test plan is an XML document (a .jmx file) with a tree of elements. The three building blocks used in almost every plan are:

  • A Thread Group — simulates a pool of concurrent users. It controls the number of threads (virtual users), the ramp-up period, and the loop count (or a duration-based scheduler).

  • One or more Samplers inside the thread group — each sampler sends one request (an HTTP Request sampler for a REST endpoint) and records its response time and status.

  • Assertions attached to a sampler — verify the response is correct (response code, JSON path value, response time under a threshold) so that a "fast but wrong" response still fails the test.

A minimal .jmx excerpt with these three elements:

<jmeterTestPlan version="1.2" properties="5.0">
  <hashTree>
    <TestPlan testname="Orders API load test" enabled="true"/>
    <hashTree>

      <ThreadGroup testname="Order lookups" enabled="true">
        <stringProp name="ThreadGroup.num_threads">${__P(threads,20)}</stringProp>
        <stringProp name="ThreadGroup.ramp_time">${__P(rampUp,10)}</stringProp>
        <elementProp name="ThreadGroup.main_controller" elementType="LoopController">
          <boolProp name="LoopController.continue_forever">false</boolProp>
          <stringProp name="LoopController.loops">${__P(loops,100)}</stringProp>
        </elementProp>
      </ThreadGroup>
      <hashTree>

        <HTTPSamplerProxy testname="GET /api/orders/{id}" enabled="true">
          <stringProp name="HTTPSampler.domain">${__P(host,localhost)}</stringProp>
          <stringProp name="HTTPSampler.port">${__P(port,8080)}</stringProp>
          <stringProp name="HTTPSampler.path">/api/orders/1042</stringProp>
          <stringProp name="HTTPSampler.method">GET</stringProp>
        </HTTPSamplerProxy>
        <hashTree>

          <ResponseAssertion testname="Status is 200">
            <collectionProp name="Asserion.test_strings">
              <stringProp name="49586">200</stringProp>
            </collectionProp>
            <stringProp name="Assertion.test_field">Assertion.response_code</stringProp>
          </ResponseAssertion>

          <DurationAssertion testname="Responds under 300 ms">
            <stringProp name="DurationAssertion.duration">300</stringProp>
          </DurationAssertion>

        </hashTree>
      </hashTree>
    </hashTree>
  </hashTree>
</jmeterTestPlan>

Hand-editing .jmx XML is uncommon in practice — the JMeter GUI (or the newer element tree in JMeter 5.6+) generates it, and the file is then checked into version control and run headlessly (jmeter -n -t plan.jmx) in CI. See the Apache JMeter User’s Manual for the full element reference and the GUI-based authoring workflow.

Thread group sizing

The thread group’s three numbers map directly onto the load profile being simulated:

Threads (users)  = concurrent virtual users hitting the endpoint at the same time
Ramp-up period   = seconds JMeter takes to start all threads (avoids a "big bang" spike)
Loop count       = requests each thread sends before finishing (or use a Duration
                   scheduler with "Specify Thread lifetime" for a fixed-time soak test)

Example: 50 threads, 10 s ramp-up, 20 loops
  -> steady state reached after ~10 s, ~1000 total requests, roughly 5 req/s per thread

Common samplers and assertions

Samplers most relevant to a Spring Boot backend:

HTTP Request        -- REST/JSON endpoints (GET/POST/PUT/DELETE), the default choice
JDBC Request        -- exercises a data-access layer or a connection pool directly
JSR223 Sampler       -- custom Groovy/Java logic (e.g. building a signed request)

Assertions commonly paired with them:

Response Assertion   -- checks response code, headers, or body text/regex
JSON Assertion       -- validates a JSON path exists / matches a value
Duration Assertion   -- fails the sample if it exceeds a response-time threshold
Size Assertion       -- fails the sample if the response body size is out of range

Parameterizing plans with JMeter properties

Hard-coding a host, thread count, or loop count into the .jmx file means editing XML every time the load profile changes. JMeter properties, referenced with the \${__P(name,default)} function, let a plan be driven from the command line (or from Maven, see below) instead, so the same .jmx file becomes a reusable scenario template:

# Inside the .jmx, every tunable value is a property reference:
#   Threads   : ${__P(threads,20)}
#   Ramp-up   : ${__P(rampUp,10)}
#   Loops     : ${__P(loops,100)}
#   Host      : ${__P(host,localhost)}
#   Port      : ${__P(port,8080)}

# Running the same plan as three different profiles from the CLI:

# smoke test -- a handful of users, quick sanity check
jmeter -n -t orders-plan.jmx -Jthreads=5 -JrampUp=5 -Jloops=10 \
  -Jhost=localhost -Jport=8080 -l smoke-results.jtl

# soak test -- moderate load sustained for longer
jmeter -n -t orders-plan.jmx -Jthreads=50 -JrampUp=30 -Jloops=500 \
  -Jhost=staging.example.internal -Jport=8080 -l soak-results.jtl

# stress test -- push toward the expected capacity limit
jmeter -n -t orders-plan.jmx -Jthreads=300 -JrampUp=60 -Jloops=1000 \
  -Jhost=staging.example.internal -Jport=8080 -l stress-results.jtl

Each -J<name>=<value> flag overrides the corresponding \${__P(name,default)} reference; a property not passed on the command line falls back to its default. A .properties file (-q profile.properties) can hold a whole named profile instead of a long flag list, which keeps "smoke" / "soak" / "stress" profiles as reviewable, version-controlled files alongside the .jmx plan itself.

Correlating results with application metrics

A JMeter summary report shows response-time percentiles (p50/p90/p99) and throughput (requests/second) as seen from the client side, but it says nothing about why latency degraded under load. The missing half of the picture is the application’s own resource usage during the same time window — CPU, heap, GC pauses, thread-pool saturation, and connection-pool usage — which the Metrics and Observability page covers in depth via Actuator and Micrometer/Prometheus. That page is the reference for setting up /actuator/prometheus and building the dashboards; here the concern is simply aligning the two timelines:

Correlation workflow:

1. Start the JMeter run and note its start/end timestamps (JMeter's own log, or the
   .jtl result file's first/last "timeStamp" column, already in epoch millis).

2. Over that same time window, pull from Prometheus (scraping the app's
   /actuator/prometheus endpoint):
     - process_cpu_usage           -> CPU as a fraction of available cores
     - jvm_memory_used_bytes       -> heap/non-heap usage, watch for a sawtooth
                                       that never returns to baseline (a leak)
     - jvm_gc_pause_seconds        -> GC pause frequency/duration
     - tomcat_threads_busy_threads -> whether the web server thread pool saturated
     - hikaricp_connections_active -> whether the DB connection pool saturated

3. Overlay both series on a shared time axis (a Grafana dashboard with the JMeter
   run's start time as an annotation, or a spreadsheet plotting p99 latency next
   to CPU %). A latency cliff that lines up with CPU pinned near 100%, or with a
   connection-pool exhausted event, points at the actual bottleneck instead of a
   guess.

Example reading:
  09:14:00-09:14:30  threads=50   p99=180ms  cpu=45%   pool_active=12/20
  09:14:30-09:15:00  threads=200  p99=210ms  cpu=78%   pool_active=20/20  <- pool saturated
  09:15:00-09:15:30  threads=300  p99=940ms  cpu=95%   pool_active=20/20  <- CPU-bound too

Reading it this way turns "the service got slow above 200 users" into an actionable finding: the connection pool exhausted first, so raising spring.datasource.hikari.maximum-pool-size (within what the database can sustain) is the first lever to try before assuming more CPU is needed.

Running plans from Maven with jmeter-maven-plugin

Rather than invoking the jmeter CLI by hand, jmeter-maven-plugin runs .jmx plans as part of a Maven build — useful for a dedicated perf-test module executed in CI on a schedule, or on demand before a release:

<build>
  <plugins>
    <plugin>
      <groupId>com.lazerycode.jmeter</groupId>
      <artifactId>jmeter-maven-plugin</artifactId>
      <version>3.8.0</version>
      <executions>
        <execution>
          <id>run-load-tests</id>
          <phase>integration-test</phase>
          <goals>
            <goal>jmeter</goal>
          </goals>
        </execution>
        <execution>
          <id>check-results</id>
          <phase>verify</phase>
          <goals>
            <goal>fail-build-on-error</goal>
          </goals>
        </execution>
      </executions>
      <configuration>
        <testFilesDirectory>${project.basedir}/src/test/jmeter</testFilesDirectory>
        <resultsDirectory>${project.build.directory}/jmeter/results</resultsDirectory>
        <propertiesUser>
          <threads>${jmeter.threads}</threads>
          <rampUp>${jmeter.rampUp}</rampUp>
          <loops>${jmeter.loops}</loops>
          <host>${jmeter.host}</host>
          <port>${jmeter.port}</port>
        </propertiesUser>
      </configuration>
    </plugin>
  </plugins>
</build>

Running it:

<!-- default profile, values from the pom's <properties> section -->
<!-- mvn verify -->

<!-- override at the command line for a specific scenario -->
<!-- mvn verify -Djmeter.threads=300 -Djmeter.rampUp=60 -Djmeter.loops=1000 -->

propertiesUser maps each Maven property straight onto the plan’s \${__P(name,default)} references, so the same profile flags used with the bare jmeter CLI carry over unchanged to a Maven-driven run. Binding the jmeter goal to integration-test and a result check to verify means mvn verify both executes the load test and fails the build if error thresholds configured on the plugin are exceeded, which is what makes it usable as a CI gate rather than only a manual tool. See the Apache JMeter User’s Manual for the underlying CLI flags and result-file (.jtl) format that the plugin wraps.