What is Gatling?
Gatling is an open-source, high-performance load-testing tool designed for engineers who want to write performance tests as code. Unlike GUI-driven tools such as JMeter, Gatling uses a concise Scala or Java DSL that reads naturally and integrates cleanly into version control and CI/CD pipelines.
At its core, Gatling is built on the Akka actor model and non-blocking I/O, which means a single machine running Gatling can simulate tens of thousands of concurrent virtual users without spawning an OS thread for each one. This makes it extraordinarily resource-efficient compared to thread-based tools.
Gatling ships in two editions. The open-source version (Apache 2.0 licensed) covers all core functionality: simulations, scenarios, feeders, assertions, and detailed HTML reports. Gatling Enterprise (formerly Gatling FrontLine) adds a hosted orchestration layer, real-time dashboards, distributed load generation across multiple injectors, and historical trend comparison — features aimed at larger engineering teams running regular, large-scale performance programs.
For the vast majority of QA engineers building CI-integrated performance gates, the open-source edition is more than sufficient and is what this guide covers.
Architecture Diagram
Understanding how Gatling components connect helps you reason about where bottlenecks originate — in your simulation DSL, the Gatling engine, or the target server itself.
(Java/Scala)
(Akka / Netty)
(Akka Actors)
(non-blocking)
(log + metrics)
The Simulation DSL compiles to JVM bytecode. At runtime the Gatling Engine orchestrates Akka actors — one per virtual user. Each actor sends non-blocking HTTP requests via Netty, processes responses asynchronously, and feeds raw timing data into the Stats Engine, which produces the final HTML report.
Installation
There are two main ways to install and run Gatling: the standalone bundle for exploratory runs and the Maven plugin for CI-integrated projects.
Standalone Bundle
Download gatling-charts-highcharts-bundle-3.x.x.zip from gatling.io. Extract it and the directory structure is:
gatling-bundle/
├── bin/
│ ├── gatling.sh # Linux / macOS launcher
│ └── gatling.bat # Windows launcher
├── conf/
│ └── gatling.conf # Engine configuration
├── lib/ # Gatling JARs and dependencies
├── simulations/ # Drop your simulation .java / .scala files here
└── results/ # HTML reports written here after each run
Requires Java 11 or later on your PATH. Run java -version to verify before starting.
Maven Plugin
For project-based setups (and for CI), add the Gatling Maven plugin to your pom.xml:
<!-- pom.xml -->
<dependency>
<groupId>io.gatling.highcharts</groupId>
<artifactId>gatling-charts-highcharts</artifactId>
<version>3.9.5</version>
<scope>test</scope>
</dependency>
<plugin>
<groupId>io.gatling</groupId>
<artifactId>gatling-maven-plugin</artifactId>
<version>4.6.0</version>
<configuration>
<simulationClass>simulations.LoginSimulation</simulationClass>
</configuration>
</plugin>
With the Maven plugin, your simulations live in src/test/java/simulations/ (or the Scala equivalent) and reports are written to target/gatling/.
First Simulation — Java DSL
Gatling 3.7+ introduced a full Java DSL so you no longer need Scala knowledge. Below is a complete, working simulation that load-tests a login endpoint.
// src/test/java/simulations/LoginSimulation.java
package simulations;
import io.gatling.javaapi.core.*;
import io.gatling.javaapi.http.*;
import static io.gatling.javaapi.core.CoreDsl.*;
import static io.gatling.javaapi.http.HttpDsl.*;
public class LoginSimulation extends Simulation {
// 1. HTTP Protocol configuration
HttpProtocolBuilder httpProtocol = http
.baseUrl("https://api.example.com")
.acceptHeader("application/json")
.contentTypeHeader("application/json")
.shareConnections();
// 2. Scenario definition
ScenarioBuilder loginScenario = scenario("User Login Flow")
.exec(
http("POST Login")
.post("/auth/login")
.body(StringBody(
"{\"email\": \"#{email}\", \"password\": \"#{password}\"}"
)).asJson()
.check(status().is(200))
.check(jsonPath("$.token").saveAs("authToken"))
)
.pause(1, 3)
.exec(
http("GET Profile")
.get("/api/v1/profile")
.header("Authorization", "Bearer #{authToken}")
.check(status().is(200))
);
// 3. Load injection + protocol wiring
{
setUp(
loginScenario.injectOpen(
rampUsers(50).during(30)
)
).protocols(httpProtocol)
.assertions(
global().responseTime().percentile3().lt(500),
global().failedRequests().percent().lt(1.0)
);
}
}
mvn gatling:test into the nightly Jenkins pipeline, we caught a database connection-pool exhaustion issue under 200 concurrent users that would have been invisible in unit or integration tests. The Gatling HTML report's percentile breakdown made it immediately clear the 99th-percentile response time had spiked from 340 ms to 4.2 seconds.
HTTP Protocol Configuration
The HttpProtocolBuilder defines global settings applied to every request in the simulation. Configure it once and pass it to setUp().
HttpProtocolBuilder httpProtocol = http
.baseUrl("https://api.example.com") // All relative URLs resolve against this
.acceptHeader("application/json")
.acceptEncodingHeader("gzip, deflate")
.userAgentHeader("Gatling/3.9 LoadTest")
.contentTypeHeader("application/json")
.shareConnections() // Reuse TCP connections (more realistic)
.maxConnectionsPerHost(10) // Cap concurrent connections per host
.disableCaching(); // Bypass HTTP cache for accurate metrics
Key options to understand:
.baseUrl()— Set once, referenced by allget(),post()calls using relative paths..shareConnections()— Virtual users share the underlying connection pool, mimicking real browser behaviour.
.disableCaching() — Prevents Gatling from honoring Cache-Control headers, which would otherwise skew results by returning cached responses.
Scenarios
A ScenarioBuilder defines the sequence of actions one virtual user performs. You chain actions with .exec() and introduce think time with .pause().
ScenarioBuilder browseScenario = scenario("Browse Products")
.exec(
http("GET Homepage")
.get("/")
.check(status().is(200))
)
.pause(2) // 2-second think time
.exec(
http("GET Category Page")
.get("/products?category=electronics")
.check(status().is(200))
.check(jsonPath("$.total").exists())
)
.pause(1, 4) // Random pause between 1 and 4 seconds
.exec(
http("GET Product Detail")
.get("/products/#{productId}")
.check(status().is(200))
);
Think times (pauses) are critical for realistic load simulation. A scenario without pauses generates an artificial burst — every virtual user fires requests as fast as the server can respond. Real users read pages, scroll, and think between actions. Always include representative pauses.
Checks and Response Extraction
Checks serve two purposes: validation (assert the response looks correct) and extraction (pull values from the response for use in subsequent requests).
// Status code check
.check(status().is(200))
// JSON path extraction — saves value into session variable
.check(jsonPath("$.data.userId").saveAs("userId"))
// JSON path with validation
.check(jsonPath("$.status").is("active"))
// Header check
.check(header("Content-Type").contains("application/json"))
// Response body regex extraction
.check(regex("\"token\":\"([^\"]+)\"").saveAs("accessToken"))
// Multiple checks on one request
.check(
status().is(201),
jsonPath("$.id").saveAs("newOrderId"),
jsonPath("$.status").is("pending")
)
Session variables saved with .saveAs() are available in later .exec() calls using the #{variableName} interpolation syntax.
Feeders — Injecting Test Data
Feeders supply dynamic data to virtual users — usernames, passwords, product IDs, search terms. Without feeders, all virtual users hit the server with identical data, which rarely reflects production traffic patterns and can produce artificially optimistic results due to server-side caching.
// CSV feeder — reads users.csv with columns: email, password
FeederBuilder<String> userFeeder = csv("users.csv").circular();
// JSON feeder
FeederBuilder<Object> productFeeder = jsonFile("products.json").random();
// Programmatic feeder — generates data on the fly
FeederBuilder<String> randomIdFeeder = listFeeder(
java.util.Arrays.asList(
java.util.Map.of("productId", "P001"),
java.util.Map.of("productId", "P002"),
java.util.Map.of("productId", "P003")
)
).random();
// Using a feeder in a scenario
ScenarioBuilder scenario = scenario("Login")
.feed(userFeeder) // Each virtual user pulls a row from the CSV
.exec(
http("Login")
.post("/auth/login")
.body(StringBody("{\"email\": \"#{email}\", \"password\": \"#{password}\"}"))
.asJson()
);
The users.csv file should look like:
email,password
alice@example.com,Pass1234
bob@example.com,Pass5678
charlie@example.com,Pass9012
Feeder strategies:
.queue()— Each row used once; simulation fails if rows run out..circular()— Loops back to the start. Good for long-running tests with limited test data..random()— Each virtual user picks a random row. Best for realistic cache-busting..shuffle()— Randomises order, then iterates sequentially.
Load Injection Profiles
The injection profile defines how many users are injected and over what time period. Gatling supports open and closed workload models, matching different production traffic patterns.
// Ramp 100 users linearly over 60 seconds
loginScenario.injectOpen(rampUsers(100).during(60))
// Spike — inject all users instantly (stress test)
loginScenario.injectOpen(atOnceUsers(500))
// Steady state — constant throughput for 5 minutes
loginScenario.injectOpen(constantUsersPerSec(20).during(300))
// Ramp up, hold, ramp down — realistic production pattern
loginScenario.injectOpen(
rampUsers(50).during(30),
constantUsersPerSec(50).during(120),
rampUsersPerSec(50).to(0).during(30)
)
// Stress peak — gradually ramp then spike
loginScenario.injectOpen(
rampUsersPerSec(0).to(100).during(60),
stressPeakUsers(500).during(20)
)
For APIs where concurrency matters more than arrival rate, use the closed workload model:
// Always keep exactly 100 concurrent virtual users active
loginScenario.injectClosed(
rampConcurrentUsers(0).to(100).during(30),
keepConcurrentUsers(100).during(120)
)
Assertions — Performance Thresholds
Assertions make your simulation a proper performance gate: the run fails if thresholds are breached, failing the CI build and alerting the team.
setUp(loginScenario.injectOpen(rampUsers(100).during(60)))
.protocols(httpProtocol)
.assertions(
// 95th percentile response time under 500 ms
global().responseTime().percentile3().lt(500),
// Max response time under 2 seconds
global().responseTime().max().lt(2000),
// Error rate under 1%
global().failedRequests().percent().lt(1.0),
// Minimum throughput — at least 50 req/s
global().requestsPerSec().gt(50.0),
// Per-request assertions
forAll().responseTime().percentile3().lt(800),
// Specific request name assertion
details("POST Login").responseTime().percentile3().lt(300)
);
Percentile helpers: percentile1() = 50th, percentile2() = 75th, percentile3() = 95th, percentile4() = 99th.
Reading the HTML Report
After each simulation run, Gatling generates a self-contained HTML report in results/<simulationName>-<timestamp>/. Open index.html in any browser — no server required.
Key sections of the report:
- Global Statistics table — Requests total, success count, KO count, min/max/mean/percentile response times, and requests per second. This is your headline pass/fail view.
- Response Time Distribution chart — Histogram showing how many responses fell in each time bucket (e.g. 0-800 ms, 800-1200 ms, 1200-2000 ms, >2000 ms). A healthy run is heavily left-skewed.
- Response Time Percentiles over Time — Line chart plotting 50th, 75th, 95th, 99th percentiles over the test duration. Percentiles rising over time indicate server degradation under sustained load.
- Requests per Second — Shows actual throughput generated. Compare with your injection profile to spot throttling.
- Per-request stats — Click any named request to drill into its individual statistics, separate from the global view.
CLI and Maven Execution
Standalone CLI
# Run a specific simulation by class name
./bin/gatling.sh -s simulations.LoginSimulation
# Run with a custom results directory
./bin/gatling.sh -s simulations.LoginSimulation -rf ./perf-results
# List available simulations
./bin/gatling.sh -s
Maven
# Run the simulation configured in pom.xml
mvn gatling:test
# Override the simulation class from the command line
mvn gatling:test -Dgatling.simulationClass=simulations.CheckoutSimulation
# Run and fail the build on assertion failure (default behaviour)
mvn gatling:test -DfailOnError=true
GitHub Actions CI Integration
The following workflow runs your Gatling simulation on every push to main or on a manual trigger, and uploads the HTML report as a downloadable artifact.
# .github/workflows/gatling.yml
name: Gatling Performance Test
on:
push:
branches: [main]
workflow_dispatch:
inputs:
users:
description: 'Number of virtual users'
default: '50'
jobs:
gatling:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Java 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Cache Maven packages
uses: actions/cache@v4
with:
path: ~/.m2
key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }}
- name: Run Gatling simulation
run: mvn gatling:test -DfailOnError=true
env:
BASE_URL: ${{ secrets.PERF_TARGET_URL }}
- name: Upload Gatling HTML report
uses: actions/upload-artifact@v4
if: always()
with:
name: gatling-report-${{ github.run_number }}
path: target/gatling/
retention-days: 30
The if: always() on the artifact upload step ensures you get the report even when the simulation fails an assertion — which is exactly when you need it most for analysis.
Tool Comparison: Gatling vs Alternatives
| Feature | Gatling | JMeter | k6 | Locust |
|---|---|---|---|---|
| Scripting language | Java / Scala DSL | XML (GUI) / Groovy | JavaScript | Python |
| Scripting model | Code-first DSL | GUI / XML | Code-first JS | Code-first Python |
| Concurrency model | Actor-based (Akka) | Thread-per-user | Goroutine-like (Go) | Greenlet (gevent) |
| Built-in HTML report | Yes — rich, interactive | Yes — basic | No (needs Grafana) | Web UI / CSV |
| Protocol support | HTTP, WebSocket, gRPC | HTTP, FTP, JDBC, MQTT | HTTP, WebSocket, gRPC | HTTP (custom via gevent) |
| CI / Git-friendly | Excellent — code is text | Poor — XML is verbose | Excellent | Excellent |
| Learning curve | Medium (JVM knowledge) | Low (GUI) | Low (JS developers) | Low (Python developers) |
| Distributed load | Enterprise edition | Built-in (master/slave) | k6 Cloud / OSS k6 | Built-in (master/worker) |
Best Practices for Gatling Performance Testing
1. Always include a warm-up phase
JIT compilation, connection pool warm-up, and caching layers all distort the first few seconds of a performance run. Inject a small warm-up load (10–20 users for 30 seconds) before the measurement period. Use two separate setUp calls or ramp slowly at the start of your injection profile, then analyse only the steady-state data.
2. Use feeders for realistic data diversity
If all 500 virtual users log in as the same user, the server may return cached session data. CSV or JSON feeders ensure each user authenticates with unique credentials and hits different data records, exercising database query paths that a single-user test would cache away.
3. Separate baseline runs from regression runs
Before any new feature deployment, run a baseline simulation and commit the report to a perf-baselines/ directory in git. After the deployment, run the same simulation and compare. This makes performance regressions immediately identifiable — not just "is it slow" but "is it slower than it was."
4. Track results in version control
Archive the Gatling simulation.log alongside your HTML report. The log file is the raw source that generates the report and is useful for post-hoc analysis. Include simulation runs in your git history with commit messages like perf: baseline v1.4.2 — p95 login 342 ms so the performance history is searchable.
5. Define assertions before you run, not after
Setting thresholds based on observed results is p-hacking for performance — you will always "pass" if you set the bar at what you already measured. Define your SLOs (p95 < 500 ms, error rate < 1%) in the simulation file before the first run. If you miss them, investigate and fix rather than adjusting the threshold.
Back to Blog