QA How-To
How to Use Gatling to Load Test a GraphQL API (2026)
Learn how to run a Gatling load test GraphQL API project with Java, dynamic variables, checks, workload models, assertions, and actionable HTML reports.
22 min read | 2,734 words
TL;DR
Build the test as a Java Gatling simulation that POSTs GraphQL JSON envelopes to /graphql, supplies variables from a feeder, checks the errors field, and saves returned IDs. Ramp arrival rate gradually, then enforce error-rate and percentile assertions. Read results per operation, not only at the global level.
Key Takeaways
- Send GraphQL operations through Gatling's standard HTTP DSL as JSON POST requests.
- Check both the HTTP status and GraphQL's errors field because a GraphQL failure can return HTTP 200.
- Feed operation variables from CSV instead of producing one cache-friendly request repeatedly.
- Correlate IDs from query responses into later mutations to model a valid user journey.
- Use open injection for arrival-rate goals and closed injection for concurrency goals.
- Turn latency and error objectives into Gatling assertions so CI receives a failing exit code.
- Separate operation names in reports to expose a slow resolver instead of averaging it away.
A gatling load test graphql api project treats GraphQL as HTTP traffic while adding GraphQL-aware payloads, checks, and correlations. You POST a JSON envelope containing operationName, query, and variables, then verify both transport success and the absence of GraphQL errors. Gatling supplies virtual users, workload models, metrics, reports, and build-breaking assertions.
This tutorial builds a Java 21 and Maven test for a catalog journey. Each virtual user looks up a product, saves its returned ID, and executes an AddToCart mutation. The sample targets http://localhost:4000/graphql, so point that URL at a non-production environment implementing the shown operations. Never load test production without explicit authorization, traffic limits, monitoring, and a rollback owner.
TL;DR
| Concern | Implementation | Why it matters |
|---|---|---|
| GraphQL request | HTTP POST with a JSON body | GraphQL commonly uses one endpoint for many operations |
| Test data | Circular CSV feeder | Different SKUs exercise resolver and cache behavior |
| Functional check | HTTP 200 plus no errors field |
GraphQL can report execution errors inside a successful HTTP response |
| Correlation | Save data.product.id |
The mutation uses a real ID returned by the query |
| Load shape | Ramp from 1 to 10 arrivals/second | Gradual pressure makes the saturation point easier to identify |
| Quality gate | Error percentage and p95 assertions | Maven exits unsuccessfully when objectives are missed |
If GraphQL testing is new to you, review GraphQL API testing fundamentals. For a broader performance workflow covering objectives, environments, and analysis, use the API performance testing tutorial.
What You Will Build
You will create a small Maven project containing one Java simulation and one CSV feeder. The finished test will:
- call a named
GetProductquery with a variable SKU; - reject HTTP failures and GraphQL execution errors;
- extract the product ID and use it in an
AddToCartmutation; - generate a warm-up followed by controlled open-model traffic;
- fail automatically when errors exceed 1 percent or p95 latency exceeds 800 ms;
- produce an HTML report with separate measurements for each GraphQL operation.
The example is deliberately compact, but its request naming, correlation, and assertions are suitable foundations for a repository-owned performance suite. The numerical thresholds are illustrative. Replace them with service-level objectives agreed by your team and supported by a quiet baseline run.
Prerequisites
Use these exact tutorial versions: 64-bit OpenJDK 21, Maven 3.9.9, Gatling 3.15.1, and Gatling Maven Plugin 4.17.0. Gatling's Java SDK supports current LTS JDKs, but pinning the project makes local and CI behavior repeatable. You also need Git 2.45 or newer, a terminal, and an authorized GraphQL test environment.
Verify Java and Maven before creating files:
java -version
mvn -version
Expected verification: both commands exit with code 0, java -version reports 21, and Maven reports 3.9.9 while using that Java runtime. Confirm the target itself with a harmless introspection-free request:
curl -sS http://localhost:4000/graphql \
-H 'Content-Type: application/json' \
--data '{"query":"query Health { __typename }"}'
Expected verification: the response is JSON with a data object. If introspection is disabled and __typename is rejected by policy, ask the API owner for its documented health query rather than guessing.
Step 1: Create the Gatling Load Test GraphQL API Project
Create the standard Maven test directories:
mkdir -p gatling-graphql/src/test/java/performance
mkdir -p gatling-graphql/src/test/resources/data
cd gatling-graphql
Add this pom.xml. The compiler release is 21, and both the charts dependency and runner plugin are pinned.
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.qajobfit</groupId>
<artifactId>gatling-graphql</artifactId>
<version>1.0.0</version>
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<gatling.version>3.15.1</gatling.version>
<gatling.maven.plugin.version>4.17.0</gatling.maven.plugin.version>
</properties>
<dependencies>
<dependency>
<groupId>io.gatling.highcharts</groupId>
<artifactId>gatling-charts-highcharts</artifactId>
<version>${gatling.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>io.gatling</groupId>
<artifactId>gatling-maven-plugin</artifactId>
<version>${gatling.maven.plugin.version}</version>
</plugin>
</plugins>
</build>
</project>
Verify Step 1: run mvn -q test-compile. Maven should exit successfully and create target/test-classes. This resolves dependencies but does not send load to the API. A dependency-resolution error here is a build or network problem, not a GraphQL problem.
Step 2: Model GraphQL Operations as JSON Bodies
Gatling does not need a special GraphQL client. Its HTTP DSL sends the same JSON envelope used by application clients. Create src/test/java/performance/GraphqlLoadSimulation.java with the initial query request:
package performance;
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 GraphqlLoadSimulation extends Simulation {
private static final String BASE_URL =
System.getProperty("baseUrl", "http://localhost:4000");
private final HttpProtocolBuilder httpProtocol = http
.baseUrl(BASE_URL)
.acceptHeader("application/graphql-response+json, application/json")
.contentTypeHeader("application/json");
private final ChainBuilder getProduct = exec(
http("GraphQL GetProduct")
.post("/graphql")
.body(StringBody("""
{
"operationName": "GetProduct",
"query": "query GetProduct($sku: String!) { product(sku: $sku) { id name price } }",
"variables": { "sku": "SKU-1001" }
}
""")).asJson()
.check(status().is(200))
.check(jsonPath("$.errors").notExists())
.check(jsonPath("$.data.product.id").exists())
);
private final ScenarioBuilder smoke = scenario("GraphQL catalog smoke")
.exec(getProduct);
{
setUp(smoke.injectOpen(atOnceUsers(1))).protocols(httpProtocol);
}
}
Operation names are valuable observability labels even though the server can execute an unnamed operation. Keep the Gatling request name stable and distinct. Reports that call every request simply POST /graphql conceal whether a product resolver or cart mutation caused the slowdown.
The errors check is essential. Many GraphQL servers return HTTP 200 when parsing succeeded but a resolver failed, authorization was denied, or a non-null field became null. The three checks distinguish transport, execution, and expected data shape.
Verify Step 2: run mvn gatling:test -Dgatling.simulationClass=performance.GraphqlLoadSimulation. Expect one successful request and a report path under target/gatling/. If it is marked KO, inspect the console error and compare the actual schema with GetProduct.
Step 3: Feed Realistic GraphQL Variables
Repeating one SKU creates an unrealistic cache benchmark. Put safe test records in src/test/resources/data/products.csv:
sku,quantity
SKU-1001,1
SKU-1002,2
SKU-1003,1
SKU-1004,3
Then add this field above getProduct:
private static final FeederBuilder<String> products =
csv("data/products.csv").circular();
Change the query's variables line to use Gatling Expression Language:
"variables": { "sku": "#{sku}" }
Finally, feed a row before the query:
private final ScenarioBuilder smoke = scenario("GraphQL catalog smoke")
.feed(products)
.exec(getProduct);
The file is loaded from the test classpath, so the path is data/products.csv, not a path beginning with src/test/resources. A circular feeder restarts when it reaches the last row. That is appropriate for reusable catalog records, but not for unique accounts, one-time tokens, or mutations that consume inventory. Use a queue feeder for data that must never repeat and provision enough rows for the entire run.
Variable binding is safer than concatenating values into the GraphQL document. It preserves a stable query signature for server-side parsing and persisted-query analysis while changing only the variable map. It also reduces quoting mistakes in complex inputs.
Verify Step 3: temporarily change the injection to atOnceUsers(4), run the same Maven command, and confirm the report shows four successful GraphQL GetProduct requests. Server access logs or tracing should show four SKU values. Restore atOnceUsers(1) before continuing.
Step 4: Correlate a Query Result Into a Mutation
A credible journey uses server-returned state. Modify the last query check so it saves the ID into the virtual user's session:
.check(jsonPath("$.data.product.id").saveAs("productId"))
Add the mutation chain below getProduct:
private final ChainBuilder addToCart = exec(
http("GraphQL AddToCart")
.post("/graphql")
.body(StringBody("""
{
"operationName": "AddToCart",
"query": "mutation AddToCart($productId: ID!, $quantity: Int!) { addToCart(productId: $productId, quantity: $quantity) { id totalItems } }",
"variables": { "productId": "#{productId}", "quantity": #{quantity} }
}
""")).asJson()
.check(status().is(200))
.check(jsonPath("$.errors").notExists())
.check(jsonPath("$.data.addToCart.id").exists())
);
Update the scenario to pause as a person might between viewing and acting:
private final ScenarioBuilder smoke = scenario("GraphQL catalog journey")
.feed(products)
.exec(getProduct)
.pause(1, 3)
.exec(addToCart);
Each Gatling virtual user owns its session. saveAs("productId") stores the query result for that user, and #{productId} resolves it in the mutation body. If the query check fails, Gatling marks that request KO. For strict journeys, add exitHereIfFailed() after the query so a failed lookup does not send a malformed mutation and inflate downstream errors.
The unquoted #{quantity} is intentional because GraphQL declares quantity as Int!. Quoting it turns the JSON number into a string and should produce a variable-coercion error.
Verify Step 4: run one user again. The console summary must contain two requests, one for each operation, with zero KO responses. Confirm that the test cart changed in your environment or that a trace shows the saved product ID reaching AddToCart.
Step 5: Add Authentication Without Leaking Tokens
Most GraphQL services require a bearer token. Read it from an environment variable instead of committing it. Add these fields near BASE_URL:
private static final String API_TOKEN = System.getenv("GRAPHQL_API_TOKEN");
private static String requiredToken() {
if (API_TOKEN == null || API_TOKEN.isBlank()) {
throw new IllegalStateException("Set GRAPHQL_API_TOKEN before running the test");
}
return API_TOKEN;
}
Add the authorization header to httpProtocol:
private final HttpProtocolBuilder httpProtocol = http
.baseUrl(BASE_URL)
.acceptHeader("application/graphql-response+json, application/json")
.contentTypeHeader("application/json")
.authorizationHeader("Bearer " + requiredToken());
Export a short-lived token through your shell or CI secret store:
export GRAPHQL_API_TOKEN='replace-with-a-test-environment-token'
mvn gatling:test \
-Dgatling.simulationClass=performance.GraphqlLoadSimulation \
-DbaseUrl=https://graphql-test.example.com
A shared token measures service behavior under one identity and may hit per-user caches or throttles. If the requirement concerns many simultaneous accounts, use a protected feeder containing pre-created test tokens, or call an approved test-only authentication flow before the measured group. Do not make production login systems absorb surprise setup traffic. Never print tokens in a session dump or attach them to test artifacts.
Verify Step 5: first run without the variable and confirm the simulation stops immediately with the explicit Set GRAPHQL_API_TOKEN message. Then set a valid test token and expect both named operations to succeed. A 401 or 403 means the credential, audience, scope, or environment is wrong.
Step 6: Choose a Workload Model for Gatling GraphQL Performance Testing
Replace the smoke setup with an open workload. System properties let CI change rates without editing source:
private static final double START_RPS =
Double.parseDouble(System.getProperty("startRps", "1"));
private static final double TARGET_RPS =
Double.parseDouble(System.getProperty("targetRps", "10"));
private static final long RAMP_SECONDS =
Long.parseLong(System.getProperty("rampSeconds", "60"));
private static final long HOLD_SECONDS =
Long.parseLong(System.getProperty("holdSeconds", "180"));
{
setUp(
smoke.injectOpen(
rampUsersPerSec(START_RPS).to(TARGET_RPS).during(RAMP_SECONDS),
constantUsersPerSec(TARGET_RPS).during(HOLD_SECONDS)
)
).protocols(httpProtocol);
}
| Goal | Gatling model | Meaning | Typical use |
|---|---|---|---|
| Hold request journey arrival rate | Open | New users arrive independently of response time | Throughput target, public API traffic |
| Hold simultaneous sessions | Closed | A replacement starts as another user exits | Back-office users, fixed client population |
| Find a limit progressively | Open staircase or ramp | Arrival pressure increases by stages | Capacity exploration |
| Prove recovery | Staged profile | Load rises, drops, and settles | Autoscaling and resilience checks |
Do not translate 10 users/second into exactly 20 requests/second. Each journey has two requests plus a random pause, while failures or conditional branches can change the count. Derive the arrival profile from observed user journeys and validate actual request throughput in the report. For deeper workload design, see Gatling scenario design patterns and how to find a performance bottleneck.
Verify Step 6: execute a safe miniature run with -DstartRps=0.2 -DtargetRps=1 -DrampSeconds=10 -DholdSeconds=20. The run should last about 30 seconds plus the final users' journey time. Confirm the users-started chart follows the ramp and plateau.
Step 7: Add Gatling Response Time Assertions
Reports support investigation; assertions make the result enforceable. Chain these assertions after .protocols(httpProtocol) in the setup block:
.assertions(
global().failedRequests().percent().lt(1.0),
global().responseTime().percentile(95.0).lt(800),
details("GraphQL GetProduct").responseTime().percentile(95.0).lt(500),
details("GraphQL AddToCart").failedRequests().percent().lt(1.0)
);
Global criteria prevent the whole experience from becoming unacceptable. Operation-level criteria prevent one cheap query from hiding a slow mutation in an average. Percentiles describe distribution better than a mean for latency objectives: a p95 below 800 ms means at least 95 percent of measured responses completed below that boundary. It does not say every request was fast.
Choose targets before the test, based on user expectations and service objectives. Avoid relaxing a limit merely to make a build green. Also remember that local generator resource exhaustion can cause false failures. Observe CPU, memory, sockets, and network on the Gatling host along with application metrics.
Verify Step 7: run the miniature profile and check the console's Global: percentage of failed requests and response-time assertion lines. Maven must return zero when all criteria pass. Temporarily set the p95 limit to 1 in a local branch and verify Maven returns nonzero, then restore 800. This proves CI will detect a failed objective.
Step 8: Run the Final Test and Read the Report
Start with a baseline, then use the intended profile only after the script is functionally clean. Run the final tutorial configuration like this:
mvn gatling:test \
-Dgatling.simulationClass=performance.GraphqlLoadSimulation \
-DbaseUrl=https://graphql-test.example.com \
-DstartRps=1 \
-DtargetRps=10 \
-DrampSeconds=60 \
-DholdSeconds=180
Open target/gatling/<run-id>/index.html. Begin with KO count and error messages. A fast error is still a failure, so never celebrate latency before checking correctness. Next compare p50, p95, and p99 for GraphQL GetProduct and GraphQL AddToCart. Finally align the time-series charts with server telemetry: resolver spans, database query duration, connection-pool wait, CPU, garbage collection, cache hit rate, and downstream calls.
GraphQL needs field-level context. Gatling sees one HTTP exchange, while tracing should reveal which resolver consumed time. Preserve operationName in server logs and traces so GraphQL AddToCart in Gatling maps cleanly to backend evidence. Query depth, requested fields, list cardinality, and batching can change server work dramatically even when endpoint and status remain identical. The GraphQL query complexity security guide explains how expensive shapes can become both performance and abuse risks.
Verify Step 8: archive the HTML report and Maven exit code. Record the commit SHA, environment, data set, profile properties, API version, and backend deployment ID. Another engineer should be able to reproduce the run without asking which settings you used.
Gatling Load Test GraphQL API Interpretation Guide
A throughput plateau is not automatically server capacity. If arrivals continue rising while completed requests flatten, inspect response time, active requests, timeouts, and generator health. A rising p95 with stable CPU may indicate lock contention, a downstream dependency, database pool waiting, or rate limiting. High CPU with increasing resolver duration may indicate compute-heavy field resolution or repeated serialization.
Compare query shapes separately. A cached GetProduct lookup says little about an uncached nested search returning 100 nodes. Create named scenarios for representative operation mixes and weight them according to measured traffic. Keep administrative mutations out unless the test data lifecycle can safely reverse them. GraphQL batching also deserves its own workload because one HTTP request can contain several operations and invalidate assumptions based on request count. Review GraphQL batched request testing examples before modeling that traffic.
Watch the database, not just the API container. N+1 resolver behavior often appears as a sharp increase in queries per GraphQL operation. DataLoader-style batching may improve that pattern, but only traces and database metrics prove it. Compare performance against correctness, because an overaggressive cache can make a test quick while returning stale or cross-user data.
Treat the first successful run as a baseline, not a universal benchmark. Virtualization, noisy neighbors, warm caches, network distance, and data volume all affect results. Compare like-for-like runs and document environmental changes.
Troubleshooting
Problem: HTTP 200 responses are counted OK even though the response contains resolver errors. -> Add jsonPath("$.errors").notExists() to every GraphQL request. Also assert a required field under data; some servers may return partial data alongside errors.
Problem: the server says an Int! variable received a string. -> Keep numeric feeder substitutions unquoted in the JSON body, as shown by "quantity": #{quantity}. Validate the CSV contains only integer text and no blank row.
Problem: No attribute named productId is defined appears. -> Confirm the query uses .saveAs("productId"), the mutation uses the identical case-sensitive key, and the query returned a product. Insert exitHereIfFailed() after the query to stop invalid dependent calls.
Problem: every request returns 401 or 403 under load. -> Verify the token against the same base URL with one curl request. Check expiry, audience, scopes, clock skew, and whether a shared identity triggers authorization throttles. Do not disable authentication to make the graph look clean.
Problem: Gatling becomes CPU-bound before the API does. -> Reduce verbose logging, avoid large response bodies, monitor the injector, and distribute the test when one generator cannot produce the required traffic. Run Gatling separately from the system under test so they do not compete for resources.
Problem: local runs pass but CI cannot find the feeder. -> Store it under src/test/resources and reference the classpath name data/products.csv. Match filename case because Linux CI filesystems are usually case-sensitive.
Interview Questions and Answers
The strongest interview answers connect GraphQL semantics to performance engineering. Be ready to explain why HTTP status alone is insufficient, how operation names improve diagnosis, how you correlate IDs, and why arrival rate differs from concurrency. The model answers in the interviewQnA section below cover these points without assuming Gatling has a GraphQL-specific protocol.
Best Practices
- Run a one-user functional smoke before any load. Load only amplifies a broken script.
- Name requests after GraphQL operations, not the shared
/graphqlpath. - Send variables separately from the query document and vary data intentionally.
- Check
errors, required response fields, and business outcomes, not only status 200. - Correlate session state and stop a journey when a prerequisite request fails.
- Separate warm-up from measured steady state when your execution platform supports that analysis.
- Monitor the load generator, GraphQL service, resolvers, databases, caches, and downstreams on one timeline.
- Keep secrets in environment or CI secret stores and sanitize reports and logs.
- Version the simulation with the application and review workload changes like production code.
- Test only environments and traffic ceilings authorized by the service owner.
Where To Go Next
Start by adapting the two operations to your schema, preserving named operations and the errors checks. Establish a low-rate baseline, obtain approved objectives, and increase traffic in controlled stages. If you are still learning the tool, work through Gatling basics for testers. Compare alternative JVM and JavaScript-oriented approaches in JMeter vs Gatling, then study the load testing guide for capacity, endurance, spike, and stress-test planning.
Once the HTTP query and mutation path is stable, create separate coverage for subscriptions instead of forcing WebSocket behavior into this simulation. The GraphQL subscriptions testing tutorial explains the different connection lifecycle. Keep each test narrow enough that its report answers a specific capacity question.
Conclusion
A useful Gatling GraphQL test combines ordinary HTTP mechanics with GraphQL-aware validation. Send named operations and variables, reject response-level errors, correlate returned values, model a justified workload, and enforce objectives with assertions. Those choices turn a script that merely generates traffic into evidence a team can act on.
Run the one-user journey first, then the miniature ramp, and only then the approved target profile. Archive the report beside server telemetry and configuration so the next result is a meaningful comparison rather than an isolated chart.
Interview Questions and Answers
How would you load test a GraphQL API with Gatling?
I would use Gatling's HTTP DSL to POST named GraphQL operations with a query and variables object. Feed representative variable data, check HTTP status plus the absence of GraphQL errors, and correlate response IDs into dependent mutations. I would apply a production-derived workload and enforce agreed error and percentile objectives with assertions.
Why is checking HTTP 200 insufficient for GraphQL?
GraphQL can successfully receive and parse a request but return resolver, authorization, or validation information in an `errors` array. Depending on the server policy, the HTTP response may still be 200 and may contain partial data. I therefore check the errors field and required business data separately.
How do you correlate data between GraphQL operations in Gatling?
I extract a value with a JSONPath check and `saveAs`, which stores it in that virtual user's session. A later body references the same session key through Gatling Expression Language. I stop the journey after a failed prerequisite so it does not send misleading follow-up requests.
Why should Gatling request names match GraphQL operation names?
All operations often share one `/graphql` URL, so endpoint-based naming combines unrelated resolver paths. Operation-based names produce separate latency and failure statistics for queries and mutations. They also align better with backend logs and distributed traces.
What is the difference between open and closed injection models?
An open model schedules new arrivals independently of response completion, which fits external demand expressed as users per second. A closed model maintains a concurrent population and starts replacements as users finish. Under saturation, the models behave differently, so I select one from the business traffic model.
How would you identify an N+1 resolver problem during the test?
I would correlate rising operation latency with resolver spans and database query counts per GraphQL request. An N+1 pattern often shows database calls growing with returned list size even when the HTTP request count remains stable. I would compare query shapes and cardinalities, then verify any batching fix with the same data and load profile.
How do you prevent caching from making a GraphQL test unrealistic?
I use a controlled feeder with representative hot and cold keys rather than repeating one variable. I track cache hit ratios and label the intended distribution in the test record. I also separate cache-warm and cache-cold questions because they measure different system behavior.
Frequently Asked Questions
Can Gatling load test a GraphQL API?
Yes. Gatling sends GraphQL queries and mutations through its HTTP DSL, normally as JSON POST bodies. Add checks for the GraphQL errors field and expected data because an HTTP 200 response can still contain execution failures.
Does Gatling have a dedicated GraphQL protocol?
A dedicated protocol is not required for queries and mutations over HTTP. Use Gatling's HTTP request builder, StringBody or a resource body, JSONPath checks, feeders, and session correlation. Subscriptions need separate WebSocket lifecycle modeling.
How do I pass GraphQL variables in Gatling?
Place variables in the JSON envelope's variables object and insert feeder or session values with Gatling Expression Language such as `#{sku}`. Preserve JSON types, so do not quote a substitution when the GraphQL variable is an integer.
How do I detect GraphQL errors when the status is 200?
Combine `status().is(200)` with `jsonPath("$.errors").notExists()` and a check for required data. This distinguishes HTTP transport success from GraphQL execution and business success.
Should a GraphQL load test use open or closed workload injection?
Use an open model when arrivals occur independently and the requirement is an arrival rate. Use a closed model when the requirement defines a fixed concurrent population. Choose from the production traffic model, not from whichever graph looks smoother.
What GraphQL metrics should I assert in Gatling?
Assert failed-request percentage and meaningful latency percentiles globally and for critical named operations. Pair client metrics with resolver traces, database timing, pool waits, cache behavior, and injector utilization to explain failures.
How many virtual users should I use for a GraphQL load test?
There is no universal number. Derive arrivals or concurrency from observed demand, growth expectations, and an agreed safety margin, then increase gradually in an authorized environment. Validate the achieved request mix because one virtual user can execute several operations.