This blog has a lot of posts about making Spring Boot code correct and very few about finding out how much traffic it survives. That is a real gap: a service that passes every test can still fall over at the first busy morning, and the failure rarely looks like a crash. It looks like latency creeping from 50 ms to 700 ms and then a wall of 503s.
This post builds a tiny Spring Boot 4 app that has exactly that failure on purpose — a dependency that can only do about 160 requests per second — and then uses Gatling’s Java DSL to find the point where it breaks (the knee), fail a build when the numbers are bad, and read the report. At the end the same test is written for k6, run against the same app, and compared. If you came looking for gatling java spring boot setup that actually runs, every file is in the companion module and every number below came out of a real run.
Versions this was run against, taken frommaven-metadata.xmland the tools themselves: JDK 25.0.5 (Temurin), Spring Boot 4.1.2 (latest GA; published to Maven Central on 22 October 2026, and 4.2.0-RC2 is only a release candidate), Gatling 3.16.0 (released 2026-09-29, the date printed in its own report) withgatling-maven-plugin4.21.12, and k6 v2.3.0. Everything ran on one 2-vCPU sandbox VM with the app and the load generator sharing it, so every latency and rate below is indicative, not a benchmark.
All code lives in the new gatling-spring-boot module of java-testing-lab, next to the JUnit 6, AssertJ and Mockito testing post modules. scripts/run-all.sh regenerates every transcript quoted here.
A load test answers one question: where does it stop being fast?
A unit test says whether code is right. A load test says how much of it you can run at once before it stops being right enough — slow, or refusing work. The shape of that answer is almost always the same: latency stays flat while the system has spare capacity, then bends sharply upward at one load level. That bend is the knee, and finding it is the job.
The lab app makes the knee easy to see because its limit is a number you can calculate. A request is handed to a pool of 8 workers that each take about 50 ms (a stand-in for a slow downstream service or a connection-limited database). Up to 100 more requests wait in a queue; beyond that the app answers 503 straight away. The diagram below is the whole mechanism, and its three numbers are the argument for the rest of the post.
Capacity is 160 requests per second. Below it the queue stays empty and latency is the 50 ms of the work itself. Above it the queue fills, and once it is full the most any request can be in the system is 108 (8 being served plus 100 waiting). Divide by the rate the workers drain them, 160 per second, and you get 0.675 s: a latency ceiling near 675 ms that the measurements below land on. When a plateau in your own results is a round number like that, look for a queue of that size.
The app under test: one endpoint with a deliberate limit
The whole app is three small classes. DownstreamPool is the bounded pool; it is the only interesting one. The source is at src/main/java/lab/gatling/DownstreamPool.java.
package lab.gatling;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
/**
* A stand-in for a slow dependency with limited capacity (a legacy pricing service, a
* connection-limited database). {@code workers} requests are served at once, each taking
* about {@code delayMs}; up to {@code queueSize} more wait; everything beyond is rejected.
*
* Capacity is therefore workers / delay: 8 / 0.050 s = 160 requests per second.
* Below that the service answers in ~50 ms. Above it the queue fills, latency climbs, and
* once the queue is full requests are rejected. That is the knee the load test looks for.
*/
@Component
public class DownstreamPool {
private final ThreadPoolExecutor executor;
private final int delayMs;
private final AtomicLong rejected = new AtomicLong();
public DownstreamPool(@Value("${lab.downstream.workers:8}") int workers,
@Value("${lab.downstream.queue:100}") int queueSize,
@Value("${lab.downstream.delay-ms:50}") int delayMs) {
this.delayMs = delayMs;
this.executor = new ThreadPoolExecutor(workers, workers, 0, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(queueSize), new ThreadPoolExecutor.AbortPolicy());
}
/** Runs the lookup on the bounded pool and waits for it. Throws {@link Saturated} when full. */
public double price(String symbol) throws InterruptedException {
Callable<Double> lookup = () -> {
// 0.9x - 1.1x of the nominal delay, derived from the symbol so runs are repeatable
long ms = delayMs - delayMs / 10 + Math.floorMod(symbol.hashCode(), Math.max(1, delayMs / 5 + 1));
Thread.sleep(ms);
return 10 + Math.floorMod(symbol.hashCode(), 49000) / 100.0;
};
try {
return executor.submit(lookup).get();
} catch (RejectedExecutionException e) {
rejected.incrementAndGet();
throw new Saturated();
} catch (ExecutionException e) {
throw new IllegalStateException(e.getCause());
}
}
public Stats stats() {
return new Stats(executor.getActiveCount(), executor.getQueue().size(),
executor.getCompletedTaskCount(), rejected.get());
}
public record Stats(int active, int queued, long completed, long rejected) { }
public static class Saturated extends RuntimeException { }
}
The controller (src/main/java/lab/gatling/QuoteController.java) exposes GET /api/quote/{symbol}, maps the saturation exception to 503 with a Retry-After header, and has an /api/stats diagnostic endpoint. Delete that one before shipping anything like it. Tomcat itself is untouched, so its 200 request threads are not the limit — the pool is.
Before involving any load tool, a plain JUnit test (src/test/java/lab/gatling/SaturationTest.java) proves the limit exists: a pool of 2 workers and a queue of 3 admits five simultaneous callers and rejects the other two. Its transcript is in output/01-saturation-test.txt.
workers=2 queue=3 delay=400ms, 7 simultaneous callers
served=5 rejected=2
pool stats: completed=5 rejected=2
And one real request against the running app, from output/02-single-request.txt:
{"symbol":"ORCL","price":344.44}
Your first Gatling simulation: a scenario, a feeder and checks
Gatling describes a test in three layers. A scenario is what one virtual user does (look up a quote, think for a moment, look up another). A feeder supplies each user with different data, so you do not hammer one cache entry. An injection profile says how many users to start and when. A check decides whether a response counts as a success. The simulation class glues them together in a setUp block. The file is src/test/java/lab/gatling/BasicSimulation.java.
package lab.gatling;
import static io.gatling.javaapi.core.CoreDsl.*;
import static io.gatling.javaapi.http.HttpDsl.*;
import io.gatling.javaapi.core.FeederBuilder;
import io.gatling.javaapi.core.ScenarioBuilder;
import io.gatling.javaapi.core.Simulation;
import java.time.Duration;
/**
* A scenario with a feeder, checks and a mix of injection steps.
* Every user looks up two random symbols with a short think time in between.
*/
public class BasicSimulation extends Simulation {
FeederBuilder<String> symbols = csv("data/symbols.csv").random();
ScenarioBuilder browse = scenario("Browse quotes")
.feed(symbols)
.exec(http("get quote")
.get("/api/quote/#{symbol}")
.check(
status().is(200),
jmesPath("symbol").isEL("#{symbol}"),
jmesPath("price").ofDouble().gt(0.0)))
.pause(Duration.ofMillis(200), Duration.ofMillis(500))
.feed(symbols)
.exec(http("get another quote")
.get("/api/quote/#{symbol}")
.check(status().is(200)));
{
setUp(browse.injectOpen(
atOnceUsers(5),
nothingFor(Duration.ofSeconds(2)),
rampUsers(20).during(Duration.ofSeconds(5)),
constantUsersPerSec(10).during(Duration.ofSeconds(10))))
.protocols(Config.protocol())
.assertions(
global().responseTime().percentile(95).lt(200),
global().successfulRequests().percent().gt(99.0));
}
}
Three details earn their keep. The feeder is a CSV with a symbol column (src/test/resources/data/symbols.csv), read with .random() so users pick symbols in no fixed order. The checks do more than test the status: jmesPath("symbol").isEL("#{symbol}") asserts the response echoes the symbol the user asked for, which catches a server answering with someone else’s data under load. And the injection steps are chained: 5 users at once, a 2 s pause, 20 users ramped over 5 s, then 10 new users per second for 10 s.
Run it with the Maven plugin (the app must already be listening; scripts/run-all.sh does that for you):
mvn test-compile gatling:test -Dgatling.simulationClass=lab.gatling.BasicSimulation
The end of the console output, from output/03-basic-console.txt, is the summary Gatling always prints. At this gentle load, all 250 requests succeed and the 95th percentile is 62 ms, and the two assertions at the bottom report true.
---- Global Information -------------------------------------------------------------|---Total---|-----OK----|----KO----
> request count | 250 | 250 | -
> min response time (ms) | 48 | 48 | -
> max response time (ms) | 161 | 161 | -
> mean response time (ms) | 57 | 57 | -
> response time std deviation (ms) | 8 | 8 | -
> response time 50th percentile (ms) | 56 | 56 | -
> response time 75th percentile (ms) | 58 | 58 | -
> response time 95th percentile (ms) | 62 | 62 | -
> response time 99th percentile (ms) | 87 | 87 | -
> mean throughput (rps) | 13.89 | 13.89 | -
Global: 95th percentile of response time is less than 200.0 : true (actual : 62.0)
A passing smoke test proves little. This run tells you the endpoint works and the checks are wired correctly, nothing about capacity. 10 users per second is about 6% of what the app can do. The interesting results only start when the rate approaches 160, which is why the rest of the post is about injection profiles.
pom.xml: the Gatling-specific parts (and one logging trap)
The module uses spring-boot-starter-parent 4.1.2, the web starter, and two Gatling pieces: the gatling-charts-highcharts dependency (test scope) and gatling-maven-plugin. Simulations sit in src/test/java, where the plugin looks by default, and are not picked up by Surefire because their names do not end in Test. Full file: pom.xml.
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.gatling.highcharts</groupId>
<artifactId>gatling-charts-highcharts</artifactId>
<version>${gatling.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<!-- Gatling simulations live in src/test/java. Pick one with -Dgatling.simulationClass=... -->
<plugin>
<groupId>io.gatling</groupId>
<artifactId>gatling-maven-plugin</artifactId>
<version>${gatling-maven-plugin.version}</version>
</plugin>
</plugins>
</build>
The logging trap: Gatling logs through logback, and Spring Boot’s logback integration is also on the test classpath. Without a logback-test.xml (src/test/resources/logback-test.xml) every simulation started with a screenful of logback status lines. The file silences them. A second small API note that cost a compile cycle while writing this: in the Java DSL, Simulation.before() takes no arguments — you override the method rather than passing a lambda.
Feeder strategies and the full list of injection steps
A feeder file is read once and handed out by a strategy. queue() (the default) uses each line once and stops the run when it runs out, circular() loops, random() picks any line each time, and shuffle() randomises then walks through. Use random() or circular() unless every record must be used exactly once (login accounts, for example).
| Model | Step | What it does |
|---|---|---|
| open | nothingFor(d) | pause injection |
| open | atOnceUsers(n) | n users immediately |
| open | rampUsers(n).during(d) | n users spread evenly over d |
| open | constantUsersPerSec(r).during(d) | r new users per second for d |
| open | rampUsersPerSec(a).to(b).during(d) | arrival rate rises from a to b |
| open | incrementUsersPerSec(x)… | a staircase of rates (used below) |
| open | stressPeakUsers(n).during(d) | a smooth burst peaking at n users |
| closed | constantConcurrentUsers(n).during(d) | keep n users active |
| closed | rampConcurrentUsers(a).to(b).during(d) | active users rise from a to b |
| closed | incrementConcurrentUsers(x)… | staircase of concurrency |
Step names are from the Gatling injection reference; the ones this post runs are all in the companion module.
Open versus closed: the choice that decides whether you see the overload
This is the section most Gatling tutorials rush and the one that most often produces a misleading result. An open model decides when users arrive, like customers walking into a shop, and does not care whether the previous customers have been served. A closed model has a fixed crowd inside, and a user only comes back for another round when the last one finished. Real public traffic is closer to open; a fixed pool of workers or a batch job is closed.
The diagram’s point is the bottom row. In a closed test a slow server slows the test down: users spend longer waiting, so fewer requests arrive per second. The overload throttles itself and the test cannot see it. Here is that on the lab app. src/test/java/lab/gatling/ClosedModelSimulation.java keeps 50 users in a loop for 15 s, and the result, from output/07-closed-model-console.txt, is 2,506 requests, zero failures, a median of 314 ms and a 95th percentile of 329 ms, at 147.41 requests per second — just under the 160 the app can really do.
> request count | 2,506 | 2,506 | -
> response time 50th percentile (ms) | 314 | 314 | -
> response time 95th percentile (ms) | 329 | 329 | -
> mean throughput (rps) | 147.41 | 147.41 | -
> KO 0 (0%)
That reads as a healthy service with a slightly slow response. The next section shows the same app collapsing under an open model, with roughly a quarter to a third of requests refused at the top steps. Neither test is wrong; they answer different questions. The closed one tells you how the system behaves with 50 clients that wait politely. It cannot tell you what happens when the 51st customer arrives regardless.
This is coordinated omission, in one run. When the load generator waits for slow responses before sending more requests, the slow period is under-sampled: the requests that would have been sent during it are never sent, so they never get measured. Grafana’s k6 documentation describes exactly this for closed models (“slower responses lengthen iterations and lower the arrival rate”) and notes that the literature calls it coordinated omission. The practical rule: use an open model (injectOpen, or k6’s arrival-rate executors) whenever the question is “what happens at N requests per second”, and a closed one only when the real system really is a fixed number of clients.
Finding the knee with incrementUsersPerSec
To find where a system bends you need to step the load up and look at each step on its own. Gatling has a helper for exactly that: incrementUsersPerSec builds a staircase of arrival rates, each held for a while, optionally separated by short ramps. Here it runs eight levels from 30 to 240 users per second, each held 6 s with 2 s ramps between. Every user sends one request, so the arrival rate is the request rate, which keeps the arithmetic honest.
There is a catch that costs a lot of people a first attempt. Gatling’s report aggregates the whole run, so the 62 seconds of a staircase blend into one set of percentiles and the knee disappears. For this run the global figures are meaningless as a diagnosis: 8,370 requests, a median of 57 ms, a 95th percentile of 688 ms, a mean of 293 ms. None of those describes any single step. The fix is to name each request after the step it was sent in, so the report gets one row per step. The class is src/test/java/lab/gatling/KneeStepsSimulation.java.
package lab.gatling;
import static io.gatling.javaapi.core.CoreDsl.*;
import static io.gatling.javaapi.http.HttpDsl.*;
import io.gatling.javaapi.core.ScenarioBuilder;
import io.gatling.javaapi.core.Simulation;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicLong;
/**
* Finds the knee. OPEN model: one request per arriving user, so the arrival rate IS the request rate.
* Rates 30, 60, ... 240 users/s, each held 6 s, separated by 2 s ramps.
*
* Gatling's open-source report aggregates everything into one table, which would hide the knee.
* So each request is NAMED after the step it was sent in ("rate 150/s"); the report then has one
* row per step. Requests sent during a ramp between two steps are named "ramp".
*/
public class KneeStepsSimulation extends Simulation {
static final int STEPS = 8, RATE_STEP = 30, HOLD_S = 6, RAMP_S = 2;
// Set by the first user to arrive; every later user derives its step from the time elapsed since then.
static final AtomicLong startedAt = new AtomicLong();
static String stepLabel() {
long now = System.currentTimeMillis();
startedAt.compareAndSet(0, now);
long elapsedMs = now - startedAt.get();
long slot = Math.max(0, elapsedMs) / ((HOLD_S + RAMP_S) * 1000L);
long into = Math.max(0, elapsedMs) % ((HOLD_S + RAMP_S) * 1000L);
if (into >= HOLD_S * 1000L || slot >= STEPS) {
return "ramp";
}
return String.format("rate %03d/s", RATE_STEP * (slot + 1));
}
ScenarioBuilder oneRequest = scenario("One quote per user")
.feed(csv("data/symbols.csv").random())
.exec(session -> session.set("step", stepLabel()))
.exec(http("quote #{step}").get("/api/quote/#{symbol}").check(status().is(200)));
{
setUp(oneRequest.injectOpen(
incrementUsersPerSec(RATE_STEP)
.times(STEPS)
.eachLevelLasting(Duration.ofSeconds(HOLD_S))
.separatedByRampsLasting(Duration.ofSeconds(RAMP_S))
.startingFrom(RATE_STEP)))
.protocols(Config.protocol());
}
}
The first user to arrive records the start time; every later user works out which step it belongs to from the elapsed time, stores it in the session, and the request name is built from it with Gatling’s #{step} expression. Requests sent during a ramp between steps are called ramp and ignored when reading the result.
Here are the per-step results, read out of the report’s index.html by scripts/analyze.py (more on why below). The file is output/10-gatling-steps-table.txt; all times are milliseconds and ko counts responses that failed the 200 check.
step requests ok ko ko % p50 p95 p99 max (times in ms)
rate 030/s 182 182 0 0.0 55 60 69 82
rate 060/s 360 360 0 0.0 53 58 62 66
rate 090/s 540 540 0 0.0 52 57 59 61
rate 120/s 720 720 0 0.0 51 57 58 61
rate 150/s 900 900 0 0.0 52 57 60 64
rate 180/s 1080 1029 51 4.7 543 685 689 692
rate 210/s 1260 946 314 24.9 676 693 699 711
rate 240/s 1429 943 486 34.0 672 689 695 702
ramp 1899 1688 211 11.1 56 690 699 708
Read it from the top. From 30 to 150 users per second nothing changes: the median stays near 52 ms and the 95th percentile near 57–60 ms, no failures. At 150 the 95th percentile is 57 ms; one step later, at 180, it is 685 ms and 51 requests (4.72%) have been rejected. By 240 users per second 34.01% of requests fail and the 95th percentile has flattened at 689 ms. That flattening is the 108-slot queue from the first diagram. The knee sits between 150 and 180, which is where 8 / 0.050 s = 160 said it would be.
The chart plots the same table: Gatling’s median and 95th percentile against the injected rate on top, the share of 503s underneath, with the calculated capacity as the dashed line. The amber series is the k6 run covered further down; ignore it for now, and note that it traces the Gatling line almost exactly.
What the chart says in one sentence: latency is flat, then it is not, and the point where it stops being flat is the number to put in a capacity plan, minus a safety margin. Below the knee there is nothing to optimise; above it the extra traffic is not served, it is just refused or queued. The chart is generated by scripts/analyze.py from output/knee-gatling.json, so it is the data and not a drawing.
A second profile asks the same question continuously instead of in steps. src/test/java/lab/gatling/KneeRampSimulation.java ramps from 10 to 250 users per second over 40 s with rampUsersPerSec(10).to(250). That run sent 5,200 requests, 604 of them rejected (output/06-knee-ramp-console.txt). A ramp is quicker to run and shows roughly where things break; steps are what let you read a latency at a known rate, so use the ramp to find the neighbourhood and steps to measure it.
Reproducibility has limits. Run the staircase twice and the knee does not move, but the rows around it do: the 150/s row sits right at 94% utilisation (150 × 0.05 / 8), where a few noisy milliseconds decide whether the queue forms. An earlier run on this machine while other jobs were competing for its two cores showed latency lifting one step earlier. Treat the step where it bends as accurate to one step, and re-run before you quote a number.
Going deeper: why steps, ramps and holds each have a job
A hold at each level matters because queues need time to fill. A 6 s hold at 180/s is long enough here only because the queue is short (108 slots drains in under a second); a system with a 30 s timeout and a deep queue needs much longer holds, or you will measure the first seconds of an overload and call it fine. The 2 s ramps are there to avoid a vertical jump that would make each step start with a burst.
For a real service, run the staircase against a deployment with the same CPU limits and connection pools as production (see the Kubernetes probes and CPU limits post for how limits change behaviour) and keep the load generator on a different machine. Here they shared a 2-vCPU VM, which is fine for a sleep-based app and wrong for a CPU-bound one.
- Pair this with Micrometer metrics on the server so the client’s view and the server’s view can be compared.
- If the knee is a thread-pool limit like this one, the virtual threads benchmarks show what changes and what does not.
- If you would rather refuse excess traffic deliberately than let it queue, see rate limiting with Bucket4j and Resilience4j.
Assertions: make the build fail when the numbers are bad
A report nobody reads does not protect anything. Gatling’s global assertions turn thresholds into a pass/fail result, and the Maven plugin turns a failure into a failed build. src/test/java/lab/gatling/FailingAssertionSimulation.java drives 220 users per second, above the 160 capacity, while asserting a 95th percentile under 200 ms, at least 99% success, and a maximum under 1000 ms for the get quote request.
public class FailingAssertionSimulation extends Simulation {
ScenarioBuilder oneRequest = scenario("One quote per user")
.feed(csv("data/symbols.csv").random())
.exec(http("get quote").get("/api/quote/#{symbol}").check(status().is(200)));
{
setUp(oneRequest.injectOpen(constantUsersPerSec(220).during(Duration.ofSeconds(15))))
.protocols(Config.protocol())
.assertions(
global().responseTime().percentile(95).lt(200),
global().successfulRequests().percent().gt(99.0),
details("get quote").responseTime().max().lt(1000));
}
}
It is meant to fail, and it does. From output/05-failing-assertion-console.txt — each assertion is printed with true or false and the measured value, then Maven reports the build failure, and the exit code in output/05b-failing-assertion-exit.txt is non-zero, which is what makes a CI step go red:
Global: 95th percentile of response time is less than 200.0 : false (actual : 690.0)
get quote: max of response time is less than 1000.0 : true (actual : 706.0)
[INFO] BUILD FAILURE
[ERROR] Failed to execute goal io.gatling:gatling-maven-plugin:4.21.12:test (default-cli) on project gatling-spring-boot-lab: Gatling simulation assertions failed ! ForkException -> [Help 1]
maven exit code: 1
Two of the three assertions fail; the third (max < 1000) passes because the rejected requests come back fast and the slowest accepted one is bounded by the queue. The lesson is in what that implies: a maximum-latency assertion alone would have given a green build on an app that refused a quarter of its traffic. Assert on success rate and a percentile together.
Set thresholds from the knee, not from hope. The 200 ms and 99% above are round numbers a person might write in a review. A better source is the staircase: pick a rate comfortably below the knee, and assert what that step actually delivered, with a margin. Thresholds that are always green are decoration; thresholds that flake are ignored. Neither protects anyone.
Where assertions can attach, and a gotcha with fast failures
Assertions attach to global(), to forAll(), or to a single request with details("get quote"), and can test response time (min, max, mean, percentiles), success percentage, failed request counts, and throughput. The failing run has a trap worth knowing: its rejected requests are cheap. In output/05-failing-assertion-console.txt the mean response time is 641 ms for successful requests but 1 ms for the 836 failed ones, so the all-requests mean of 479 ms looks better than what any successful user experienced. Always read the OK column separately from the total.
The default Gatling check treats a response as a success only for 2xx or 304 status, so a 503 would already be a KO without any explicit check; the explicit status().is(200) in this module just makes the intent obvious.
Reading the Gatling report (and what 3.16 does not give you)
Every run writes a directory under target/gatling/<simulation>-<timestamp>/ containing index.html, one req_*.html page per request name, a js/ folder and a binary simulation.log. Opening index.html gives the run information (its header shows Gatling 3.16.0 and the release date 2026-09-29), the assertion results, a statistics table with count, failures, requests per second and min/p50/p75/p95/p99/max/mean/stddev for each request, plus charts for response-time ranges, requests and users per second, concurrent users and response-time distribution; each request page adds one more, response time against global throughput.
This is the part I had to find out by looking. I expected a stats.json to parse. The 3.16.0 report I generated has none, and simulation.log is binary, so there is no ready-made per-second percentile series either. The numbers in the table above therefore come from the statistics table inside index.html, which scripts/analyze.py reads with a regular expression over the rows (columns 2 to 14). That is why the per-step request naming trick matters: it is the only way to get per-step percentiles out of this report.
For the whole staircase, the console summary from output/04-knee-steps-console.txt shows how the aggregate hides the story:
---- Global Information -------------------------------------------------------------|---Total---|-----OK----|----KO----
> request count | 8,370 | 7,308 | 1,062
> min response time (ms) | 0 | 46 | 0
> max response time (ms) | 711 | 711 | 27
> mean response time (ms) | 293 | 335 | 1
> response time std deviation (ms) | 301 | 300 | 1
> response time 50th percentile (ms) | 57 | 105 | 1
> response time 75th percentile (ms) | 674 | 676 | 1
> response time 95th percentile (ms) | 688 | 689 | 3
> response time 99th percentile (ms) | 695 | 696 | 5
> mean throughput (rps) | 132.86 | 116 | 16.86
> status.find.is(200), found 503 27 (100%)
Read the columns in the order the report gives them: total, OK, KO. The total median near 57 ms is a blend of many fast 503s with the slow, queued successes, which is why it matches neither group. Use the table by step, and open index.html for the visual summary.
Do not committarget/gatling. The report directory contains a binary log and vendored JavaScript. Keeptarget/in.gitignore, archive the HTML as a CI artifact if you want it, and commit the small derived tables instead, which is what theoutput/folder here does.
How analyze.py works, and why it avoids the binary log
scripts/analyze.py has three subcommands: gatling <report-dir>, k6 <json> and chart. The Gatling one finds each <tr id="req_..." data-parent="ROOT"> row, takes the request name from the ellipsed-name span and the numeric cells by their col-N class. It uses only the standard library, so there is nothing to install. The chart subcommand draws the SVG by hand from the two JSON files it wrote, using numeric XML entities only so it can be embedded in a WordPress post.
Parsing a binary log written by a tool whose format is not documented as an interface is a good way to break on the next minor version. Parsing the HTML table is a little ugly but breaks loudly. This post only covers what the report written by the open-source Gatling 3.16.0 gave me; I did not try Gatling Enterprise.
The same test in k6
k6 is the other tool most teams weigh against Gatling. I installed it by downloading the k6-v2.3.0 package from dl.k6.io and unpacking it (GitHub releases were not reachable from this sandbox), so the numbers here come from a real run of the same app on the same machine. The script is k6/knee.js and it reproduces the Gatling staircase exactly: same 30 to 240 rates, 6 s holds, 2 s ramps, same CSV feeder, same step tagging.
const stages = [];
for (let i = 0; i < STEPS; i++) {
if (i > 0) stages.push({ duration: `${RAMP_S}s`, target: RATE_STEP * (i + 1) });
stages.push({ duration: `${HOLD_S}s`, target: RATE_STEP * (i + 1) });
}
export const options = {
scenarios: {
knee: {
executor: 'ramping-arrival-rate', // open model: iterations start on a schedule
startRate: RATE_STEP,
timeUnit: '1s',
preAllocatedVUs: 200,
maxVUs: 1000,
stages,
},
},
};
function stepLabel() {
const elapsed = Date.now() - exec.scenario.startTime;
const slot = Math.floor(elapsed / ((HOLD_S + RAMP_S) * 1000));
const into = elapsed % ((HOLD_S + RAMP_S) * 1000);
if (into >= HOLD_S * 1000 || slot >= STEPS) return 'ramp';
return `rate ${String(RATE_STEP * (slot + 1)).padStart(3, '0')}/s`;
}
export default function () {
const symbol = symbols[Math.floor(Math.random() * symbols.length)];
const res = http.get(`${BASE}/api/quote/${symbol}`, { tags: { step: stepLabel() } });
check(res, { 'status is 200': (r) => r.status === 200 });
}
The structure maps one-to-one. ramping-arrival-rate is k6’s open-model executor, stages is the staircase, SharedArray plus Math.random() is the feeder, check() is the check, and the step tag on each request plays the role of Gatling’s per-step request name. The http.setResponseCallback(http.expectedStatuses(200)) line makes a 503 count as a failed request, the equivalent of Gatling treating it as KO. The full script is in the accordion further down.
Run, the k6 summary is shorter than Gatling’s. From output/22-k6-knee-console.txt:
http_req_duration..............: avg=289.82ms min=334.47µs med=55.3ms max=705.87ms p(90)=684.17ms p(95)=688.07ms
http_req_failed................: 12.63% 1057 out of 8364
http_reqs......................: 8364 133.443081/s
k6’s text summary has no per-step breakdown, so I used its --out json feed and the same analyze.py to build the equivalent table, in output/21-k6-steps-table.txt:
step requests ok ko ko % p50 p95 p99 max (times in ms)
rate 030/s 179 179 0 0.0 51 56 57 59
rate 060/s 360 360 0 0.0 51 56 56 57
rate 090/s 540 540 0 0.0 51 56 56 64
rate 120/s 720 720 0 0.0 51 56 56 58
rate 150/s 900 900 0 0.0 52 56 58 60
rate 180/s 1080 1037 43 4.0 506 682 688 693
rate 210/s 1260 945 315 25.0 675 691 698 702
rate 240/s 1440 948 492 34.2 674 693 698 706
ramp 1885 1678 207 11.0 55 687 694 701
Put next to the Gatling table, they say the same thing: flat until 150 (56 ms 95th percentile for k6, 57 ms for Gatling), bending at 180 (682 ms against 685 ms, 4.0% against 4.72% rejected), saturated at 693 ms and 34.2% rejected at 240 (Gatling: 689 ms and 34.01%). The honest reading is that on this app the app is the bottleneck, so both tools measure the same wall. It says nothing about which tool needs less CPU or memory to generate load: I did not measure that, and k6’s vus line in the threshold run below peaks at 108, which matches the 108 requests that fit in the system.
The k6 version of the failing-build test is k6/threshold-fail.js: 220 requests per second for 15 s with thresholds on http_req_duration and http_req_failed. From output/23-k6-threshold-fail-console.txt and output/23b-k6-threshold-fail-exit.txt:
http_req_duration
✗ 'p(95)<200' p(95)=687.37ms
http_req_failed
✗ 'rate<0.01' rate=24.81%
k6 exit code: 99
k6 exits with code 99 when a threshold is crossed, Gatling through Maven exits with 1; either turns a CI step red. The main differences you will actually feel are in the table below.
| Gatling (Java DSL) | k6 | |
|---|---|---|
| Script language | Java, in your existing Maven project | JavaScript, a standalone file |
| Setup in a Spring project | A plugin and a dependency in the pom; simulations in src/test/java | One binary on the PATH; no build-tool coupling |
| Open model | injectOpen with arrival-rate steps | constant-arrival-rate, ramping-arrival-rate |
| Closed model | injectClosed | constant-vus, ramping-vus |
| Staircase helper | incrementUsersPerSec | Write the stages yourself (a loop in this script) |
| Pass/fail | Assertions; Maven fails (exit 1 observed) | Thresholds; exit code 99 observed |
| Per-run output | HTML report directory | Text summary; JSON/other outputs on request |
| Reuse of app code | Can share Java classes (DTOs, test data builders) | None; everything is re-expressed in JS |
If your team writes Java and wants the load test reviewed in the same pull requests as the code, Gatling is a comfortable fit. If the load test should be runnable by anyone with a single binary, or lives with an ops team that does not run Maven, k6 is. Both did the job here.
The full k6 script, and what I could not run
Everything in this section was executed here: k6 v2.3.0 ran both scripts against the same app on the same machine, and the outputs are committed under output/. What I did not do is compare resource use of the two load generators, or run either tool against a remote host over a real network. Treat any claim of the form “tool X generates more load per core” as unsupported by this post.
// k6 equivalent of KneeStepsSimulation: open model, 30 -> 240 requests/s in 8 steps,
// each held 6 s and separated by 2 s ramps. Every request is tagged with the step it was sent in.
import http from 'k6/http';
import exec from 'k6/execution';
import { check } from 'k6';
import { SharedArray } from 'k6/data';
const BASE = __ENV.APP_URL || 'http://localhost:18300';
const STEPS = 8, RATE_STEP = 30, HOLD_S = 6, RAMP_S = 2;
// the feeder: same CSV Gatling reads
const symbols = new SharedArray('symbols', () =>
open('../src/test/resources/data/symbols.csv').trim().split('\n').slice(1));
// k6 treats only these statuses as success, so a 503 counts in http_req_failed
http.setResponseCallback(http.expectedStatuses(200));
const stages = [];
for (let i = 0; i < STEPS; i++) {
if (i > 0) stages.push({ duration: `${RAMP_S}s`, target: RATE_STEP * (i + 1) });
stages.push({ duration: `${HOLD_S}s`, target: RATE_STEP * (i + 1) });
}
export const options = {
scenarios: {
knee: {
executor: 'ramping-arrival-rate', // open model: iterations start on a schedule
startRate: RATE_STEP,
timeUnit: '1s',
preAllocatedVUs: 200,
maxVUs: 1000,
stages,
},
},
};
function stepLabel() {
const elapsed = Date.now() - exec.scenario.startTime;
const slot = Math.floor(elapsed / ((HOLD_S + RAMP_S) * 1000));
const into = elapsed % ((HOLD_S + RAMP_S) * 1000);
if (into >= HOLD_S * 1000 || slot >= STEPS) return 'ramp';
return `rate ${String(RATE_STEP * (slot + 1)).padStart(3, '0')}/s`;
}
export default function () {
const symbol = symbols[Math.floor(Math.random() * symbols.length)];
const res = http.get(`${BASE}/api/quote/${symbol}`, { tags: { step: stepLabel() } });
check(res, { 'status is 200': (r) => r.status === 200 });
}
The k6 version output, from output/20-k6-version.txt:
k6 v2.3.0 (commit/e088784614, go1.26.8, linux/amd64)
Run it yourself
One command regenerates every transcript, table and the chart (about six minutes; no single load run is longer than about 70 seconds). Set K6 to include the k6 steps; without it they are skipped. The script is scripts/run-all.sh.
git clone https://ankurm.com/git.app/asmhatre/java-testing-lab.git
cd java-testing-lab/gatling-spring-boot
K6=/path/to/k6 ./scripts/run-all.sh
Should you even do this? A load test on a laptop or a shared CI runner tells you the order of magnitude and the shape of the curve, not your production capacity. It is worth doing to find a knee, to catch a regression in it, and to check that your timeouts and queue sizes do what you think. It is not worth doing against a mock that has no realistic limits (the lab app works only because its limit is real), against a third-party service you do not own, or with the load generator sharing the CPU of the system under test and then quoting the results as benchmarks. If you have no performance requirement yet, the smoke test and one staircase run before a release is usually enough.
Further reading
- The companion module: java-testing-lab/gatling-spring-boot, with every transcript in
output/. - On this site: virtual threads vs WebFlux vs platform threads benchmarks, Spring Boot 4.1 vs Quarkus vs Micronaut benchmarked, profiling with Java Flight Recorder, Spring Boot Actuator in production.
- Gatling documentation, including the injection and assertions references.
- Grafana k6 documentation.
No Comments yet!