Track 2 / Guide 11
GraphQL vs REST: JSON Over the Wire
Compare GraphQL and REST using response shape, resolver work, caching, authorization, and reproducible request measurements rather than endpoint counts.
On this page
Compare consumer workflows, not slogans
GraphQL and REST can both deliver JSON, but they expose different interaction models. GraphQL lets a client select fields through a typed query language. REST commonly exposes resource-oriented representations through HTTP semantics. Neither label guarantees efficient database access, small responses, or safe authorization. Compare a concrete screen or operation with the same data requirements.
List the fields the consumer needs, the number of dependent network round trips, and the backend work required to produce them. A REST endpoint designed for that workflow may avoid over-fetching, while a poorly implemented GraphQL resolver tree can perform many internal queries. Conversely, a flexible graph can serve several clients without creating a separate aggregation endpoint for each view.
Keep organizational constraints in the comparison. Independently evolving clients may benefit from field selection, while public integrations may value simple HTTP tooling and stable resource URLs. The REST design guide shows how pagination and projection can be explicit REST features. Do not compare a carefully designed GraphQL API with an intentionally inflexible REST straw man and call the result a protocol benchmark.
Over-fetching and under-fetching move costs around
Over-fetching transfers fields the consumer does not need. Under-fetching requires additional requests to complete the workflow. GraphQL selection sets can reduce the first problem and combine related reads, but query text, validation, and resolver execution add their own work. Small responses can still be expensive to compute if each nested field triggers a separate backend call.
Measure both wire bytes and backend operations. Include request bodies, headers, compressed response sizes, and connection reuse. A single HTTP request is not necessarily a single database query. Likewise, several cacheable REST requests may be cheaper than one uncached graph query under repeated traffic. The workload's cache hit rate and data access pattern matter more than the visible endpoint count.
Define a representative set of client views and weight them by actual usage. A benchmark containing only one highly nested query can favor one design without representing the product. Include slow dependencies, empty results, errors, and pagination. Record the response equivalence criteria so a format that omits required information cannot appear more efficient simply because it did less work.
A runnable field-selection example
The local example below executes two GraphQL queries over the same synthetic user and prints their JSON byte sizes. Install graphql 16.x and run the file with Node.js. It demonstrates selection behavior without a web server, authentication layer, or database. The resulting byte counts are real measurements of these sample responses, but they do not measure production latency.
The schema defines what fields can be selected, and the resolver supplies the underlying value. In a real service, avoid loading every expensive field eagerly if clients often request only a subset. At the same time, do not replace eager loading with one database call per field without batching. The correct fetch plan depends on both the selection and the storage model.
When extending the experiment, measure query parsing and validation separately from execution, then include all phases in an end-to-end test. Persisted operations can reduce repeated query transport and validation work under an explicit policy. Keep the same authorization rules across query variants; a smaller query must not bypass checks that happened accidentally in an unselected field resolver.
const { buildSchema, graphql } = require("graphql");
const schema = buildSchema(`
type User { id: ID!, name: String!, biography: String! }
type Query { user(id: ID!): User }
`);
const rootValue = {
user: ({ id }) => ({ id, name: "Ada", biography: "Engineer".repeat(100) })
};
(async () => {
for (const source of [
'{ user(id: "u1") { id name } }',
'{ user(id: "u1") { id name biography } }'
]) {
const result = await graphql({ schema, source, rootValue });
if (result.errors) throw result.errors[0];
const output = JSON.stringify(result);
console.log(Buffer.byteLength(source), "request bytes",
Buffer.byteLength(output), "response bytes");
}
})().catch(error => {
console.error(error.message);
process.exitCode = 1;
});Resolver planning determines backend efficiency
The common N-plus-one pattern occurs when a list resolver loads records and each child resolver performs another lookup. At scale, that amplifies database and network work. Batch related identifiers and use request-scoped caching where appropriate. Request scope matters because sharing an authorization-sensitive cache across users can expose data or return values under the wrong policy.
Plan pagination at every potentially large collection, not only at the root. A small root list with several unbounded nested lists can still create a huge result. Set complexity or cost budgets that reflect multiplicative expansion. Depth alone is incomplete: a shallow query can request many expensive aliases or very large lists.
Observe resolver counts and downstream time by operation category without logging arbitrary sensitive variables. Persisted operation identifiers can help create bounded metrics. For REST aggregation endpoints, apply the same scrutiny to internal fan-out. The architectural issue is how much work a consumer can request and how efficiently the server fulfills it, regardless of which interface syntax triggered that work.
Caching requires identity and variation rules
REST often aligns naturally with HTTP caching through resource URLs, validators, and cache-control directives. GraphQL can also use caching, including persisted queries over appropriate methods, server-side result caches, and normalized client caches. The mechanisms differ, and personalized results need careful variation rules in either design.
A cache key must include all inputs that affect the result, including authorization context when data differs by principal or tenant. Omitting that context can turn a performance feature into a data leak. Avoid caching private results in a shared layer merely because the operation is a read. Determine freshness requirements and invalidation behavior before choosing a cache duration.
Client normalization introduces entity identity requirements. Stable identifiers help merge results from different selections, but partial records and field arguments complicate updates. Test mutation invalidation and stale reads rather than assuming a client library makes them disappear. A REST client caching several resources has similar consistency questions, although the storage shape differs. Compare real cache hit rates under representative navigation patterns, not just cold-request timings.
Authorization must follow every access path
A graph can expose the same entity through several relationships, so authorization cannot depend solely on one root field. Apply policy at a layer that covers every access path and every sensitive field. A resolver that returns an object through a new relationship must not bypass a check implemented only in the original lookup route.
For both interfaces, bound query inputs and validate data before passing it to downstream interpreters. GraphQL's type system does not automatically establish business ownership or protect against expensive operations. The JSON security guide explains resource budgets and operator allowlists that remain relevant behind either API style.
Consider error disclosure. A field-level error can reveal whether a protected object exists if different paths return distinguishable details. Define consistent behavior for inaccessible resources and avoid exposing internal resolver exceptions. Keep authentication, authorization, input validation, and cost enforcement reviewable as separate controls. A valid query is only a request expressed in the supported language, not permission to execute unlimited work.
Partial results and version evolution affect clients
GraphQL can return data alongside errors when some fields fail, subject to nullability propagation. Clients must understand whether a partial result is usable. A REST aggregation endpoint can also define partial failure, but it needs an explicit representation. Do not compare one system's partial success with another's all-or-nothing response without acknowledging the semantic difference.
Nullability is an operational promise. Marking every field non-null can cause a small downstream failure to invalidate a larger result through propagation. Making every field nullable can force clients to handle ambiguity everywhere. Choose based on actual domain guarantees and test dependency failures. Document which errors can be retried and whether retrying the full operation repeats expensive work.
Schema evolution still needs consumer coordination. Deprecating a graph field is useful only if usage can be observed and clients migrate. REST versions likewise need a support lifecycle. Keep compatibility fixtures for important operations and generated clients. Additive changes are often easier than removals, but strict clients, enum handling, and changed semantics can make apparently small changes disruptive in either approach.
Measure the full transformation pipeline
Trace data from storage through domain objects, response projection, serialization, transport, and client cache updates. Repeated JSON stringify-and-parse operations used as cloning steps can add substantial allocation without changing the API's visible shape. Remove unnecessary transformations before attributing performance problems to GraphQL or REST.
Run load tests with equal application work and include p95 or p99 latency, error rate, backend query counts, CPU, and peak memory. The codec and gRPC benchmark guide explains why transport and serialization measurements should remain distinct. A protocol migration should be justified by the workload and operating model, not a single favorable microbenchmark.
Write the decision in terms of client flexibility, cache behavior, authorization complexity, and measured cost. Hybrid systems can be reasonable when boundaries are clear, but avoid maintaining duplicate business logic behind two interfaces. Share a well-defined application layer and test that both paths enforce the same policy. The durable advantage comes from disciplined data access and contracts, not from the name of the API style.
Persisted operations need an explicit trust policy
A persisted operation can let a client send an identifier for a known query instead of repeating its full text. That can reduce request bytes and make operation metrics easier to bound. It does not automatically authorize the operation or its variables. The server still needs to validate inputs, apply tenant policy, and enforce cost limits.
Decide whether clients may register arbitrary new operations or only execute a published allowlist. Those are different security models. An automatic query cache is not the same as a reviewed operation inventory. Keep deployment and cache invalidation behavior clear so a new client release does not fail unpredictably when an operation is missing.
Include variables in result-cache identity where they affect output, and include authorization context where required. Reusing a cached result for the same operation name with different variables is incorrect. A persisted identifier describes query structure, not the entire request meaning.
For REST, a similar discipline applies to supported filter and projection combinations. Flexibility should be intentional and bounded in both interfaces. A comparison is fair when both designs expose the capabilities the product actually needs under equivalent access and cost policies.
Model a screen-level experiment with warm and cold caches
Select a real client screen and list every field needed for its initial render and subsequent interaction. Implement equivalent retrieval paths, including pagination and authorization. Measure time until the client has usable data, not merely the completion of the first network response. Under-fetching can hide in follow-up requests that a narrow benchmark omits.
Run cold-cache and realistic warm-cache scenarios. A normalized client cache can satisfy part of a graph query from earlier navigation, while an HTTP cache can reuse a resource response. Neither benefit should be ignored, and neither should be assumed without measuring the actual client behavior.
Inject a slow dependency and a partial failure. Observe whether the client can render useful data, how retries behave, and whether errors produce duplicate backend work. The user experience may depend more on failure isolation than on a small difference in serialized bytes.
Record backend query count, transferred bytes, server CPU, and client transformation time alongside visible latency. A response that is smaller on the wire can still require expensive normalization or reconciliation in the client. Likewise, an aggregation endpoint may reduce network round trips while increasing server fan-out.
Use the results to describe the chosen boundary rather than declare one style universally superior. A product may use a graph for a complex application and resource endpoints for integrations. Shared business logic and consistent authorization are more important than forcing every consumer through one interface for architectural uniformity.
Include client startup and generated-artifact size when mobile or constrained clients are important. A server-side latency improvement can be offset by additional client initialization or cache-processing work. Measure the actual application journey on representative devices rather than assuming that fewer transferred response fields guarantee a faster experience.
Engineering Comparison
| Dimension | GraphQL | REST | What to measure |
|---|---|---|---|
| Response selection | Client selection set | Resource or explicit projection | Required versus transferred fields |
| Backend work | Resolver plan and batching | Handler plan and aggregation | Queries and dependency calls |
| Caching | Operation and entity-aware | HTTP resource-oriented | Realistic hit rate |
| Authorization | Every graph path and field | Every route and projection | Cross-tenant negative tests |
| Failure shape | Potential partial data | Contract-defined status and body | Client recovery behavior |
| Evolution | Schema deprecation | Version and field policy | Consumer migration coverage |
Neither interface guarantees fewer backend operations. The example measures sample JSON sizes only; production latency requires a controlled service experiment.