Back to All Articles
Performance

Performance Testing with Gatling — Complete Guide

Honnesh Muppala May 5, 2026 14 min read

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.

Simulation DSL
(Java/Scala)
Gatling Engine
(Akka / Netty)
Virtual Users
(Akka Actors)
HTTP Requests
(non-blocking)
Target Server
Stats Engine
(log + metrics)
HTML Report

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)
         );
    }
}
From my work at Viasat: We used Gatling simulations as performance regression gates in our satellite broadband provisioning APIs. By wiring 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:

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:

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:

Tip from production experience: At Amazon, performance investigations always started with the 99th percentile, not the mean. A mean response time of 200 ms looks fine, but if the 99th percentile is 4 seconds, one in every 100 users experiences a broken experience. The Gatling HTML report makes this immediately visible — the mean can look healthy while the tail latency is catastrophic. Always review both the 95th and 99th percentile columns.

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
From Experience — Virtusa: During a major e-commerce client engagement at Virtusa, JMeter was our load testing tool for checkout flows. We ran baseline tests at 50 concurrent users and ramped gradually to 500. The checkout API consistently degraded at 300 users due to a database connection pool exhaustion — a failure mode that unit tests or functional tests would never have surfaced. Catching it in load testing rather than on a peak sales day was a significant win. The fix was a one-line connection pool config change; finding the root cause was the hard part.