Post

Load Testing Spring Boot with Gatling — Find Your Breaking Point

Find your Spring Boot application's breaking point with Gatling. Covers writing simulations, injection profiles (ramp, constant, stress), assertions, HTML reports, CI integration, and interpreting results to find bottlenecks.

Load Testing Spring Boot with Gatling — Find Your Breaking Point

The Problem — Works in Dev, Dies in Prod

Your application runs perfectly on your laptop. Response times are instant. Everything feels snappy.

Then you deploy to production. 500 concurrent users hit the server. Response times climb from 50ms to 5 seconds. The thread pool saturates. The database connection pool fills up. The JVM starts garbage collecting constantly. Your pager goes off at 2 AM.

Load testing before deployment is the difference between “we know our limits” and “we’ll find out in production.”


What is Gatling

Gatling is a load testing tool built on Scala with:

  • A code-as-test DSL (no XML configuration files)
  • High-performance engine (async, non-blocking — can simulate thousands of users on a single machine)
  • Beautiful HTML reports with response time distributions, percentiles, and throughput graphs
  • Assertions for CI/CD integration (fail the build if response times exceed thresholds)

Unlike JMeter’s GUI-based approach, Gatling simulations are code — version controlled, reviewable, and composable.


Setup

Target Application (Spring Boot)

1
2
3
4
5
6
7
8
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

Gatling Module

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<dependencies>
    <dependency>
        <groupId>io.gatling.highcharts</groupId>
        <artifactId>gatling-charts-highcharts</artifactId>
        <version>3.11.5</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>io.gatling</groupId>
        <artifactId>gatling-test-framework</artifactId>
        <version>3.11.5</version>
        <scope>test</scope>
    </dependency>
</dependencies>

<build>
    <plugins>
        <plugin>
            <groupId>net.alchim31.maven</groupId>
            <artifactId>scala-maven-plugin</artifactId>
            <version>4.9.1</version>
        </plugin>
        <plugin>
            <groupId>io.gatling</groupId>
            <artifactId>gatling-maven-plugin</artifactId>
            <version>4.9.6</version>
        </plugin>
    </plugins>
</build>

Writing a Simulation

A Gatling simulation has four parts:

  1. HTTP Protocol — base URL, headers
  2. Scenarios — user behavior (what they do)
  3. Injection Profile — how many users, how fast
  4. Assertions — pass/fail criteria
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class ProductApiSimulation extends Simulation {

  val httpProtocol = http
    .baseUrl("http://localhost:8080")
    .acceptHeader("application/json")
    .contentTypeHeader("application/json")

  val getAllProducts = scenario("Get All Products")
    .exec(
      http("GET /api/products")
        .get("/api/products")
        .check(status.is(200))
        .check(jsonPath("$[0].id").exists)
        .check(responseTimeInMillis.lte(500))
    )

  setUp(
    getAllProducts.inject(rampUsers(100).during(30.seconds))
  ).protocols(httpProtocol)
    .assertions(
      global.responseTime.percentile3.lt(1000),
      global.successfulRequests.percent.gt(95.0)
    )
}

Injection Profiles

Injection profiles define how users enter the system. Choose based on what you’re testing:

ProfileCodeUse Case
Ramp uprampUsers(100).during(30.seconds)Normal load growth
At onceatOnceUsers(200)Spike testing (flash sale)
Constant rateconstantUsersPerSec(50).during(60.seconds)Sustained throughput
StaircaseincrementUsersPerSec(10).times(5).eachLevelLasting(30.seconds)Finding capacity
NothingnothingFor(10.seconds)Think time between phases
Peak + valleyCombine multiple inject() callsReal traffic patterns

Mixed Workload Example

Real traffic isn’t one endpoint. Model realistic user behavior:

1
2
3
4
5
6
7
8
val mixedWorkload = scenario("Mixed Workload")
  .randomSwitch(
    60.0 -> exec(http("GET /api/products").get("/api/products")),
    30.0 -> exec(http("GET /api/products/{id}").get("/api/products/1")),
    10.0 -> exec(http("POST /api/products")
              .post("/api/products")
              .body(StringBody("""{"name": "New", "price": 19.99}""")))
  )

This mirrors real traffic: 60% list, 30% detail, 10% create.


Running Tests

1
2
3
4
5
# Start your Spring Boot app
cd app && mvn spring-boot:run

# In another terminal, run Gatling
cd gatling && mvn gatling:test -Dgatling.simulationClass=simulations.ProductApiSimulation

Gatling prints real-time progress:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
================================================================================
---- Global Information --------------------------------------------------------
> request count                                        300 (OK=297    KO=3     )
> min response time                                     12 (OK=12     KO=503   )
> max response time                                    487 (OK=487    KO=512   )
> mean response time                                    89 (OK=87     KO=508   )
> std deviation                                         74 (OK=71     KO=4     )
> response time 50th percentile                         67 (OK=67     KO=508   )
> response time 75th percentile                        112 (OK=112    KO=512   )
> response time 95th percentile                        234 (OK=232    KO=512   )
> response time 99th percentile                        412 (OK=410    KO=512   )
> mean requests/sec                                     10 (OK=9.9    KO=0.1   )
---- Response Time Distribution ------------------------------------------------
> t < 800 ms                                           297 ( 99%)
> 800 ms <= t < 1200 ms                                  0 (  0%)
> t >= 1200 ms                                           0 (  0%)
> failed                                                 3 (  1%)
================================================================================

Reading HTML Reports

After the test, Gatling generates a detailed HTML report at target/gatling/:

Key metrics to examine:

  • Response Time Distribution — where most requests land (p50, p75, p95, p99)
  • Active Users Over Time — did all users get served or did they queue?
  • Response Time Percentiles Over Time — when did performance degrade?
  • Requests Per Second — actual throughput achieved

The p95 and p99 are what matter. The mean lies — it hides the tail latency that real users experience.


Assertions

Assertions let you fail the build when performance degrades:

1
2
3
4
5
6
7
8
setUp(...)
  .assertions(
    global.responseTime.percentile3.lt(1000),      // p95 < 1000ms
    global.successfulRequests.percent.gt(95.0),     // >95% success
    forAll.responseTime.max.lt(5000),               // No request > 5s
    details("GET /api/products")
      .responseTime.mean.lt(200)                    // This endpoint < 200ms mean
  )

If assertions fail, the Maven build fails — perfect for CI gates.


Identifying Bottlenecks

graph TD
    A[Run Load Test] --> B{Response time<br/>within threshold?}
    B -->|Yes| C[Increase load]
    C --> A
    B -->|No| D[Check metrics]
    D --> E{CPU > 80%?}
    E -->|Yes| F[Profile code,<br/>optimize algorithms]
    D --> G{Thread pool<br/>saturated?}
    G -->|Yes| H[Increase threads or<br/>switch to reactive]
    D --> I{DB connections<br/>exhausted?}
    I -->|Yes| J[Tune pool size,<br/>optimize queries]
    D --> K{GC pauses?}
    K -->|Yes| L[Tune heap size,<br/>check for leaks]
    F --> M[Fix & Retest]
    H --> M
    J --> M
    L --> M
    M --> A

diagram

Common Spring Boot bottlenecks under load:

  1. Tomcat thread pool — default is 200 threads. If all 200 are blocked waiting on I/O, new requests queue.
  2. Database connection pool — HikariCP default is 10 connections. Under load, threads wait for connections.
  3. Synchronous blocking — a Thread.sleep() or slow HTTP call blocks a thread.
  4. GC pressure — creating too many objects per request causes frequent garbage collection.

CI Integration — GitHub Actions

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
name: Load Test
on: [push]
jobs:
  load-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: 21
      - name: Start application
        run: cd app && mvn spring-boot:run &
      - name: Wait for app
        run: |
          for i in $(seq 1 30); do
            curl -s http://localhost:8080/api/products && break || sleep 2
          done
      - name: Run Gatling
        run: cd gatling && mvn gatling:test
      - name: Upload Report
        uses: actions/upload-artifact@v4
        with:
          name: gatling-report
          path: gatling/target/gatling/**/index.html

The build fails if assertions aren’t met — performance regressions are caught before merge.


Gatling vs JMeter vs k6

CriteriaGatlingJMeterk6
LanguageScala DSLXML/GUIJavaScript
ProtocolHTTP, WebSocketHTTP, JDBC, JMS, etc.HTTP, WebSocket, gRPC
ReportsBeautiful HTML (built-in)Basic (needs plugins)Cloud dashboard or JSON
ScalabilityThousands of users per machineHundreds per machineThousands per machine
CI-friendlyYes (Maven/Gradle)Possible but awkwardYes (CLI)
Learning curveMedium (Scala)Low (GUI)Low (JavaScript)
Code as testYesNo (XML)Yes
Resource usageLow (async I/O)High (threads)Low (Go runtime)
Cloud optionGatling EnterpriseBlazeMeterGrafana Cloud k6

Choose Gatling when: you want beautiful reports, code-as-test, and Maven/Gradle integration for Java projects.


Common Problems

ProblemCauseSolution
“Connection refused”App not runningStart Spring Boot before Gatling
All requests failWrong base URLCheck http.baseUrl()
Unrealistic resultsNo think timeAdd pause() between requests
“Too many open files”OS limitIncrease ulimit -n 65535
Scala compilation errorsWrong Scala versionMatch scala.version to Gatling’s
Maven can’t find GatlingMissing pluginAdd gatling-maven-plugin
Results show 0 usersWrong simulation classUse -Dgatling.simulationClass=

Full Working Example

Complete project with Spring Boot app and Gatling simulations:

https://github.com/AnupamSinha/spring-boot-examples/tree/main/32-gatling

1
2
3
4
5
6
7
8
9
# Start the target app
cd 32-gatling/app && mvn spring-boot:run

# Run load test (in another terminal)
cd 32-gatling/gatling
mvn gatling:test -Dgatling.simulationClass=simulations.ProductApiSimulation

# Open the HTML report
open target/gatling/productapisimulation-*/index.html

References

This post is licensed under CC BY 4.0 by the author.