Gatling
Gatling is an open-source load-testing tool built around two bets that turn out to matter enormously once a load test has to run on every pull request instead of once a quarter on someone's laptop: write the test as real, compiled, versioned code instead of a GUI-built file, and represent a virtual user as a lightweight, non-blocking task instead of an operating-system thread. The first bet is what lets a Gatling simulation live in the same repository as the application it exercises, get reviewed in the same pull request, and run on the same CI pipeline as everything else. The second is what lets one modestly-sized Gatling process generate load that would need a small cluster of a thread-per-user tool to match. This page covers both in full — the Scala-first DSL and how it reads, the async engine underneath it, the injection profiles that decide what "load" actually means, and the detailed, self-contained HTML report Gatling writes after every run without you standing up a single dashboard.
Say you need to find out how many kids your school cafeteria's doors can handle at once before someone gets stuck. One way: hire a thousand actual kids, give each one a numbered spot and a stopwatch, and have them all shuffle through — that's a lot of kids to manage, feed, and keep track of individually, and it's exactly what JMeter's classic setup does, one real thread per fake kid. Gatling's way: one very fast robot that can convincingly pretend to be a thousand different kids at once, switching between them so quickly nobody standing at the door can tell it isn't juggling anyone in particular. Same doors get tested, far fewer bodies and far less hardware standing around doing it. And when the robot's done, it doesn't hand you a shoebox of stopwatch readings — it hands you a finished report with the graphs already drawn.
What Gatling is and the problem it solves
☺ Like you're 10: It's a program that pretends to be a huge crowd of users, using code you write and check into git instead of clicking through a screen — and it does that pretending with far fewer real computers than older tools need.
Gatling was created by Stéphane Landelle and Nicolas Rémond and first released as open source around 2011–2012, and is now maintained primarily by Gatling Corp, the company that also sells the commercial Gatling Enterprise product built on top of the same open-source core. The open-source engine is Apache 2.0 licensed. From the start it made a deliberate bet against the incumbent of the time — Apache JMeter — on two fronts at once: how a test is authored, and how a virtual user is represented internally while the test runs.
JMeter, the long-standing default, models each virtual user as its own operating-system thread that blocks while waiting on a response. It's simple to reason about, and it's why JMeter test plans are traditionally built in a desktop GUI and saved as an XML .jmx file. But thread creation, context-switching overhead, and the memory a blocked thread's stack alone holds — commonly several hundred kilobytes to a few megabytes per thread — all scale roughly linearly with concurrent users. Simulating tens of thousands of concurrent users this way means either a very large single machine or a distributed cluster of JMeter injector nodes coordinated by a controller, purely to hold that many idle, waiting threads.
Gatling's answer is to stop using OS threads as the unit of concurrency at all. A Gatling virtual user is a lightweight, asynchronous task scheduled on a small, fixed pool of real threads, with Netty handling non-blocking network I/O underneath it — a "waiting" virtual user, one that's sent a request and is doing nothing but waiting for the response, costs Gatling almost nothing, because no OS thread is sitting blocked on its behalf. Thousands of virtual users share a handful of real threads instead of each claiming one outright, which is the entire reason a single JVM process can realistically simulate tens of thousands of concurrent users on hardware that would buckle under the same load in a thread-per-user tool.
Gatling's engine has depended on the Akka actor model for scheduling virtual users since its earliest releases. Akka's 2022 shift to the Business Source License pushed several projects in this corner of the ecosystem to reconsider that dependency, so check Gatling's current release notes for exactly what's underneath a given version if the specifics matter to you — the design principle that survived unchanged across that transition is the one that matters here: virtual users are non-blocking tasks, not OS threads, and that's what the throughput-per-node claim actually rests on.
Gatling isn't a general-purpose testing framework wearing a load-testing hat. Its check() assertions validate individual responses well enough to catch a broken endpoint mid-run, but it isn't meant to replace your correctness test suite. It also doesn't monitor the system under test — you still need your own dashboards on the target service (see monitoring & observability) to see why latency degraded, not just that it did. And the open-source edition runs as a single JVM process per test by default, with no built-in multi-machine orchestration — more on what that means below.
How it works — the async engine behind the throughput claim
☺ Like you're 10: Instead of hiring one real thread per fake user, a small crew of real threads takes turns being every fake user, so a handful of workers can convincingly be ten thousand people at once.
Everything Gatling does at runtime traces back to one loop: read the injection profile to decide how many virtual users should exist right now and how fast new ones should arrive, run each active virtual user's scenario as a chain of non-blocking steps, and record the timing of every request as it completes. The diagram below is the shape that takes for a single test run, including the piece — the report generator on the right — that this page's second half spends the most time on.
Where the CPU actually goes
Connection pooling and keep-alive are handled by Netty and reused across virtual users, so the engine spends its cycles on the actual work — serializing request bodies, matching checks against responses, advancing each virtual user's scenario to its next step — rather than on thread bookkeeping. The injection profile acts as a backpressure valve: Gatling schedules new virtual-user arrivals according to the profile you define, not as fast as the hardware physically allows, which is what makes a run's load shape reproducible rather than "whatever the machine happened to manage that day."
Single node by default — and Gatling Enterprise for going further
Open-source Gatling runs as one JVM process per test invocation, with no built-in mechanism to coordinate several machines into one logical run. For most services this is not a limitation that ever gets hit — a single modern instance can realistically drive tens of thousands of concurrent virtual users before the load generator itself becomes the bottleneck. When a test genuinely needs more than one machine can generate, teams either shard the test by hand across several instances and merge the resulting reports out of band, or reach for Gatling Enterprise (the commercial product, previously branded FrontLine), which orchestrates a fleet of "injector" machines from a control plane, aggregates their combined results into a single report, and adds a persistent dashboard for comparing trends across runs over time — features the open-source core doesn't attempt to provide on its own. Verify current packaging, limits, and pricing on Gatling's own site before planning around it, since commercial tiers shift.
The simulation DSL — load tests as code you review
☺ Like you're 10: The whole test is a small, compiled program — what to hit, how to check the answer, and how many pretend users to throw at it — readable in a pull request the same way any other code change is.
A Gatling simulation is a class: an http protocol builder that sets shared connection details, one or more named scenario blocks describing a chain of steps a virtual user takes, and a setUp(...) call that wires a scenario to an injection profile and, optionally, a set of pass/fail assertions for the whole run. It reads close to plain English once the shape is familiar:
import io.gatling.core.Predef._
import io.gatling.http.Predef._
import scala.concurrent.duration._
class CheckoutSimulation extends Simulation {
// one HTTP client config, shared across every scenario below
val httpProtocol = http
.baseUrl("https://staging.acme.example")
.acceptHeader("application/json")
.userAgentHeader("gatling-load-test")
// a CSV feeder — each virtual user pulls the next row; .circular loops back
// to the start instead of erroring once the file runs out (see Gotchas)
val users = csv("users.csv").circular
val checkoutFlow = scenario("Browse and checkout")
.feed(users)
.exec(
http("Login")
.post("/api/login")
.body(StringBody("""{"user":"${username}","pass":"${password}"}""")).asJson
.check(status.is(200), jsonPath("$.token").saveAs("authToken"))
)
.pause(1.second, 3.seconds) // think time between steps
.exec(
http("Browse catalog")
.get("/api/catalog")
.header("Authorization", "Bearer ${authToken}")
.check(status.is(200), responseTimeInMillis.lte(800))
)
.pause(2.seconds)
.exec(
http("Checkout")
.post("/api/checkout")
.header("Authorization", "Bearer ${authToken}")
.body(StringBody("""{"items":["sku-42"]}""")).asJson
.check(status.in(200, 201))
)
setUp(
checkoutFlow.inject(
rampUsersPerSec(5).to(50).during(2.minutes), // open model — arrival rate, not a fixed pool
constantUsersPerSec(50).during(10.minutes)
)
).protocols(httpProtocol)
.assertions(
global.responseTime.percentile(95).lt(500),
global.successfulRequests.percent.gt(99)
)
}Two things in that file are worth naming explicitly. check() validates one response and, on failure, marks that single request as KO without stopping the run — it's the per-request layer. assertions() on setUp is the run-level layer: a global threshold that, if violated, makes the whole simulation's process exit non-zero. That distinction is what makes Gatling genuinely CI-friendly rather than merely CI-compatible — a build fails because a real threshold was crossed, not because someone has to remember to parse a results file afterward.
Scala was Gatling's only DSL for most of its history; Gatling 3.7 (2021) added first-class Java and Kotlin DSLs with near-identical method names, so a team that doesn't want to introduce Scala into its stack can write the exact scenario above in a language it already uses. The engine itself remains implemented in Scala regardless of which DSL you write simulations in, and the Scala DSL still tends to have the deepest well of community examples and the closest mapping to the underlying documentation.
What actually delivers on "reviewed as code" is mundane and that's the point: the file above is a normal source file sitting in src/test/scala/... (or the Java/Kotlin equivalent) inside the application's own repository, or a companion performance-test repository, built by the same Maven or Gradle toolchain the rest of the project already uses. A change to it is an ordinary, line-by-line diff a reviewer can read and comment on in the same pull request as the feature it's testing — a sharp contrast with a JMeter .jmx file, which is GUI-generated XML that is technically diffable but not meaningfully reviewable, and where recording a test in JMeter's own proxy recorder (Gatling has an equivalent recorder for bootstrapping a simulation from captured traffic) still produces something that reads better as generated code than as hand-authored test logic.
Injection profiles and workload models — open versus closed
☺ Like you're 10: How many pretend users show up, and how fast, is its own decision — and picking the wrong shape can make your test lie to you about how bad a slowdown really is.
An injection profile is the function that decides how virtual users are introduced over the run's duration, and Gatling exposes two structurally different families of it. An open workload model — atOnceUsers(n), rampUsers(n).during(d), constantUsersPerSec(rate).during(d), rampUsersPerSec(from).to(to).during(d) — injects new virtual users at an arrival rate that is independent of how quickly earlier users' requests come back. A closed workload model — constantConcurrentUsers(n).during(d), rampConcurrentUsers(from).to(to).during(d) — instead holds a fixed-size pool of virtual users, each of which loops back and sends its next request only once its previous one has completed.
The difference sounds like a technicality and is not. Real end users mostly behave like an open model: someone new opens the checkout page regardless of whether the last person's request is still pending. Under an open model, if the system under test slows down, the measured request rate keeps arriving at the rate you configured, and the resulting tail-latency numbers reflect what actually happened. Under a closed model, a fixed pool of users that each wait for a response before sending the next one means a slowdown mechanically reduces the observed arrival rate — the very users whose requests would show the worst latency are, by construction, the ones not generating new requests to be measured.
Gil Tene's term for exactly this blind spot is coordinated omission: a closed-model load test that slows down under stress quietly under-samples the slow responses that matter most, because a stuck virtual user can't generate the next request until the current one finishes. A report built on that data can show a deceptively tame p99 while the system is, in reality, failing badly for a growing share of real traffic. The practical fix is to default to an open model for any scenario meant to represent independent, arriving users — reach for a closed model deliberately, only when the thing you're actually testing is a fixed-size resource pool (a connection pool, a worker queue) rather than a user population.
Day-to-day commands and running it in CI
☺ Like you're 10: A handful of commands cover almost everything: run every simulation, run one specific simulation, and point it at a different environment without touching the code.
# Maven project (gatling-maven-plugin) — the most common setup $ mvn gatling:test # runs every simulation under src/test/.../simulations $ mvn gatling:test -Dgatling.simulationClass=sims.CheckoutSimulation $ mvn gatling:test -DjvmArgs="-DbaseUrl=https://staging.acme.example" # parameterize the target env # Gradle project (io.gatling.gradle plugin) $ ./gradlew gatlingRun-sims.CheckoutSimulation # standalone bundle — no build tool required, just the downloaded zip $ ./bin/gatling.sh # interactive: pick a simulation from a numbered list $ ./bin/gatling.sh -s sims.CheckoutSimulation -rf results/checkout-run-42 # headless, CI-friendly # open the report Gatling just wrote — a plain folder, no server needed $ open target/gatling/checkoutsimulation-20260816120000/index.html
Command names and flags shift a little between Gatling major versions — check the current docs for your installed version before scripting around them. The part that matters for CI is structural and stable: mvn gatling:test (and its Gradle and bundle equivalents) exits non-zero automatically the moment a defined assertions() threshold fails, which is what actually fails the pipeline job — no separate step to parse a results file and decide pass/fail yourself.
# .github/workflows/load-test.yml — the shape of it, not a copy-paste-ready file
- name: Run load test
run: mvn gatling:test
- name: Upload Gatling report
if: always()
uses: actions/upload-artifact@v4
with:
name: gatling-report
path: target/gatling/**The HTML report — detailed reporting out of the box
☺ Like you're 10: The moment the run ends, it writes you a finished report with the graphs already drawn — you don't have to build a dashboard first to see how the test went.
While a run is in progress, Gatling appends one compact record per completed request to simulation.log. The instant the run finishes, it parses that log and generates a fully self-contained static HTML and JavaScript report — no external time-series database, no dashboard server, no separate "generate the report" command to remember. It lands at target/gatling/<simulation-name>-<timestamp>/index.html and opens directly in a browser from disk.
What's actually in it: a global stats table with request counts, KO count, and the percentile columns that matter for an SLO conversation — mean, standard deviation, p50, p75, p95, p99, and max; a requests and responses over time chart; an active users over time chart per scenario; a full response-time distribution histogram; and, critically, a per-request breakdown — one row per named request ("Login", "Browse catalog", "Checkout" in the example above) carrying the same percentile columns, so a single slow endpoint is visible sitting right next to the fast ones instead of being averaged away into one aggregate number. The whole thing is a plain folder: zip it, attach it as a CI artifact, email it, and it stays readable years later with no dependency on whatever generated it still being installed anywhere.
That's a genuinely different default posture from its two closest rivals. JMeter's built-in GUI is designed for live debugging while you build a test plan, not for handing someone a finished report — getting comparable graphs out of JMeter typically means adding a listener plugin that streams to InfluxDB and Grafana, or running its separate jmeter -g results.jtl -o report-folder dashboard-generation step after the fact. k6 leans the other way by default: it prints a terse text summary to the console and expects you to stream results to an external backend — Grafana Cloud k6 or your own InfluxDB-plus-Grafana stack — if you want the kind of rich visual report Gatling hands you unprompted.
Older Gatling releases required explicitly including the gatling-charts-highcharts module to get the HTML report at all; recent bundles and build-tool plugins include charting by default. If a run ever completes without producing a report, that dependency — or a build-tool plugin version mismatch — is usually the first thing worth checking.
Gotchas and failure modes
☺ Like you're 10: Most of the ways a Gatling run misleads you have nothing to do with the target system at all — they're about the load generator itself running out of something first.
The injector can bottleneck before the target does
A single machine generating tens of thousands of concurrent virtual users has its own finite resources: CPU to run the engine and its checks, network bandwidth, and — commonly the first thing to actually run out — ephemeral local ports and file descriptors, since every open HTTP connection consumes one. A load generator that opens outbound connections faster than the OS recycles them out of TIME_WAIT can hit port exhaustion and start throwing connection errors that look exactly like the target failing, when the target was never actually under stress. Always graph the injector's own CPU, memory, and open-connection count alongside the target's metrics — a flattening throughput curve on the client side, not the server side, is a load-generator ceiling, not a finding about the system under test.
Feeder exhaustion
A feeder's default strategy, queue, hands out each record once and throws once the file runs dry — a ten-minute run backed by a hundred-row CSV will start erroring well before the run ends if nothing accounts for that. The fix is either .circular (loop back to the start once exhausted, used in the example above) or supplying a dataset generated large enough to genuinely cover the full run; .random and .shuffle exist for when repetition needs to look less predictable than a strict loop.
JVM warm-up skewing the first minutes
The opening seconds of any JVM process pay a JIT warm-up cost, and Gatling's own engine is no exception — the very first requests of a run can show worse latency than the steady state that follows, for reasons that have nothing to do with the system under test. Treat the first portion of a long run as warm-up and exclude it from steady-state percentile analysis, and if a run reports latency spikes that correlate suspiciously well with GC activity rather than target load, check the injector's own heap sizing and GC logs before concluding the target regressed.
Version and dependency drift
Gatling's DSL evolves with its major releases, and a build-tool plugin version that doesn't match the core engine's version is a common source of confusing compile errors or a report that silently fails to generate. Pin the Maven/Gradle plugin and the Gatling core version together, and read a release's migration notes before bumping either one on an existing simulation.
Where Gatling fits in this course
☺ Like you're 10: Every capacity number this course talks about has to come from somewhere real — Gatling is one of the places that number gets generated before Sol ever does arithmetic on it.
Gatling is one concrete way to produce the throughput and latency curves capacity planning & performance reasons about, and the open-versus-closed workload distinction above is the same open-versus-closed-system framing that queueing theory for SRE covers in more mathematical depth — read that page if the coordinated-omission warning above raised more questions than it answered. If you want hands-on reps with a real Gatling simulation rather than just reading about one, Capstone Part 5 — capacity plan & load test and the shorter drill — forecast the bottleneck are exactly that exercise. See the SRE toolchain for how Gatling sits alongside the rest of this course's monitoring, paging, and chaos-engineering categories.
Alternatives and when to choose it
☺ Like you're 10: Other tools ask the same "how much load can this take" question — they just make different trades between who writes the test, how many machines it needs, and how much setup the report costs you.
| Option | Model | Best when | Costs you |
|---|---|---|---|
| Gatling | Scala-first DSL compiled as code; async, non-blocking virtual users on one JVM; detailed self-contained HTML report by default | Load tests should live and be reviewed as code next to the app; you want high throughput per node without standing up a cluster | Scala is the native language (Java/Kotlin DSL is close, but a step behind in examples and docs); open source is single-node by default — distributed runs need Gatling Enterprise or manual sharding |
| Apache JMeter | GUI-built or hand-edited XML (.jmx) test plans; one OS thread per virtual user | A huge protocol and plugin ecosystem (JDBC, JMS, FTP, LDAP, and more) matters more than code-review-ability; the team is already fluent in it | Thread-per-user model is memory- and CPU-heavy at scale; .jmx diffs are close to unreviewable in a pull request; good-looking reports need extra plugins or a separate dashboard-generation step |
| k6 | JavaScript/TypeScript test scripts; a lightweight, Go-based VU runtime | A CI-native workflow and a team that wants tests written in JS; optional cloud execution via Grafana Cloud k6 | Terse console summary by default — rich visual reporting means streaming to Grafana/InfluxDB yourself, or paying for the cloud product; free, built-in distributed local runs aren't part of the open-source core |
| Locust | Python test scripts (locustfile.py); event-loop-based (gevent) — the same async concurrency idea as Gatling, in Python | The team already thinks in Python; built-in, no-extra-product distributed runs matter (Locust's master/worker mode ships in the open-source core) | Reporting is comparatively bare by default — a live web UI plus CSV/JSON exports rather than a rich generated artifact; per-VU overhead is higher than Gatling's engine at extreme scale |
The practical rule most teams land on: if the load test needs to be written, reviewed, and versioned the same way the application code is, and a single well-sized machine can plausibly generate the load a launch actually needs, Gatling is usually the right default. Reach for JMeter specifically when a protocol or plugin only it supports is a hard requirement; reach for k6 or Locust when the team's dominant language is JavaScript or Python and that outweighs Gatling's throughput-per-node and reporting advantages. None of these are mutually exclusive across an organization — it's entirely normal for a platform team to standardize on one while a legacy suite of JMeter plans keeps running until it's worth the cost to port.
Sol the Sloth: Before I write one number into the capacity plan, I want to know it came from an honest test. Benny, is the checkout simulation still living in the app's own repo?
Benny the Beaver: Same repo, same pull request review as everything else we ship. Nobody's hand-editing an XML file at 2 a.m. anymore.
Foxy: Wait — the load held steady at exactly fifty requests a second the whole run, even after checkout started timing out. Doesn't that seem a little too convenient?
Sol the Sloth: It should. That's a closed model — a fixed pool of virtual users, each one waiting for its last request to finish before sending the next. Real users don't do that; a slow response doesn't stop new ones from arriving. Switch the injection to an open model and watch the true tail latency show up.
Timmy the Turtle: And has anyone actually confirmed the assertion block fails the build, or is a failing check just quietly logged somewhere nobody reads?
Benny the Beaver: Confirmed, on purpose, once. I broke the p95 threshold on this exact simulation just to watch the pipeline go red. It did.
Going further
☺ Like you're 10: This page is enough to write and run a real Gatling simulation — the official docs are where you go to get fluent in every corner of the DSL.
The canonical source is the documentation at docs.gatling.io, alongside the source at github.com/gatling/gatling and Gatling Corp's own site for details on Gatling Enterprise. Pair this page with capacity planning & performance for what the numbers a Gatling run produces actually get used for, and queueing theory for SRE for the mathematical grounding behind the open-versus-closed workload distinction covered above.
1. What's the core architectural difference between how Gatling and JMeter represent a virtual user, and why does it let one Gatling node push more throughput than an equivalent JMeter setup? 2. What does it mean for a Gatling simulation to be reviewed "as code," and why does that matter for CI? 3. Name three things in Gatling's default HTML report, and explain why generating it requires no extra setup. 4. What's the difference between an open and a closed workload model, and what does "coordinated omission" mean in that context? 5. Your CSV feeder ran out of rows halfway through a ten-minute run — what happened, and what are the two fixes?
Check your answers
- Gatling represents a virtual user as a lightweight, non-blocking task scheduled on a small, fixed pool of real threads (historically via the Akka actor model, with Netty handling async I/O), instead of JMeter's default of one OS thread per virtual user with a blocking call. A "waiting" virtual user costs Gatling almost nothing, so thousands of concurrent users share a handful of real threads instead of each claiming one — which is what lets one JVM process simulate load that would otherwise need a JMeter cluster.
- It means the simulation is a compiled Scala, Java, or Kotlin source file living in the same repository and build as the application, so a change to the load test shows up as an ordinary, line-by-line-readable diff in a pull request — unlike a GUI-built JMeter
.jmxXML file, which is close to unreviewable. That readability is what lets a load test run automatically, and gate, the same CI pipeline as everything else, sinceassertions()failing makes the run's process exit non-zero. - Any three of: a global stats table with percentiles (p50/p75/p95/p99/max), a requests-and-responses-over-time chart, an active-users-over-time chart, a full response-time distribution histogram, and a per-request breakdown table. It needs no extra setup because Gatling parses its own
simulation.logand writes a fully self-contained static HTML/JavaScript report automatically the moment a run ends — no time-series database, dashboard server, or separate report-generation command required. - An open workload model injects virtual users at a fixed arrival rate independent of how fast responses come back (e.g.
constantUsersPerSec); a closed model holds a fixed pool of virtual users that each wait for a response before sending the next request (e.g.constantConcurrentUsers), so the measured arrival rate self-throttles the moment the system slows down. Coordinated omission is the resulting blind spot: under a closed model, exactly the slow requests that should dominate your tail-latency numbers get undercounted, because a stuck virtual user can't generate its next request until the slow one finishes. - Gatling's default feeder strategy,
queue, throws once the feeder runs out of records, so that part of the run starts erroring instead of generating valid requests. The two fixes are making the feeder.circular(loop back to the start once exhausted) or supplying a dataset large enough to cover the entire run without repeating.