
Introduction
GraphQL gives developers a flexible way to query exactly the data they need, but that flexibility can also make performance testing more complicated than with traditional REST APIs. A single GraphQL endpoint can serve dozens of query shapes, deeply nested fields, expensive resolvers, and write-heavy mutations. That means a GraphQL API that looks healthy under light traffic can quickly develop bottlenecks under real-world concurrency.
Load testing GraphQL APIs helps you understand how your schema, resolvers, database access patterns, caching layers, and authentication flows behave under pressure. Whether you are validating a new schema design, preparing for a product launch, or trying to prevent resolver-level slowdowns, performance testing is essential.
In this guide, you’ll learn how to load test GraphQL APIs with LoadForge using realistic Locust scripts. We’ll cover basic queries, authenticated traffic, mutations, and mixed user journeys that better reflect production behavior. Along the way, we’ll look at common GraphQL performance bottlenecks and how to analyze your results using LoadForge’s cloud-based infrastructure, distributed testing, real-time reporting, and global test locations.
Prerequisites
Before you begin load testing your GraphQL API, make sure you have:
- A GraphQL endpoint, such as:
https://api.example.com/graphqlhttps://staging.example.com/graphql
- At least one valid GraphQL schema with queries and mutations to test
- Test credentials for authenticated requests
- A non-production environment when possible, especially for mutation and stress testing
- Basic familiarity with:
- GraphQL queries and mutations
- HTTP headers and bearer token authentication
- Locust and LoadForge
You should also gather a few key details before writing your test:
- The most important GraphQL operations in your application
- Expected traffic patterns
- Authentication requirements
- Common query depths and field selections
- Known expensive resolvers or database-heavy operations
- Rate limits, query complexity limits, or persisted query settings
For this guide, we’ll assume an e-commerce GraphQL API with operations such as:
viewerproductsearchProductsaddToCartcheckoutPreview
These are realistic operations that often involve joins, authorization, caching, and database access.
Understanding GraphQL Under Load
GraphQL behaves differently from REST during load testing because many operations are routed through a single endpoint. Instead of testing many URLs, you often test one URL with many different payloads. That creates unique performance testing challenges.
Why GraphQL performance can degrade under load
Common GraphQL bottlenecks include:
- Resolver waterfalls and N+1 query problems
- Deeply nested queries that trigger excessive backend calls
- Expensive field resolvers for computed or aggregated data
- Poorly cached queries
- Authentication overhead on every request
- Mutation contention on shared resources like carts, inventory, or orders
- Query parsing and validation cost for large dynamic payloads
What to test in a GraphQL API
A strong GraphQL load testing strategy should cover:
- High-frequency lightweight queries
- Search queries with filters and pagination
- Deep nested queries
- Authenticated user flows
- Mutations that update state
- Mixed workloads that combine reads and writes
- Error handling under concurrency
- Query complexity and response size behavior
Key GraphQL metrics to watch
When load testing GraphQL APIs, focus on:
- Response time by operation type
- P95 and P99 latency for key queries and mutations
- Requests per second
- Error rate
- Timeouts
- Resolver-heavy query latency
- Performance differences between shallow and deep queries
- Infrastructure saturation such as CPU, memory, and database connections
LoadForge makes this easier by letting you run distributed load testing from multiple regions and analyze results in real time, which is especially useful if your GraphQL API is fronted by a CDN, API gateway, or regional edge network.
Writing Your First Load Test
Let’s start with a basic GraphQL query load test. This example sends a product query to a typical GraphQL endpoint and validates the response.
Basic GraphQL query load test
from locust import HttpUser, task, between
class GraphQLBasicUser(HttpUser):
wait_time = between(1, 3)
def on_start(self):
self.headers = {
"Content-Type": "application/json",
"Accept": "application/json"
}
@task
def get_product_details(self):
payload = {
"operationName": "GetProductDetails",
"query": """
query GetProductDetails($slug: String!) {
product(slug: $slug) {
id
slug
name
sku
price {
amount
currency
}
availability
category {
id
name
}
}
}
""",
"variables": {
"slug": "running-shoe-001"
}
}
with self.client.post(
"/graphql",
json=payload,
headers=self.headers,
name="GraphQL Query - GetProductDetails",
catch_response=True
) as response:
if response.status_code != 200:
response.failure(f"Unexpected status code: {response.status_code}")
return
data = response.json()
if "errors" in data:
response.failure(f"GraphQL errors: {data['errors']}")
return
product = data.get("data", {}).get("product")
if not product or product["slug"] != "running-shoe-001":
response.failure("Product data missing or invalid")
else:
response.success()What this script does
This script simulates a user repeatedly fetching a product record from /graphql. It includes:
- A realistic GraphQL query with variables
- JSON request formatting
- Response validation for both HTTP-level and GraphQL-level errors
- A friendly request name for easier reporting in LoadForge
Why this matters
Unlike REST, a 200 OK response does not always mean success in GraphQL. GraphQL often returns errors inside the response body, so your load test should inspect the errors field and not rely on HTTP status codes alone.
This first test is useful for:
- Baseline performance testing
- Measuring latency for a common read operation
- Comparing performance before and after schema or resolver changes
Advanced Load Testing Scenarios
Once you have a basic query working, the next step is to simulate production-like behavior. Real users authenticate, search, paginate, add items to carts, and trigger mutations. Below are several more advanced GraphQL load testing examples.
Authenticated GraphQL queries with JWT login
Many GraphQL APIs require authentication for user-specific fields like profile data, orders, and saved payment methods. In this example, users log in through a REST auth endpoint, then use the JWT token for GraphQL queries.
from locust import HttpUser, task, between
import random
class AuthenticatedGraphQLUser(HttpUser):
wait_time = between(1, 2)
credentials = [
{"email": "alice@example.com", "password": "TestPass123!"},
{"email": "bob@example.com", "password": "TestPass123!"},
{"email": "carol@example.com", "password": "TestPass123!"}
]
def on_start(self):
user = random.choice(self.credentials)
login_response = self.client.post(
"/auth/login",
json={
"email": user["email"],
"password": user["password"]
},
headers={"Content-Type": "application/json"},
name="Auth Login"
)
if login_response.status_code != 200:
raise Exception(f"Login failed: {login_response.status_code}")
token = login_response.json().get("access_token")
if not token:
raise Exception("No access token returned")
self.headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"Authorization": f"Bearer {token}"
}
@task(3)
def get_viewer_profile(self):
payload = {
"operationName": "GetViewerProfile",
"query": """
query GetViewerProfile {
viewer {
id
email
firstName
lastName
loyaltyPoints
defaultShippingAddress {
city
state
postalCode
country
}
}
}
"""
}
with self.client.post(
"/graphql",
json=payload,
headers=self.headers,
name="GraphQL Query - ViewerProfile",
catch_response=True
) as response:
data = response.json()
if response.status_code != 200 or "errors" in data:
response.failure(f"Viewer profile failed: {data}")
else:
response.success()
@task(2)
def get_order_history(self):
payload = {
"operationName": "GetOrderHistory",
"query": """
query GetOrderHistory($first: Int!) {
viewer {
orders(first: $first) {
edges {
node {
id
orderNumber
createdAt
total {
amount
currency
}
status
}
}
}
}
}
""",
"variables": {
"first": 10
}
}
with self.client.post(
"/graphql",
json=payload,
headers=self.headers,
name="GraphQL Query - OrderHistory",
catch_response=True
) as response:
data = response.json()
if response.status_code != 200 or "errors" in data:
response.failure(f"Order history failed: {data}")
else:
response.success()Why this scenario is important
Authenticated GraphQL traffic is often slower than public queries because it may involve:
- Token validation
- Permission checks
- User-specific resolver logic
- More cache misses
- More personalized data assembly
This is exactly the kind of workload you should include in a serious GraphQL performance testing plan.
Search and pagination load testing
Search is one of the most common GraphQL bottlenecks because it often hits databases, search indexes, filters, facets, sorting logic, and pagination. This example simulates users searching products with various terms and pages.
from locust import HttpUser, task, between
import random
class GraphQLSearchUser(HttpUser):
wait_time = between(1, 3)
search_terms = ["shoes", "jackets", "backpacks", "water bottle", "wireless earbuds"]
sort_options = ["RELEVANCE", "PRICE_ASC", "PRICE_DESC", "NEWEST"]
def on_start(self):
self.headers = {
"Content-Type": "application/json",
"Accept": "application/json"
}
@task
def search_products(self):
term = random.choice(self.search_terms)
sort = random.choice(self.sort_options)
page = random.randint(1, 5)
payload = {
"operationName": "SearchProducts",
"query": """
query SearchProducts($term: String!, $page: Int!, $pageSize: Int!, $sort: ProductSort!) {
searchProducts(term: $term, page: $page, pageSize: $pageSize, sort: $sort) {
totalCount
pageInfo {
currentPage
totalPages
hasNextPage
}
items {
id
name
slug
brand
price {
amount
currency
}
thumbnailUrl
rating
}
}
}
""",
"variables": {
"term": term,
"page": page,
"pageSize": 24,
"sort": sort
}
}
with self.client.post(
"/graphql",
json=payload,
headers=self.headers,
name="GraphQL Query - SearchProducts",
catch_response=True
) as response:
if response.status_code != 200:
response.failure(f"HTTP {response.status_code}")
return
data = response.json()
if "errors" in data:
response.failure(f"GraphQL errors: {data['errors']}")
return
search_results = data.get("data", {}).get("searchProducts", {})
if "items" not in search_results:
response.failure("Missing search results")
else:
response.success()What this test reveals
This scenario helps identify:
- Slow search resolvers
- Pagination inefficiencies
- Expensive sort operations
- Backend pressure from large result sets
- Cache effectiveness for repeated searches
It also gives you a more realistic picture than simply hammering one static query.
Mixed read/write GraphQL workflow with mutations
GraphQL mutations are especially important to test because they often involve transactions, inventory updates, cart state, and validation logic. This example simulates a user journey where an authenticated customer browses a product, adds it to a cart, and requests a checkout preview.
from locust import HttpUser, task, between
import random
class GraphQLCartUser(HttpUser):
wait_time = between(1, 2)
products = [
{"slug": "running-shoe-001", "quantity": 1},
{"slug": "gym-bag-204", "quantity": 2},
{"slug": "water-bottle-550", "quantity": 1}
]
def on_start(self):
login_response = self.client.post(
"/auth/login",
json={
"email": "shopper@example.com",
"password": "TestPass123!"
},
headers={"Content-Type": "application/json"},
name="Auth Login"
)
token = login_response.json().get("access_token")
self.headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"Authorization": f"Bearer {token}"
}
@task(2)
def browse_product(self):
product = random.choice(self.products)
payload = {
"operationName": "GetProductForCart",
"query": """
query GetProductForCart($slug: String!) {
product(slug: $slug) {
id
slug
name
sku
availability
price {
amount
currency
}
}
}
""",
"variables": {
"slug": product["slug"]
}
}
self.client.post(
"/graphql",
json=payload,
headers=self.headers,
name="GraphQL Query - ProductForCart"
)
@task(2)
def add_to_cart(self):
product = random.choice(self.products)
payload = {
"operationName": "AddToCart",
"query": """
mutation AddToCart($input: AddToCartInput!) {
addToCart(input: $input) {
cart {
id
totalItems
subtotal {
amount
currency
}
items {
id
quantity
product {
id
name
sku
}
}
}
userErrors {
field
message
}
}
}
""",
"variables": {
"input": {
"sku": product["slug"],
"quantity": product["quantity"]
}
}
}
with self.client.post(
"/graphql",
json=payload,
headers=self.headers,
name="GraphQL Mutation - AddToCart",
catch_response=True
) as response:
data = response.json()
if response.status_code != 200 or "errors" in data:
response.failure(f"Add to cart failed: {data}")
return
user_errors = data.get("data", {}).get("addToCart", {}).get("userErrors", [])
if user_errors:
response.failure(f"Mutation user errors: {user_errors}")
else:
response.success()
@task(1)
def checkout_preview(self):
payload = {
"operationName": "CheckoutPreview",
"query": """
query CheckoutPreview {
viewer {
cart {
id
totalItems
subtotal {
amount
currency
}
estimatedTax {
amount
currency
}
shippingOptions {
id
label
price {
amount
currency
}
}
grandTotal {
amount
currency
}
}
}
}
"""
}
self.client.post(
"/graphql",
json=payload,
headers=self.headers,
name="GraphQL Query - CheckoutPreview"
)Why mixed workflows matter
This is much closer to real production traffic than isolated query testing. It helps you evaluate:
- Mutation latency under concurrency
- Cart locking or inventory contention
- Data consistency issues
- The performance impact of stateful workflows
- How read and write operations interact under load
In LoadForge, you can scale this across many concurrent users and distributed generators to simulate realistic shopping traffic from multiple regions.
Analyzing Your Results
After running your GraphQL load test, the next step is understanding what the results actually mean.
Focus on operation-level performance
Because GraphQL usually uses one endpoint, the URL alone is not enough for analysis. Name your requests clearly in Locust, such as:
GraphQL Query - GetProductDetailsGraphQL Query - SearchProductsGraphQL Mutation - AddToCart
This lets LoadForge break out response times by operation, making it much easier to identify problematic queries and mutations.
Pay attention to percentile latency
Average response time can hide serious issues. Look closely at:
- P50 for normal user experience
- P95 for degraded experience under load
- P99 for severe outliers and bottlenecks
If SearchProducts has a reasonable average but a very high P95, you may have intermittent backend slowness, cache misses, or database contention.
Separate HTTP errors from GraphQL errors
A GraphQL API may return:
- HTTP 200 with GraphQL
errors - HTTP 429 due to rate limiting
- HTTP 500 from server exceptions
- Timeouts from overloaded resolvers or upstream dependencies
Your test scripts should capture both transport-level and application-level failures. This is critical for accurate performance testing.
Compare query shapes
Not all GraphQL operations cost the same. Compare:
- Shallow vs. nested queries
- Public vs. authenticated queries
- Read vs. write operations
- Cached vs. uncached queries
- Small vs. large page sizes
These comparisons often reveal where your schema design or resolver implementation needs improvement.
Use LoadForge features for deeper insight
LoadForge is particularly useful for GraphQL load testing because you can:
- Run distributed load tests from multiple geographic regions
- Monitor results in real time while a test is running
- Scale to high concurrency using cloud-based infrastructure
- Integrate tests into CI/CD pipelines to catch regressions early
For example, if a schema change introduces a slow resolver, you can catch that in staging before it affects production users.
Performance Optimization Tips
Once your GraphQL load testing identifies slow operations, these optimization strategies can help.
Reduce N+1 query problems
Use batching and caching tools like DataLoader to avoid repeated database lookups from nested resolvers.
Limit query complexity
Enforce:
- Maximum depth
- Maximum complexity score
- Field-level cost limits
This helps prevent abusive or accidentally expensive queries from overwhelming your API.
Optimize resolver logic
Review slow resolvers for:
- Repeated external API calls
- Inefficient joins
- Unnecessary field computation
- Missing indexes
- Poor pagination implementation
Cache wisely
Consider caching at several levels:
- Persisted queries
- Resolver-level caching
- CDN or edge caching for public queries
- Search result caching
- Application object caching
Use pagination consistently
Avoid returning large collections without limits. Cursor-based or page-based pagination helps control response size and backend workload.
Test realistic payloads
Don’t only test tiny queries. Include realistic field selections, nested objects, and pagination sizes so your load testing reflects production behavior.
Common Pitfalls to Avoid
GraphQL performance testing is easy to get wrong if you use unrealistic scripts or overlook GraphQL-specific behavior.
Treating GraphQL like a simple REST endpoint
If you only measure /graphql as one generic URL, you won’t know which operations are slow. Always name requests by operation.
Ignoring GraphQL errors in 200 responses
A 200 OK response can still contain application errors. Always inspect the response body.
Testing only one query
Production traffic is a mix of queries and mutations. A single-operation test won’t reveal real bottlenecks.
Using unrealistic static data
If every virtual user requests the same payload, you may overestimate cache performance and underestimate backend load.
Running mutation tests against production without safeguards
Mutations can create carts, orders, or other side effects. Use isolated test accounts and staging environments whenever possible.
Overlooking authentication overhead
Authenticated traffic often behaves very differently from public traffic. Include login flows or token usage in your tests.
Not correlating API results with backend metrics
Load testing tells you what is slow, but backend monitoring tells you why. Correlate GraphQL latency with database, CPU, memory, and cache metrics.
Conclusion
GraphQL APIs offer flexibility and developer productivity, but they also introduce unique load testing challenges. Queries vary in complexity, mutations can create contention, and a single endpoint can hide multiple performance bottlenecks. By using realistic Locust scripts in LoadForge, you can test the GraphQL operations that matter most, uncover slow resolvers, validate concurrency handling, and improve overall API reliability.
Whether you’re validating product queries, authenticated user flows, search performance, or cart mutations, LoadForge gives you the tools to run scalable load testing with distributed infrastructure, real-time reporting, global test locations, and CI/CD integration.
If you’re ready to load test your GraphQL API with realistic traffic patterns and actionable performance insights, try LoadForge and start building faster, more resilient GraphQL services.
LoadForge Team
LoadForge is a load and performance testing platform built on Locust. Our team has been shipping load tests against production systems since 2018, and we write these guides from real customer engagements.
Related guides
Keep going with more guides from the same category.

How to Load Test API Rate Limiting with LoadForge
Test API rate limiting with LoadForge to verify throttling rules, retry behavior, and service stability during traffic spikes.

Load Testing API Gateways with LoadForge
Discover how to load test API gateways with LoadForge to measure routing performance, latency, and resilience under heavy traffic.

Load Testing HTTP/2 Applications with LoadForge
Learn how to load test HTTP/2 applications with LoadForge to measure multiplexing performance, latency, and connection efficiency.