Gatling Advanced Scenarios: Feeders, Checks, and Throttling

Gatling Advanced Scenarios: Feeders, Checks, and Throttling

Once you have a basic Gatling simulation running, feeders let you inject realistic test data, checks validate and extract values from responses, and throttling lets you control request rate independently from virtual user count. Together these three features let you model production traffic patterns with high fidelity.

Key Takeaways

  • Feeders are the primary mechanism for test data injection — CSV, JSON, and custom Scala/Java feeders supply unique values (emails, IDs, tokens) to each virtual user so you avoid the "thundering herd of identical requests" anti-pattern.
  • Checks serve dual purposes: they validate responses (assert status code, JSON field value) AND extract data into the session for later requests — this chaining pattern is essential for realistic API flows.
  • throttle() controls request rate at the scenario level, separate from virtual user injection — use it when your target is "N requests per second" rather than "N concurrent users."
  • repeat, foreach, and doIf give your scenarios dynamic structure so a single simulation can model browsing, bulk operations, and conditional paths without duplication.
  • Session manipulation via .exec(session -> ...) is the escape hatch for any logic too complex for the built-in DSL.

The Gatling quickstart gets you a working simulation, but real production traffic is messier than a simple ramp of identical GET requests. Users have different accounts, browse different products, and follow different paths through your application. This guide covers the Gatling features that let you model that complexity accurately.

Feeders: Realistic Test Data

The most common anti-pattern in load testing is every virtual user using the same credentials, the same product ID, or the same search term. This skews results because your application's caches become artificially warm, and your database only ever locks the same rows.

Feeders solve this by supplying a stream of data records — one per virtual user iteration.

CSV Feeder

Create a file at src/test/resources/data/users.csv:

username,password,customerId
user001@example.com,pass001,CID-001
user002@example.com,pass002,CID-002
user003@example.com,pass003,CID-003

Reference it in your simulation:

FeederBuilder<String> userFeeder = csv("data/users.csv").circular();

ScenarioBuilder scenario = scenario("Authenticated Browse")
    .feed(userFeeder)  // Injects username, password, customerId into session
    .exec(
        http("Login")
            .post("/auth/login")
            .body(StringBody("""
                {"username": "#{username}", "password": "#{password}"}
            """)).asJson()
            .check(status().is(200))
            .check(jsonPath("$.token").saveAs("authToken"))
    )
    .exec(
        http("Get My Profile")
            .get("/users/#{customerId}")
            .header("Authorization", "Bearer #{authToken}")
            .check(status().is(200))
    );

The .circular() strategy cycles through the CSV rows when the virtual user count exceeds the row count. Alternatives:

csv("data/users.csv").random()     // Pick a random row each time
csv("data/users.csv").shuffle()    // Shuffle then iterate, no repeats until exhausted
csv("data/users.csv").queue()      // Take each row once; simulation fails if exhausted

Use .queue() when each row must be used exactly once (e.g., account creation tests). Use .circular() or .random() for authentication and browsing scenarios.

JSON Feeder

For complex nested data, JSON feeders are cleaner than CSV:

Create src/test/resources/data/orders.json:

[
  {"productId": "PROD-001", "quantity": 2, "coupon": "SAVE10"},
  {"productId": "PROD-002", "quantity": 1, "coupon": null},
  {"productId": "PROD-003", "quantity": 5, "coupon": "BULK20"}
]

Use it in your simulation:

FeederBuilder<Object> orderFeeder = jsonFile("data/orders.json").circular();

ScenarioBuilder checkout = scenario("Checkout Flow")
    .feed(orderFeeder)
    .exec(
        http("Add to Cart")
            .post("/cart/items")
            .body(StringBody("""
                {
                  "productId": "#{productId}",
                  "quantity": #{quantity}
                }
            """)).asJson()
            .check(status().is(201))
    );

Custom Java Feeder

For data that must be generated at runtime (unique order references, timestamps, random amounts):

FeederBuilder<Object> dynamicFeeder = feeder(() -> {
    Map<String, Object> record = new HashMap<>();
    record.put("orderRef", "ORD-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase());
    record.put("amount", ThreadLocalRandom.current().nextDouble(10.0, 500.0));
    record.put("timestamp", Instant.now().toEpochMilli());
    return record;
});

Custom feeders generate a new record on each call, so they never run out of data.

Checks: Validation and Extraction

Checks attach to HTTP requests to assert correctness and extract values for subsequent requests. They are the mechanism that makes multi-step flows possible.

Status and Body Checks

http("Search Products")
    .get("/products?q=#{searchTerm}")
    .check(status().is(200))
    .check(status().in(200, 206))  // Accept either status
    .check(bodyString().contains("\"total\":"))
    .check(bodyBytes().transform(bytes -> bytes.length).lt(1048576))  // Response < 1MB

JSON Path Extraction

http("List Orders")
    .get("/orders?customerId=#{customerId}")
    .check(status().is(200))
    .check(jsonPath("$.orders").exists())
    .check(jsonPath("$.total").ofInt().gte(0).saveAs("orderCount"))
    .check(jsonPath("$.orders[0].id").optional().saveAs("firstOrderId"))
    .check(jsonPath("$.orders[*].status")
        .findAll()
        .saveAs("orderStatuses"))  // Saves as List<String>

The .optional() modifier prevents the check from failing when the JSON path returns no result — useful for empty lists or nullable fields.

Response Time Checks

http("Critical Path")
    .get("/checkout/summary")
    .check(status().is(200))
    .check(responseTimeInMillis().lt(300))  // Fail this VU if response > 300ms

Note: response time checks are per-virtual-user failures, not global assertions. Use simulation-level .assertions() for SLO gates; use request-level response time checks when you want individual slow requests to count as failures.

Header Checks

http("Cached Resource")
    .get("/static/config.json")
    .check(header("Cache-Control").is("public, max-age=3600"))
    .check(header("ETag").exists().saveAs("etag"))

Throttling: Rate-Based Load Shaping

Virtual user injection controls how many users are active. Throttling controls how many requests per second those users collectively send, regardless of user count. This is useful when you have a specific RPS target from capacity planning documents.

setUp(
    scenario.injectOpen(
        constantUsersPerSec(100).during(300)  // 100 users/sec for 5 minutes
    )
).protocols(httpProtocol)
 .throttle(
     reachRps(200).in(30),      // Ramp to 200 req/sec over 30 seconds
     holdFor(120),              // Hold 200 req/sec for 2 minutes
     jumpToRps(100),            // Drop instantly to 100 req/sec
     holdFor(60)                // Hold 100 req/sec for 1 minute
 );

Throttling acts as a ceiling. If users naturally generate fewer requests than the throttle allows, the throttle has no effect. If users would generate more, Gatling queues or drops requests to stay within the limit.

You can also throttle at the scenario level when simulating different user classes with different rate limits:

ScenarioBuilder premiumUsers = scenario("Premium Checkout")
    .throttle(reachRps(50).in(20), holdFor(300));

ScenarioBuilder freeUsers = scenario("Free Browse")
    .throttle(reachRps(200).in(30), holdFor(300));

Looping: repeat and foreach

Fixed Repetition with repeat

ScenarioBuilder pollUntilDone = scenario("Long Poll")
    .exec(session -> session.set("attempts", 0))
    .repeat(10, "i").on(
        exec(
            http("Poll Job Status")
                .get("/jobs/#{jobId}/status")
                .check(status().is(200))
                .check(jsonPath("$.status").saveAs("jobStatus"))
        )
        .pause(2)
        .exitHereIfFailed()
    );

The second argument to repeat ("i") is the loop counter variable name, accessible in the session as #{i}.

Iterating Over a List with foreach

After saving a list via findAll(), iterate over it:

exec(
    http("Get Cart")
        .get("/cart")
        .check(jsonPath("$.items[*].id").findAll().saveAs("itemIds"))
)
.foreach("#{itemIds}", "itemId").on(
    exec(
        http("Get Item Detail")
            .get("/products/#{itemId}")
            .check(status().is(200))
    )
    .pause(500, ChronoUnit.MILLIS)
)

This pattern models a browser that loads the cart page and then makes individual requests for each item — a realistic representation of frontend behavior.

Conditional Logic with doIf and doSwitch

doIf

Execute a block only when a condition is true:

.doIf(session -> session.getInt("orderCount") > 0).then(
    exec(
        http("Get First Order Detail")
            .get("/orders/#{firstOrderId}")
            .check(status().is(200))
    )
)

The lambda receives the full session object, so you can access any session variable for your condition.

doIfOrElse

.doIfOrElse(session -> "premium".equals(session.getString("tier"))).then(
    exec(http("Premium Checkout").post("/checkout/premium"))
).orElse(
    exec(http("Standard Checkout").post("/checkout/standard"))
)

doSwitch

For branching on enumerated values:

.doSwitch("#{userType}").on(
    onCase("admin").then(exec(http("Admin Panel").get("/admin"))),
    onCase("manager").then(exec(http("Manager Dashboard").get("/dashboard"))),
    onCase("viewer").then(exec(http("View Only").get("/reports")))
)

Direct Session Manipulation

When the built-in DSL doesn't cover your logic, use .exec(session -> ...) to write arbitrary Java code:

.exec(session -> {
    // Compute a derived value from multiple session variables
    String userId = session.getString("userId");
    int quantity = session.getInt("quantity");
    double price = session.getDouble("unitPrice");
    double total = quantity * price;

    return session
        .set("orderTotal", total)
        .set("requestId", "REQ-" + userId + "-" + System.currentTimeMillis());
})
.exec(
    http("Place Order")
        .post("/orders")
        .body(StringBody("""
            {
              "total": #{orderTotal},
              "requestId": "#{requestId}"
            }
        """)).asJson()
)

Session objects are immutable — .set() returns a new session with the added key, it does not mutate in place.

Putting It Together: A Realistic E-commerce Flow

Here is a simulation combining feeders, checks, looping, and conditionals into a realistic user journey:

public class EcommerceLoadTest extends Simulation {

    FeederBuilder<String> userFeeder = csv("data/users.csv").circular();
    FeederBuilder<Object> searchFeeder = jsonFile("data/searches.json").random();

    ScenarioBuilder shopping = scenario("Shopping Journey")
        .feed(userFeeder)
        // Login
        .exec(http("Login").post("/auth/login")
            .body(StringBody("""{"username":"#{username}","password":"#{password}"}""")).asJson()
            .check(status().is(200))
            .check(jsonPath("$.token").saveAs("token")))
        .pause(1, 2)
        // Search and browse products
        .repeat(3).on(
            feed(searchFeeder)
            .exec(http("Search").get("/products?q=#{query}")
                .header("Authorization", "Bearer #{token}")
                .check(status().is(200))
                .check(jsonPath("$.products[*].id").findAll().saveAs("productIds")))
            .pause(2, 4)
            .doIf(session -> !session.<List<?>>get("productIds").isEmpty()).then(
                foreach("#{productIds}", "pid").on(
                    exec(http("View Product").get("/products/#{pid}")
                        .header("Authorization", "Bearer #{token}")
                        .check(status().is(200)))
                    .pause(1, 3)
                )
            )
        )
        // Add to cart and checkout 30% of the time
        .doIf(session -> ThreadLocalRandom.current().nextDouble() < 0.3).then(
            exec(http("Add to Cart").post("/cart/items")
                .header("Authorization", "Bearer #{token}")
                .body(StringBody("""{"productId":"#{productIds[0]}","quantity":1}""")).asJson()
                .check(status().is(201)))
            .pause(1)
            .exec(http("Checkout").post("/orders")
                .header("Authorization", "Bearer #{token}")
                .check(status().is(201)))
        );

    {
        setUp(
            shopping.injectOpen(
                rampUsers(100).during(60),
                constantUsersPerSec(20).during(300)
            )
        ).protocols(http.baseUrl("https://api.example.com").acceptHeader("application/json"))
         .throttle(reachRps(300).in(60), holdFor(300))
         .assertions(
             global().responseTime().percentile(95).lt(500),
             global().failedRequests().percent().lt(1.0)
         );
    }
}

This simulation models realistic heterogeneous behavior: different users, different search terms, variable browsing depth, and a realistic checkout conversion rate. The throttle caps total throughput to protect the environment under test during the initial ramp.

Common Mistakes to Avoid

Sharing mutable state between virtual users. Session variables are per-virtual-user — use them. Static Java fields shared across users cause race conditions and incorrect results.

Using queue() feeder with more VUs than CSV rows. The simulation fails with an exception when the queue is exhausted. Always use .circular() unless you specifically need unique-per-VU data.

Checking response time inside a check instead of using assertions. Per-request response time checks count individual slow requests as failures, which inflates your error rate. Use simulation-level assertions for SLO measurement and reserve per-request checks for validating functional correctness.

Not calling .saveAs() on extracted values. If you call jsonPath("$.id").exists() without .saveAs(), the value is validated but not stored. Subsequent requests using #{id} will use an undefined session variable, which sends the literal string #{id} to your API.

Read more

Start now free