multiple queries, one single HTTP roundtrip
A dashboard that simultaneously loads user data, order history and product recommendations fires three separate HTTP requests without batching, each with its own TLS handshake and its own latency. GraphQL batching bundles such independent operations into a single HTTP request, reducing the number of roundtrips without changing the schema or the resolver logic.
Table of Contents
- 1. Why many requests are unnecessary in GraphQL
- 2. What a bundled HTTP request looks like
- 3. Client-side batching with Apollo Link Batch
- 4. Implementing batching server-side
- 5. Batching vs. DataLoader: two different layers
- 6. HTTP/2 multiplexing as an alternative to batching
- 7. Error handling for bundled requests
- 8. Where batching hits its limits
- 9. Batching strategies compared
- 10. Summary
- 11. FAQ
1. Why many requests are unnecessary in GraphQL
GraphQL already allows combining multiple fields in a single query out of the box, yet in practice multiple separate HTTP requests often still occur. This typically happens when independent UI components each trigger their own query, for example a cart widget, a product recommendation and a user profile badge that all load simultaneously during page load. Without GraphQL batching, the client fires off a separate HTTP request for each of these components, each with its own TLS handshake, its own headers and its own network latency.
This overhead adds up especially on mobile connections with high latency: even if each individual query is answered server-side in a few milliseconds, the round-trip time of every single request stacks up instead of running in parallel or bundled. GraphQL batching solves this problem by combining multiple independent operations into a single HTTP request, which the server processes together and returns as a bundled response.
2. What a bundled HTTP request looks like
Technically, GraphQL batching is remarkably simple: instead of a single JSON object in the request body with the fields query, variables and operationName, the client sends a JSON array of multiple such objects. The server recognizes the array format as a batch, processes each operation individually, and responds with an array as well, in an order that exactly matches the order of the requests. This convention is not part of the official GraphQL specification, but has become the de facto standard through Apollo.
Important for implementing GraphQL batching: each operation in the batch is validated and executed independently, a failure in one operation does not affect the others. The server therefore still has to check every operation in the array individually for authorization and syntactic correctness, and must not discard the entire batch just because a single operation fails.
[
{
"query": "query GetCart($cartId: ID!) { cart(id: $cartId) { items { id name price } } }",
"variables": { "cartId": "abc123" }
},
{
"query": "query GetRecommendations($productId: ID!) { recommendations(productId: $productId) { id name } }",
"variables": { "productId": "xyz789" }
},
{
"query": "query GetProfile { me { firstName loyaltyPoints } }"
}
]
3. Client-side batching with Apollo Link Batch
Apollo Client offers BatchHttpLink as a built-in solution for GraphQL batching, working transparently in the background. Instead of immediately sending every useQuery call as its own HTTP request, BatchHttpLink collects all requests triggered within a configurable time window, usually ten milliseconds, and sends them bundled as a single array. From the perspective of the React components, nothing changes: useQuery and useMutation work unchanged, batching happens entirely transparently at the transport layer.
This transparency is one of the biggest advantages of GraphQL batching over manually consolidating queries in frontend code: development teams do not need to restructure their components or artificially merge queries just to save network overhead. Every component keeps its own, focused query, while the batching logic at the transport layer automatically handles the bundling.
// apollo-client.ts — batching multiple independent queries transparently
import { ApolloClient, InMemoryCache } from '@apollo/client';
import { BatchHttpLink } from '@apollo/client/link/batch-http';
const batchLink = new BatchHttpLink({
uri: 'https://api.mironsoft.de/graphql',
batchMax: 10, // batch up to 10 operations per request
batchInterval: 20, // wait up to 20ms to collect operations
});
export const client = new ApolloClient({
link: batchLink,
cache: new InMemoryCache(),
});
// Component code stays unchanged — batching is fully transparent
function Dashboard() {
const { data: cart } = useQuery(GET_CART, { variables: { cartId } });
const { data: recs } = useQuery(GET_RECOMMENDATIONS, { variables: { productId } });
const { data: profile } = useQuery(GET_PROFILE);
// All three queries are combined into a single HTTP request automatically
}
4. Implementing batching server-side
On the server side, GraphQL batching must be explicitly supported, since a normal GraphQL server by default only expects a single operation object in the request body. Apollo Server automatically detects array bodies and processes every operation in the batch in parallel, unless there is an explicit reason for sequential processing. It matters that every operation still gets its own GraphQL context, so that authorization checks, for instance, run correctly and independently per operation.
For custom-built GraphQL servers or frameworks without built-in batching support, the logic can usually be retrofitted with a few lines of middleware: the request handler checks whether the body is an array and iterates over the individual operations as needed, instead of assuming a single operation. The challenge lies less in parsing the array than in correctly isolating errors between operations within the same GraphQL batching request.
// batch-middleware.js — minimal batching support for a custom GraphQL server
async function handleGraphQLRequest(req, res) {
const body = req.body;
const isBatch = Array.isArray(body);
const operations = isBatch ? body : [body];
const results = await Promise.all(
operations.map(async (op) => {
try {
// Each operation gets its own fresh context (auth, dataloaders, etc.)
const context = await createContext(req);
return await executeGraphQL(schema, op.query, op.variables, context);
} catch (error) {
// Isolate failures — one bad operation must not break the whole batch
return { errors: [{ message: error.message }] };
}
})
);
res.json(isBatch ? results : results[0]);
}
5. Batching vs. DataLoader: two different layers
A common misunderstanding is confusing GraphQL batching with DataLoader batching, even though both solve different problems. HTTP batching, as described in this article, bundles multiple complete, independent GraphQL operations into one request to save network overhead. DataLoader batching, by contrast, solves the N+1 problem within a single query: if a resolver were to trigger a separate database query for every element of a list, DataLoader collects these individual lookups within an event loop tick and executes them as one bundled database query.
Both techniques complement each other, but solve different bottlenecks: HTTP batching reduces the number of network roundtrips between client and server, DataLoader batching reduces the number of database queries within a single request. A production setup with GraphQL batching at the transport layer almost always benefits additionally from DataLoader at the resolver layer, since both optimizations address different points in the request pipeline.
// dataloader.js — batches per-request DB lookups, separate from HTTP batching
import DataLoader from 'dataloader';
function createProductLoader(db) {
return new DataLoader(async (productIds) => {
// A single query for all IDs collected within this event loop tick
const rows = await db.query(
'SELECT * FROM products WHERE id = ANY($1)',
[productIds]
);
const byId = new Map(rows.map((row) => [row.id, row]));
return productIds.map((id) => byId.get(id) ?? null);
});
}
// Resolver stays simple — DataLoader handles the batching transparently
const resolvers = {
Product: {
relatedProducts: (product, _args, { loaders }) =>
loaders.product.loadMany(product.relatedProductIds),
},
};
6. HTTP/2 multiplexing as an alternative to batching
With HTTP/2 and its built-in multiplexing, the question arises whether GraphQL batching is even necessary anymore, since multiple parallel requests can run over the same TCP connection without additional handshake overhead. Indeed, HTTP/2 eliminates much of the classic overhead problem of many individual HTTP/1.1 requests, in particular the repeated TLS handshake and the limited number of parallel connections per domain.
Still, GraphQL batching stays useful even with HTTP/2 in certain scenarios: every operation in the batch still shares the same response header overhead and the same connection management processing time server-side, which is measurable with very many small queries. In addition, not all infrastructure components, say older load balancers or CDNs, support HTTP/2 consistently, which keeps batching a protocol-independent optimization that works regardless of the HTTP version.
# Sending a batched request manually with curl for testing/debugging
curl -X POST https://api.mironsoft.de/graphql \
-H "Content-Type: application/json" \
-d '[
{"query": "query GetCart($id: ID!) { cart(id: $id) { items { name } } }", "variables": {"id": "abc123"}},
{"query": "query GetProfile { me { firstName } }"}
]'
# Response is an array in the same order as the requests
# [
# { "data": { "cart": { "items": [{ "name": "Sneaker" }] } } },
# { "data": { "me": { "firstName": "Anna" } } }
# ]
7. Error handling for bundled requests
An important aspect of GraphQL batching is handling partial failures: if one operation in the batch fails, say due to an authorization error or an internal server error, the other operations in the same request must not be affected. Client-side response processing must therefore evaluate every position in the response array individually, instead of discarding the entire batch on a single failure. Apollo Client handles this mapping automatically, delivering each original useQuery instance only its own result along with any errors.
A more subtle problem arises when one operation in the batch takes unusually long, say an expensive aggregation, while the other operations would have finished long ago. Since the HTTP response is only sent once every operation in the batch has completed, the slowest operation blocks the fastest ones. For use cases with strongly varying response times, it is therefore sensible not to combine expensive and fast queries in the same GraphQL batching request, but to keep them deliberately separate.
8. Where batching hits its limits
GraphQL batching is not a universal solution. For time-critical interactions like a live search with autocomplete, waiting for the batching time window, usually ten to twenty milliseconds, can add noticeable latency without bringing a real performance gain, since only a single query runs there anyway. For subscriptions, batching is fundamentally not applicable, since these run over a long-lived WebSocket connection instead of individual HTTP requests.
Caching also creates complications: HTTP caching based on GET requests and URL-based cache keying does not work with bundled POST requests, since every batch potentially contains a different combination of operations. Anyone relying heavily on HTTP-level caching, say through a CDN in front of the GraphQL API, should apply GraphQL batching selectively only to non-cacheable, dynamic operations and handle cacheable queries separately.
9. Batching strategies compared
The table below compares GraphQL batching with related optimization approaches by scope and typical use cases.
| Technique | Scope | Solves | Typical use |
|---|---|---|---|
| HTTP batching | Client-server transport | Many parallel requests | Dashboards, many independent widgets |
| DataLoader batching | Resolver layer | N+1 database queries | Lists with nested fields |
| HTTP/2 multiplexing | Transport protocol | TCP connection overhead | General, protocol-wide |
| Frontend query consolidation | Application code | Redundant queries | Tightly coupled components |
The most robust configuration combines GraphQL batching at the transport layer with DataLoader at the resolver layer, since both address different bottlenecks and do not exclude each other. Consolidating queries in frontend code usually remains the last resort, since it hurts component modularity.
Mironsoft
GraphQL performance, API architecture and Magento integrations
Too many network roundtrips in your GraphQL frontend?
We analyze your request patterns, set up client- and server-side batching and combine it with DataLoader for a noticeably faster API.
Request analysis
Identifying redundant requests and batching potential in the frontend
Batching setup
Setting up Apollo Link Batch and server-side batch support in production
Performance tuning
Optimizing DataLoader integration and HTTP/2 alongside batching
10. Summary
GraphQL batching reduces the number of HTTP roundtrips by bundling multiple independent queries and mutations into a single request, formatted as a JSON array instead of a single operation object. Apollo Link Batch makes this optimization transparent on the client side, without components needing to artificially merge their queries, while the server processes each operation in the batch independently and isolates errors cleanly.
It matters not to confuse GraphQL batching with DataLoader batching, which solves a different problem, the N+1 query problem within a single request. Both techniques complement each other in a production architecture. Batching hits limits with time-critical single queries, with subscriptions and with HTTP-level caching, which is why it should be applied deliberately to scenarios with many parallel, independent operations, instead of enabling it blanket everywhere.
GraphQL Batching — Key Takeaways
JSON array instead of single object
Multiple operations are bundled as an array in the request body, the server responds with a matching array.
Transparent on the client side
Apollo Link Batch automatically bundles requests within a time window, component code stays unchanged.
Don't confuse with DataLoader
HTTP batching saves roundtrips, DataLoader saves database queries. Both techniques complement each other.
Know the limits
Not suited for subscriptions, time-critical single queries or HTTP-level caching.