What is Gatling?
Gatling is an open-source load testing tool built on Scala, Akka, and Netty. Unlike traditional load testing tools that rely on thread-per-connection models, Gatling uses an asynchronous, message-driven architecture powered by Akka actors. This allows a single machine to simulate thousands of concurrent virtual users with minimal resource consumption.
Gatling tests are written in a domain-specific language (DSL) that reads like natural English, making test scenarios easy to understand and maintain. The tool ships with a powerful HTML report generator that provides detailed metrics including response time percentiles, request per second graphs, and error distribution charts — all ready to share with your team.
Key Features at a Glance
- High-performance asynchronous engine — simulate thousands of users on a single machine
- Expressive Scala DSL — readable, composable, and maintainable test scenarios
- Rich HTML reports — automatically generated, shareable, with percentile charts and trend analysis
- Recorder tool — browser-based HAR capture to bootstrap scenarios quickly
- CI/CD integration — first-class support for Maven, Gradle, Jenkins, and GitLab CI
- Extensible protocol support — HTTP, WebSocket, JMS, JDBC, MQTT, and gRPC
Why Gatling Matters for Developers
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Performance testing has historically been a siloed activity handled by dedicated QA teams using GUI-based tools like JMeter. Gatling shifts this paradigm by bringing performance testing into the developer workflow. Here's why that matters:
- Code-as-test philosophy: Scenarios live in version control alongside application code. They can be reviewed, refactored, and executed in CI pipelines just like unit tests.
- Early feedback loop: Developers can run small-scale load tests locally during development to catch performance regressions before they reach production.
- Collaboration: Because scenarios are plain text, product owners and QA engineers can read and validate them without needing to learn a GUI.
- Reliability: The asynchronous engine eliminates thread-scheduling artifacts, giving you reproducible, trustworthy results that reflect real production behavior.
- Cost efficiency: Fewer machines needed to generate equivalent load compared to thread-based tools, reducing infrastructure costs for performance testing environments.
Core Concepts
Before diving into code, let's establish the mental model Gatling uses:
- Simulation: The top-level test class that bundles scenarios, load profiles, and protocol configurations into a single executable test.
- Scenario: A sequence of requests representing a user journey — think "user logs in, searches for a product, adds to cart, and checks out."
- Virtual User: An instance of a scenario executing independently. Gatling manages thousands of these concurrently via actors.
- Session: Each virtual user carries a session — a key-value store that flows through the scenario, holding things like authentication tokens, correlation IDs, or data extracted from responses.
- Injection Profile: Defines how virtual users are introduced over time — ramp-up, constant rate, bursts, or custom patterns.
- Protocol Configuration: HTTP settings like base URL, headers, connection pooling, and SSL parameters shared across all users.
Getting Started with Gatling
Installation and Setup
Gatling offers multiple setup paths. The recommended approach for developers is to use the Gradle or Maven plugin, which integrates seamlessly into existing JVM projects.
Option 1: Gradle (Kotlin DSL)
// build.gradle.kts
plugins {
id("io.gatling.gradle") version "3.9.5"
}
dependencies {
gatlingImplementation("io.gatling.highcharts:gatling-charts-highcharts:3.9.5")
}
Option 2: Maven
<!-- pom.xml -->
<plugin>
<groupId>io.gatling</groupId>
<artifactId>gatling-maven-plugin</artifactId>
<version>4.2.0</version>
</plugin>
<dependency>
<groupId>io.gatling.highcharts</groupId>
<artifactId>gatling-charts-highcharts</artifactId>
<version>3.9.5</version>
<scope>test</scope>
</dependency>
Option 3: Standalone Bundle
Download the standalone bundle from the Gatling website, unzip it, and use the provided shell scripts. This is ideal for quick prototyping or if you don't have a JVM project already.
# Download and extract
curl -L https://repo1.maven.org/maven2/io/gatling/highcharts/gatling-charts-highcharts-bundle/3.9.5/gatling-charts-highcharts-bundle-3.9.5-bundle.zip -o gatling.zip
unzip gatling.zip
cd gatling-charts-highcharts-bundle-3.9.5/bin
# Run a simulation
./gatling.sh
Project Structure
Whether using the build plugin or standalone bundle, the canonical project structure looks like this:
project-root/
├── src/
│ └── test/
│ ├── scala/ # Simulation classes
│ │ └── simulations/
│ │ ├── MyFirstSimulation.scala
│ │ └── AdvancedCheckoutSimulation.scala
│ └── resources/ # Test data and configuration
│ ├── bodies/ # Request payloads (JSON, XML, etc.)
│ │ └── createUser.json
│ ├── data/ # CSV feeders
│ │ └── users.csv
│ └── logback-test.xml # Logging configuration
└── results/ # Generated reports (auto-created)
Writing Your First Gatling Simulation
Let's build a complete simulation step by step. We'll test a hypothetical REST API for a task management service.
Basic Simulation Structure
Every simulation extends the Simulation class and defines three essential parts: protocol configuration, scenarios, and injection profiles.
package simulations
import io.gatling.core.Predef._
import io.gatling.http.Predef._
import scala.concurrent.duration._
class TaskApiSimulation extends Simulation {
// 1. Protocol configuration
val httpProtocol = http
.baseUrl("https://api.example-tasks.com")
.acceptHeader("application/json")
.contentTypeHeader("application/json")
.userAgentHeader("Gatling-LoadTest/1.0")
// 2. Scenario definition
val scn = scenario("Create and List Tasks")
.exec(
http("Create Task")
.post("/api/tasks")
.body(StringBody("""{"title":"Buy groceries","priority":"high"}"""))
.check(status.is(201))
)
.pause(2)
.exec(
http("List Tasks")
.get("/api/tasks")
.check(status.is(200))
)
// 3. Injection profile
setUp(
scn.inject(
rampUsers(100).during(30.seconds)
)
).protocols(httpProtocol)
}
Defining HTTP Protocol
The protocol configuration centralizes settings shared across all requests. This avoids repetition and keeps scenarios clean.
val httpProtocol = http
.baseUrl("https://api.example-tasks.com")
.acceptHeader("application/json")
.contentTypeHeader("application/json")
.userAgentHeader("Gatling-LoadTest/1.0")
// Connection settings
.maxConnectionsPerHost(100)
.shareConnections
// Timeouts
.connectTimeout(5.seconds)
.responseTimeout(30.seconds)
// SSL
.sslSessionTimeout(5.seconds)
// Retry on specific statuses
.disableAutoReferer
// Additional headers
.header("X-Request-ID", "gatling-${java.util.UUID.randomUUID.toString}")
Creating Scenarios
Scenarios are built by chaining exec blocks. Each block represents an action — typically an HTTP request, but also pauses, loops, conditions, or session manipulations.
Request building:
// GET request with query parameters
http("Get Tasks by Priority")
.get("/api/tasks")
.queryParam("priority", "high")
.queryParam("page", "1")
.check(status.is(200))
// POST with JSON body from file
http("Create Task from File")
.post("/api/tasks")
.body(RawFileBody("bodies/createTask.json"))
.check(status.is(201))
// PUT with dynamic values from session
http("Update Task")
.put("/api/tasks/${taskId}")
.body(StringBody("""{"title":"${title}","completed":true}"""))
.check(status.is(200))
// DELETE
http("Delete Task")
.delete("/api/tasks/${taskId}")
.check(status.is(204))
Scenario composition with pauses and loops:
val userJourney = scenario("Full User Journey")
// Authentication
.exec(
http("Login")
.post("/api/auth/login")
.body(StringBody("""{"email":"${username}","password":"${password}"}"""))
.check(jsonPath("$.token").saveAs("authToken"))
)
.pause(1, 5) // Random pause between 1 and 5 seconds
// Use the token
.exec(
http("Fetch Profile")
.get("/api/users/me")
.header("Authorization", "Bearer ${authToken}")
.check(status.is(200))
)
// Loop: create 3 tasks
.repeat(3, "counter") {
exec(
http("Create Task #{counter}")
.post("/api/tasks")
.body(StringBody("""{"title":"Task number ${counter}","priority":"medium"}"""))
.check(jsonPath("$.id").saveAs("taskId"))
)
.pause(1)
}
// Conditional logic
.doIf(session => session("authToken").as[String].nonEmpty) {
exec(
http("List All Tasks")
.get("/api/tasks")
.header("Authorization", "Bearer ${authToken}")
.check(status.is(200))
)
}
Injection Profiles
The injection profile determines the load pattern. Gatling provides several built-in strategies:
// Gradual ramp-up: 500 users over 5 minutes
scn.inject(rampUsers(500).during(5.minutes))
// Constant rate: 50 users per second, sustained for 10 minutes
scn.inject(constantUsersPerSec(50).during(10.minutes))
// Step-wise: start with 10 users/sec, increase by 10 every minute up to 100
scn.inject(
incrementUsersPerSec(10)
.times(10)
.eachLevelLasting(60.seconds)
.startingFrom(10)
)
// Burst: 100 users suddenly, then hold for 30 seconds
scn.inject(
atOnceUsers(100),
constantUsersPerSec(20).during(30.seconds)
)
// Complex composite profile
setUp(
scenario("Browse Products").inject(
rampUsers(1000).during(10.minutes)
),
scenario("Checkout").inject(
constantUsersPerSec(5).during(10.minutes)
)
).protocols(httpProtocol)
Running Gatling Simulations
Via Gradle
# Run all simulations
./gradlew gatlingRun
# Run a specific simulation
./gradlew gatlingRun-simulations.TaskApiSimulation
Via Maven
# Run all simulations
mvn gatling:test
# Run a specific simulation
mvn gatling:test -Dgatling.simulationClass=simulations.TaskApiSimulation
Via Standalone Script
./bin/gatling.sh -s simulations.TaskApiSimulation
After execution, Gatling generates an HTML report in the results/ directory. Open index.html in any browser to explore response time distributions, percentile curves, and request timelines.
Advanced Gatling Features
Feeders: Data-Driven Tests
Feeders inject external data into scenarios — essential for realistic testing with varied inputs. Gatling supports CSV, JSON, database, and programmatic feeders.
// CSV feeder
val csvFeeder = csv("data/users.csv").circular
val scn = scenario("Login with CSV Data")
.feed(csvFeeder)
.exec(
http("Login")
.post("/api/auth/login")
.body(StringBody("""{"email":"${email}","password":"${password}"}"""))
.check(status.is(200))
)
// JSON feeder with custom parsing
val jsonFeeder = jsonFile("data/testdata.json").random
// Programmatic feeder
val generatedFeeder = Iterator.continually(Map(
"timestamp" -> System.currentTimeMillis().toString,
"uuid" -> java.util.UUID.randomUUID().toString,
"index" -> (ThreadLocalRandom.current().nextInt(1000) + 1).toString
))
// Database feeder (requires jdbc module)
val dbFeeder = jdbcFeeder(
"jdbc:postgresql://localhost:5432/mydb",
"username", "password",
"SELECT email, role FROM users WHERE active = true LIMIT 1000"
).circular
Feeder strategies:
.circular— loops through data indefinitely.random— picks random records (with replacement).shuffle— shuffles once, then iterates.queue— consumes records exactly once, stops when exhausted
Checks and Assertions
Checks extract data from responses and optionally validate them. Gatling's check system is one of its most powerful features.
// Basic status check
.check(status.is(200))
// Multiple statuses
.check(status.in(200, 201, 204))
// JSON path extraction
.check(jsonPath("$.id").saveAs("userId"))
.check(jsonPath("$.name").is("John Doe"))
.check(jsonPath("$.items[*].id").findAll.saveAs("itemIds"))
// Header extraction
.check(header("X-RateLimit-Remaining").saveAs("rateLimit"))
// Response body substring check
.check(bodyString.contains("success"))
// Regex extraction
.check(regex("""access_token":"([^"]+)"""")
.saveAs("accessToken"))
// Combined checks in one request
http("Create and Validate")
.post("/api/users")
.body(StringBody("""{"name":"Test User"}"""))
.check(
status.is(201),
jsonPath("$.id").saveAs("userId"),
jsonPath("$.createdAt").exists,
header("Location").saveAs("userLocation")
)
// Conditional checks
.check(
jsonPath("$.error").notExists,
jsonPath("$.data.id").optional.saveAs("resultId")
)
Pacing and Throttling
Realistic user behavior includes think time between actions. Gatling provides flexible pacing mechanisms:
// Fixed pause
.pause(5)
// Random pause between bounds
.pause(2, 8)
// Pause with Gaussian distribution
.pause(5, 10, GaussianRandomStrategy)
// Pause based on response time (simulates reading)
.pause(
session => {
val lastResponseTime = session("lastResponseTime").as[Long]
lastResponseTime * 2 // Think time proportional to response time
}
)
// Explicit throttling at scenario level
val scn = scenario("Throttled Scenario")
.throttle(
reachRps(100).in(10.seconds), // Ramp to 100 RPS over 10s
holdFor(5.minutes), // Hold for 5 minutes
jumpToRps(200), // Jump to 200 RPS
holdFor(2.minutes)
)
.exec(
http("Throttled Request")
.get("/api/data")
.check(status.is(200))
)
Session Manipulation
The session is a virtual user's state. You can read from it, write to it, and transform it throughout the scenario.
// Setting session values directly
.exec(session => session.set("customKey", "customValue"))
.exec(session => session.setAll(Map("key1" -> "val1", "key2" -> "val2")))
// Reading session values
.exec(session => {
val userId = session("userId").as[String]
println(s"Current user ID: $userId") // Warning: prints for every virtual user
session
})
// Conditional session transformation
.exec(session => {
if (session("attemptCount").as[Int] >= 3) {
session.markAsFailed // Mark virtual user as failed
} else {
session.set("attemptCount", session("attemptCount").as[Int] + 1)
}
})
// Gatling expression language (Gatling EL)
// Access session values directly in strings:
.body(StringBody("""{"userId":"${userId}","timestamp":"${timestamp}"}"""))
.header("Authorization", "Bearer ${authToken}")
.queryParam("page", "${currentPage}")
Error Handling and Retry Logic
// Try/Catch pattern
val resilientScenario = scenario("Resilient User")
.exec(
http("Primary Endpoint")
.get("/api/primary")
.check(status.is(200))
)
.tryMax(3) { // Retry up to 3 times
exec(
http("Flaky Endpoint")
.get("/api/flaky")
.check(status.is(200))
)
}
// Exit scenario on failure
.exitBlockOnFail {
exec(
http("Critical Operation")
.post("/api/critical")
.check(status.is(200))
)
}
// Silent requests (don't report failures as errors)
http("Optional Health Check")
.get("/api/health")
.silent
.check(status.in(200, 503)) // 503 is acceptable for health checks
// Custom error handling
.exec(session => {
if (session.isFailed) {
// Log or take recovery action
session.set("recoveryToken", "fallback-value")
} else {
session
}
})
Generating and Interpreting Reports
Gatling's HTML reports are one of its standout features. After every run, a timestamped directory is created under results/ containing:
- Global Stats: Total requests, mean response time, min/max, standard deviation
- Percentile Charts: Response times at 50th, 75th, 95th, and 99th percentiles — crucial for understanding tail latency
- Requests per Second: Throughput over time, showing how your system handles sustained load
- Response Time Distribution: Histogram of where response times cluster
- Error Breakdown: Which requests failed, with what status codes, and at what rate
- Group Details: Per-request-group drill-down with all above metrics
To customize report generation or export data for external analysis:
// Export raw results as JSON for custom processing
// In gatling.conf (or logback configuration):
gatling {
data {
writers = [console, file, graph]
file {
bufferSize = 8192
}
}
}
// Programmatic access to statistics
import io.gatling.core.stats.StatsEngine
// After simulation, access:
// - statsEngine.meanResponseTime
// - statsEngine.percentile95
// - statsEngine.requestsPerSecond
Testing Beyond HTTP
Gatling supports protocols beyond HTTP. Here's a quick overview of additional modules:
WebSocket
import io.gatling.http.Predef._
import io.gatling.ws.Predef._
val wsProtocol = ws
.baseUrl("wss://ws.example.com")
.connectTimeout(5.seconds)
val wsScenario = scenario("WebSocket Chat")
.exec(ws("Connect").connect("/chat/${roomId}"))
.pause(2)
.exec(ws("Send Message")
.sendText("Hello from ${username}")
.await(30.seconds)
.check(wsTextMessage.check(jsonPath("$.ack").is("received")))
)
.exec(ws("Close").close())
JDBC (Database Testing)
import io.gatling.jdbc.Predef._
val dbConfig = jdbc
.url("jdbc:postgresql://localhost:5432/mydb")
.username("app_user")
.password("secret")
.maxPoolSize(50)
val dbScenario = scenario("Database Load Test")
.exec(jdbc("Fetch Users")
.query("SELECT id, name FROM users WHERE status = 'active' LIMIT 100")
)
.exec(jdbc("Update Last Access")
.execute("UPDATE users SET last_access = NOW() WHERE id = ${id}")
)
Best Practices for Gatling Testing
1. Design Meaningful Scenarios
Model real user journeys, not isolated API calls. A single scenario should represent a complete workflow — login, browse, purchase, logout. This reveals cross-request issues like session affinity problems, cache churn, and database connection leaks that single-request tests miss.
2. Use Feeder Data Strategically
Avoid using the same data for all virtual users. Unique emails, varied payload sizes, and diverse query parameters exercise different code paths and uncover edge cases. Use .circular feeders with large datasets (10,000+ records) to approximate production diversity.
3. Start Small, Scale Incrementally
Begin with a single-user smoke test to verify scenarios work correctly. Then scale to 10, 100, 1000 users in separate runs. This incremental approach isolates failures — if 1000 users break but 100 don't, you know where to focus investigation.
4. Set Realistic Think Times
Production users don't click at machine speed. Analyze your analytics to determine realistic inter-request pauses, then model them with .pause() distributions. Overly aggressive tests create unrealistic load patterns that may not reflect actual user behavior.
5. Warm Up Before Measuring
Most JVM-based systems (including Gatling itself) need warm-up time. Run a brief warm-up phase (30 seconds at low load) before your main injection profile. Discard warm-up metrics from your analysis or run them as separate simulations.
6. Version Control Your Simulations
Treat simulation code as production code. Store it in the same repository as your application, run it in CI on every significant change, and review simulation changes in pull requests. This prevents the "it worked in the load test six months ago" problem.
7. Monitor the System Under Test
Gatling reports show client-side metrics. Complement this with server-side monitoring — CPU, memory, GC pauses, thread pools, database connections. Correlate Gatling's response time spikes with server metrics to pinpoint root causes.
8. Avoid Premature Optimization
Focus first on correctness of scenarios and baseline measurements. Optimize only after you have reliable, reproducible results. Pre-optimization often leads to testing the wrong things efficiently.
9. Use Assertions for CI Gating
Gatling supports scenario-level assertions that can fail a CI build:
setUp(scn.inject(rampUsers(100).during(60.seconds)))
.protocols(httpProtocol)
.assertions(
global.responseTime.mean.lt(500), // Mean < 500ms
global.responseTime.percentile95.lt(1000), // p95 < 1000ms
global.successfulRequests.percent.gt(99), // > 99% success rate
global.requestsPerSec.gt(50) // Throughput > 50 RPS
)
10. Keep Simulations Focused
One simulation per critical user journey. Don't cram every API endpoint into a single enormous simulation. Focused simulations produce clearer reports, fail more diagnostically, and are easier to maintain as the API evolves.
Integrating Gatling into CI/CD Pipelines
Jenkins Pipeline Example
// Jenkinsfile
stage('Performance Test') {
steps {
sh './gradlew gatlingRun -Dgatling.simulationClass=simulations.CheckoutSimulation'
// Gatling generates results/ directory
publishHTML(target: [
allowMissing: false,
always: true,
reportDir: 'results/checkout-simulation-*',
reportFiles: 'index.html',
reportName: 'Gatling Performance Report'
])
// Fail build if assertions don't pass
// Gatling exits with non-zero code if assertions fail
}
}
GitLab CI Example
# .gitlab-ci.yml
performance-test:
stage: test
image: gradle:8.5-jdk17
script:
- ./gradlew gatlingRun -Dgatling.simulationClass=simulations.ApiLoadTest
artifacts:
paths:
- build/reports/gatling/
expire_in: 30 days
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
when: manual
- if: '$CI_COMMIT_BRANCH == "main"'
when: always
Conclusion
Gatling represents a fundamental shift in how developers approach performance testing. By expressing load tests as code — composable, version-controlled, and CI-friendly — it eliminates the friction traditionally associated with performance validation. The asynchronous actor-based engine delivers reliable, high-throughput results that mirror real production conditions, while the rich HTML reports provide actionable insights into response time distributions and system throughput.
The journey from writing your first simulation to integrating assertions into a CI pipeline is straightforward. Start with a simple scenario modeling a real user journey, add feeders for data diversity, layer in checks for correctness, and gradually scale injection profiles until you understand your system's breaking points. As you adopt Gatling, you'll find performance testing becomes not a separate phase but a natural extension of your development workflow — one that catches regressions early, informs capacity planning, and ultimately leads to more resilient systems.
Performance is a feature, and Gatling gives you the tools to measure, protect, and improve it throughout your software delivery lifecycle.