How to load test a GraphQL API: a practical guide
6 min read
To load test a GraphQL API, script the operations your clients really send, give each one its own name in the test tool and mix them in the proportions you see in production, with varied variables. Check every response body for an errors array, because a GraphQL server usually answers with HTTP 200 even when part of an operation failed. Then ramp up the load and judge each operation by its own percentiles.
Sources: graphql.org, the draft GraphQL over HTTP specification and each tool’s own documentation, all checked on 9 October 2026.
| Tool | Name each operation with | Fail a response that has errors | Feed variables from |
|---|---|---|---|
| k6 | tags: { name: "..." } | A check() on the parsed body | SharedArray |
| JMeter | The sampler name | JSON Assertion on $.errors, inverted | CSV Data Set Config |
| Gatling | http("...") or the GraphQL plugin | jsonPath("$.errors").notExists() | Feeders |
| Artillery | A request name | notHasProperty: "errors" | config.payload |
Why does a GraphQL API need a different load test?
Because every operation goes to one URL, and the work behind it depends on the query and its variables. A report grouped by URL puts a cheap lookup and an expensive search in the same bucket.
graphql.org’s Serving over HTTP guide says a GraphQL server operates on a single endpoint, usually /graphql. Operations arrive there by POST with a JSON body holding query, an optional operationName and variables.
How do you get results per operation?
Name every request after its GraphQL operation and make the tool group its statistics by that name. Each tool has its own switch for this (checked on 9 October 2026).
- k6: the tags and groups guide says to set
tags.nameto give a request a meaningful name, and thresholds can target one tag, such ashttp_req_duration{name:GetProduct}. - JMeter: the Aggregate Report creates one row per differently named request. Since JMeter 5.4 the GraphQL HTTP Request sampler has Query, Variables and Operation Name fields.
- Gatling: the request name is the key for its statistics, and the GraphQL plugin names requests after the operation, such as
query GetUser. - Artillery: the metrics-by-endpoint plugin reports per URL and adds a request’s
nameto its entry, so named operations are reported separately;useOnlyRequestNames: truedrops the URL and keeps the name alone.
Which operations should the test send?
The ones production receives, in the same proportions. Take the operation counts from your server logs or GraphQL monitoring and turn them into a weighted mix of reads and mutations.
Use the documents your clients ship, at their real depth, rather than one simplified query. Feed variables from a data file, so the server looks up many different records instead of returning one cached result. Keep the number of distinct operations close to reality too: Apollo’s technote on load testing GraphQL, a vendor document, explains that each new operation is parsed and validated before it runs, and warns against generating far more unique operations than real clients send.
Mutations change data, so run them where the data can be reset. The step-by-step load testing guide covers goals and ramp-up.
Why can an HTTP 200 response still be a failure?
Because GraphQL reports errors inside the response body. A request can return status 200 with an errors array and partial data, and a status check passes it.
graphql.org’s Response page explains that when a resolver fails, execution continues and the response carries data alongside errors. Its HTTP guide adds that such a response should still get a 2xx status, since HTTP has no code for partial success, and that some legacy servers answer 2xx even for validation errors. The GraphQL over HTTP draft proposes a custom 294 status for partial success with the newer application/graphql-response+json media type.
In a load test this hurts twice. Errors go uncounted, and requests that fail fast, such as operations rejected by a depth limit, pull the response times down.
How do you check the errors array in each tool?
Parse the JSON body and fail the request when errors is present. Each tool has a built-in way.
In k6, a check on the parsed body does it, and a threshold on the checks metric fails the run, because checks alone do not change k6’s exit code:
import http from "k6/http";
import { check, sleep } from "k6";
import { SharedArray } from "k6/data";
const URL = "https://staging.example.com/graphql";
const ids = new SharedArray("ids", () =>
JSON.parse(open("./product-ids.json")));
const QUERY = `query GetProduct($id: ID!) {
product(id: $id) { id name price }
}`;
export const options = {
vus: 20,
duration: "5m",
thresholds: {
checks: ["rate>0.99"],
"http_req_duration{name:GetProduct}": ["p(95)<400"],
},
};
function parse(res) {
try { return res.json(); } catch (e) { return null; }
}
export default function () {
const id = ids[Math.floor(Math.random() * ids.length)];
const payload = JSON.stringify({
query: QUERY,
operationName: "GetProduct",
variables: { id: id },
});
const res = http.post(URL, payload, {
headers: { "Content-Type": "application/json" },
tags: { name: "GetProduct" },
});
const body = parse(res);
check(res, {
"status is 2xx": (r) => r.status >= 200 && r.status < 300,
"no GraphQL errors": () => body !== null && !body.errors,
});
sleep(1);
}
SharedArray loads the IDs once for all virtual users. The try stops a body that is not JSON, such as a proxy’s error page, from ending the iteration.
In JMeter, add a JSON Assertion with the path $.errors and tick “Invert assertion”, so the sample fails when the path exists. A JSON Extractor copies IDs into variables for the next operation.
In Gatling, add .check(jsonPath("$.errors").notExists()), both from its checks reference. The GraphQL plugin checks the status and the errors by default, but its setup page limits Community Edition runs to 5 users and 5 minutes (checked on 9 October 2026). For bigger tests without Gatling Enterprise, use plain HTTP requests with the check.
In Artillery, the expect plugin offers notHasProperty: "errors", and reportFailuresAsErrors: true puts failed expectations in the report. A capture with a json path carries a value into the next request.
What should you watch besides response times?
Caching, persisted queries and database load per request. Each can make a test faster or slower than production for reasons unrelated to capacity.
graphql.org’s performance guide notes that queries sent by GET can use HTTP and CDN caching, and that persisted queries let a client send a hash instead of the full document. If your clients do that, the test should too. If they do not, a test repeating one query with the same variables measures a cache that real traffic misses. Apollo’s technote adds that Apollo Server, Gateway and the GraphOS Router enable automatic persisted queries by default.
The same guide describes the N+1 problem: a list whose resolver loads each item separately turns one request into many database queries, usually fixed by batching with a tool like DataLoader. Watch database queries per second next to requests per second; if they climb much faster as the load rises, suspect a resolver. Depth limits, complexity limits and rate limiting can also trip during a test.
Where should the load come from?
From one machine while you build and smoke test the script, then from the cloud when you need more load or traffic from several regions.
LoadFocus runs k6 scripts from 25 cloud regions. There, the number of users and the duration come from the test settings rather than the script’s options, and arrival-rate scenarios run with a fixed number of users instead of a fixed request rate, the difference covered in open vs closed workload models. LoadFocus also publishes a template for GraphQL endpoints with complex queries.
FAQ
Should a GraphQL load test send queries by GET or POST? Send them the way your clients do. graphql.org allows GET for queries only, and mutations must use POST. If production sends persisted queries by GET through a CDN, test that path too.
Can the same script load test GraphQL subscriptions? Usually not. graphql.org notes that subscriptions are long-lived and often run over WebSockets or server-sent events, so they need their own scenario in a tool that supports that transport.
Should a partial response count as a failed request? In most tests, yes, because the client got an error for part of its request. If some errors are expected, such as not-found results for deleted records, match those error codes and fail on the rest.
Related reading
- How to run a load test: a step-by-step guide
- Load testing in GitHub Actions: a practical guide
- WebSocket load testing tools: six options compared
- gRPC load testing: tools, setup and what to watch
The technical claims in this article have not been independently reviewed.