← Back to DevBytes

Gatling: Complete Testing Guide for Developers

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

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:

Core Concepts

Before diving into code, let's establish the mental model Gatling uses:

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:

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:

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.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles