Tips: When to Use GraphQL or a Typical REST API
|
This section documents the current GraphQL specification (October 2021), plus the working draft for
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production, as GraphQL tooling iterates quickly. |
Every other page in this section documents one risk, one control, or one mechanism in isolation: demand control, caching, N+1, authorization, schema evolution. None of them answers the question those pages provoke: given all that, should this API be GraphQL or a typical REST API, and if GraphQL, where do I deploy it and what must I switch on? This page answers that directly, with a pros/cons table, a scenario-based decision checklist, and a set of concrete deployment tips. It links out to the mechanism pages for detail and confines itself to the choice and the deployment posture.
What each model actually gives you
A typical REST API exposes a set of URL-addressed resources, each with its own fixed response shape; a GraphQL API exposes a single typed graph that a client queries with an arbitrary selection set. The consequence that matters most for everything else on this page is a one-sentence trade: GraphQL moves shape selection to the client and leaves authority with the server — the server still decides what it will execute, but it must now exercise that authority against the query document the client sent, instead of against a fixed route table.
That is not a loss of control, but it is a different job. A REST route and its query parameters already bound the shape of work a request can trigger; a GraphQL server has to reconstruct an equivalent bound deliberately, because a single open-ended endpoint does not give it one for free. A server left at defaults — no depth or breadth limit, no cost budget, no pagination ceiling, no timeout — has implicitly agreed to execute any shape the client can express. That is a configuration gap, not a property of the technology: with trusted documents, the set of executable request shapes shrinks to a finite, content-hashed, build-time-known list — arguably more constrained than a REST API, which still accepts arbitrary combinations of query parameters against every route.
Two meanings of "performance"
Two different things hide under the word "performance," and the rest of this page is misleading without keeping them apart:
-
Server-side cost and predictability — how expensive a request is to execute, and how easily that cost can be bounded and forecast ahead of time.
-
End-to-end, user-perceived latency — how long the client actually waits for the screen it needs.
GraphQL typically moves these in opposite directions. It usually makes server-side cost and predictability harder, for every reason the rest of this page covers. It frequently makes end-to-end latency better: collapsing several sequential round trips into one beats a marginally better query plan on a high-latency mobile link — exactly the request waterfall Getting Started lists as a problem GraphQL targets. So a blanket "performance-critical means REST" is not left standing as a rule here: it is right for a latency-critical hot path that wants one hand-tuned query, and wrong for precisely the composed-screen, high-latency-client case that motivates GraphQL in the first place.
Client-selectable access paths and query-plan cost
This is the dimension the rest of the GraphQL Reference section covers the least, and the one this page adds.
Field selections are mostly cheap — adding another field to a row already being fetched costs little.
Arguments are what cost. An argument can trigger a new data-source call, a list argument can multiply
everything below it, and — the case this section is about — an argument can become a predicate or a sort.
Performance & N+1 recommends pushing a field’s filter,
sortBy, first, and after arguments straight into the datastore’s own WHERE/ORDER BY/LIMIT clauses,
which is the right advice — and it also means the client chooses the datastore access path at request time:
# Shallow, narrow, cheap-looking -- and a full scan plus sort unless a composite
# index on (country, last_login) happens to exist.
query { users(country: "ES", sortBy: LAST_LOGIN, first: 50) { id name } }
The controls Security and Demand Control documents do not
catch this. Depth, breadth, and alias limits count how many fields are selected. @cost(weight:) is a static
weight declared per field at schema-design time, so it cannot distinguish sortBy: LAST_LOGIN (unindexed) from
sortBy: CREATED_AT (indexed) — the weight attaches to the field, not to the argument value. So: the
demand-control stack bounds query shape; nothing in it bounds query-plan quality. That is a real hole.
The combinatorial argument holds too. A REST API’s endpoints are a finite, enumerable set of query shapes: a team
can EXPLAIN each one, index for it, and review it in code review. The executable set for a GraphQL schema is
combinatorial over fields x arguments — not enumerable ahead of time, and it changes when a client deploys,
not when the server does. That removes the server-deploy checkpoint at which a REST team would have noticed a new
access path appearing.
Four consequences follow directly:
-
Cursor pagination has an index precondition. Keyset pagination is only proportional to page size if an index covers the exact sort key plus tiebreaker; every additional
orderByoption a connection exposes is another required composite index, or that ordering silently degrades to scan-and-sort. Pagination documents offset degradation at depth but says nothing about the index the cursor alternative depends on — a schema’s sort/filter surface implies a set of required indexes, and that is currently undocumented. See DDL for creating the indexes themselves. -
Batching changes the plan, not only the round-trip count. DataLoader turns N seeks into one
WHERE id IN (…), normally a win — but a largeINlist can flip the planner to a different strategy or hit parameter limits, and a loader keyed on a non-indexed column collapses N+1 into a single full scan rather than fixing anything. See Fetching and N+1 for the same pattern on the JVM/Hibernate side. -
No global planner exists across data sources. When resolvers span services or databases no single planner sees the whole query: a join a SQL optimizer would resolve as a hash join becomes application-level nested loops over the network. Federation's router plans a sequence of sub-requests across subgraphs, but each subgraph still plans independently — there is no cost model spanning them.
-
Attribution and capacity planning degrade. The datastore sees a query issued by a resolver, not by an endpoint, so a slow-query log no longer points at a caller by itself; see the query-tagging tip below. See also Performance and Statistics for measuring what a query actually costs once it reaches the database.
Two qualifications keep this from overstating the case:
-
REST is not immune — it just fails more visibly.
GET /users?sortBy=lastLogin&include=ordershas exactly the same problem. The difference is enumerability and change control, not kind: REST’s shapes are finite and change only on a server deploy. -
Trusted documents restore the REST property for performance too, not only for security. If only build-time-registered documents execute, the production query set is finite and known again, so every query can be `EXPLAIN`ed in CI — at the cost of ad hoc queryability.
Pros and cons at a glance
| Dimension | GraphQL | Typical REST API |
|---|---|---|
Response shape |
Client-selected; no over- or under-fetching |
Fixed per endpoint; |
Round trips for a composed screen |
One query traverses the graph server-side |
Several requests, often sequential (waterfall) |
Contract & typing |
Schema is the runtime-enforced source of truth; codegen for free |
As good as its OpenAPI document, if current |
New client screen |
Usually no server change |
Frequently a new endpoint or parameter |
Versioning |
Additive evolution + |
|
Bounding server work |
Must be reconstructed against the query document (depth, breadth, cost, ceilings, timeouts) |
Largely implied by the route and its parameters |
HTTP / CDN caching |
Defeated by a single |
Native: URL is the cache key |
N+1 risk |
Default behaviour of nested resolvers; DataLoader is mandatory |
Explicit in each handler; visible in code review |
Authorization surface |
Per-type/per-field; no |
Route-level guards plus method checks |
Rate limiting / quotas |
Needs cost-based accounting; per-route limiting is meaningless |
Per route + method works well |
Error signalling |
|
Status codes drive retries, breakers and alerts directly |
Graceful degradation |
Partial results are first-class — if nullability was designed for it |
Whole-response success/failure unless a fallback is hand-written |
Observability |
One endpoint; needs per-operation/per-field instrumentation |
Per-endpoint metrics come free from any gateway |
Public-facing exposure |
Safe with trusted documents + full demand control; risky at defaults |
Safe with ordinary WAF/rate-limit hygiene |
Query-plan predictability |
Client arguments select the access path; the executable set is combinatorial and not enumerable at defaults |
Each endpoint is one known query you can |
Index coverage |
Implied by the schema’s argument/sort surface — easy to expose an ordering with no supporting index |
Implied by the endpoint set; enumerable and reviewable |
When the access path changes |
On a client deploy — no server-side checkpoint |
On a server deploy — reviewed alongside the change |
Cost controls vs. plan quality |
Depth/breadth/ |
Route limits apply to a query whose plan is already known |
Slow-query attribution |
Datastore sees a resolver, not a caller; needs query tagging |
Endpoint maps to query directly |
Tail latency & capacity planning |
A function of shapes clients happen to send |
A function of a known endpoint mix |
Cross-source joins |
Application-level nested loops; no global planner or cost model |
Explicit, one composition per endpoint |
End-to-end latency, composed screens |
Usually better — one round trip replaces a waterfall |
Worse where a screen needs several dependent calls |
Where complexity lands |
Server platform |
Client, and endpoint sprawl over time |
Binary upload/download |
Awkward; needs a side channel or multipart convention |
Natural |
Schema discoverability |
Introspection — an asset internally, disabled publicly |
Out-of-band docs |
Where GraphQL clearly wins
-
Many heterogeneous clients evolving at different speeds, all sharing one schema instead of one endpoint per client shape.
-
Screens composing several backend services (a BFF, or federation).
-
Mobile clients on high-latency links, where collapsing round trips matters more than a marginally better plan.
-
Backoffice and self-service data surfaces, where the schema itself is the ad hoc query tool and no one can pre-build every screen in advance.
-
Fast-iterating product UI, where waiting on a new endpoint is the actual bottleneck.
Where REST (or gRPC) is the better answer
-
Latency-critical hot paths that want one hand-tuned, `EXPLAIN`ed query — a payment confirmation, a high-QPS read.
-
Binary upload/download and streaming, which GraphQL handles awkwardly.
-
Simple CRUD with a single client, where the flexibility has no one to serve.
-
Partner and machine-to-machine contracts that favor stability and HTTP caching over flexibility.
-
Read-heavy public content that lives or dies on CDN caching.
-
Internal service-to-service hot paths (gRPC).
-
Webhooks and callbacks.
-
Explicitly: a team that cannot own the demand-control, index, and observability work below — an unhardened GraphQL endpoint is worse than a boring REST one.
The two deployment profiles
The accurate framing is not "internal vs. public" as a network-location distinction — it is two deployment profiles of the same technology, chosen by caller trust. The security half of "internal is safer" is right, and stronger than commonly stated: internal callers are authenticated, few, identifiable, and individually rate-limitable, so a pathological query is a buggy dashboard rather than an adversary. The performance half does not follow the same logic: internal callers can be worse offenders — ad hoc queries, large page sizes, whole-table exports — because they hit the same database that serves public traffic. The only control that should actually be dropped internally is trusted documents, since it would destroy the ad hoc benefit that is the whole point internally; pagination ceilings, cost budgets, timeouts, and resource isolation from public traffic matter more internally, not less.
| Internal / backoffice profile | Public / partner profile | |
|---|---|---|
Arbitrary ad hoc queries |
Allowed — it is the point |
Rejected: trusted documents / allowlist only |
Introspection + GraphiQL |
Enabled (a feature) |
Disabled, plus field-suggestion hints off |
Depth / breadth / alias limits |
Generous but present |
Tight |
Cost analysis + budget |
Present; alert rather than reject |
Enforced, surfaced in |
Rate limiting |
Per authenticated user |
Complexity-based, per client + per IP |
Pagination ceilings |
Mandatory |
Mandatory |
Execution timeouts (cancelling the whole tree) |
Mandatory |
Mandatory |
Error detail |
Verbose, aids debugging |
Redacted, correlated by request ID |
Filter/sort argument surface |
Wider, but still index-backed |
Closed enum, matched 1:1 to existing indexes |
Query set `EXPLAIN`ed in CI |
Not feasible (ad hoc by design) |
Yes — trusted documents make it finite |
Data-source access |
Read replica / isolated pool |
Isolated pool, CDN in front where cacheable |
Tips for running both side by side
Topology
-
Serve the two APIs under separate paths (
/graphqlvs./api) for routing policy, telemetry, and cache configuration — but a path is not a security boundary. If both surfaces share a host, port, and ingress, anything that can reach/apican reach/graphql. -
Enforce the boundary at the edge instead: a separate hostname/listener that is simply not published, network policy, VPN/zero-trust, IP allowlist, or mTLS.
-
Run separate deployments for the internal and public surfaces — separate process, connection pool, read replica, and autoscaling group — so a runaway internal query cannot exhaust the pool the public API depends on, and one schema/codebase can run under two different security postures.
-
Where only REST is public, build both surfaces as presentations over a shared service/domain layer, never as two implementations — otherwise authorization gets duplicated and diverges. See Authorization for why that layer, not the resolver, is where authorization belongs.
-
Alternatively, use GraphQL as an internal composition layer behind a public REST edge (or federation with an internal-only subgraph), rather than one schema hiding privileged fields.
-
Put the public REST surface behind a CDN; do not put an authenticated backoffice GraphQL endpoint behind one.
Query-plan and index discipline
-
The schema’s argument surface is the index contract. Do not expose free-form filtering or sorting — expose a closed enum of supported orderings and a fixed set of filterable fields, never a generic
where/JSON filter argument. This is the highest-value tip on this page. -
Assert that contract in CI: every
orderByenum value and every supported filter combination has a backing index, with anEXPLAINassertion per combination that fails the build on an unexpected sequential scan. -
Where trusted documents are in use,
EXPLAINthe entire production query set in CI — the set is finite, so treat that as the performance counterpart of the allowlist’s security guarantee. -
Index the sort key plus tiebreaker behind every connection, and treat each additional
orderByoption as a new required composite index; prefer keyset over offset pagination for large lists (see Pagination). -
Ensure every DataLoader batch key is an indexed column, and cap batch sizes so a large
INlist cannot flip the query plan or exceed parameter limits. -
Set statement-level timeouts at the datastore, not only a GraphQL execution timeout, so a bad plan is killed by the layer that can actually see it.
-
Bulkhead the data layer: separate connection pools per data source, and a read replica for the ad hoc backoffice surface so it can never exhaust the primary’s pool.
-
Calibrate
@costweights from measured latency rather than intuition, and re-calibrate them periodically — a guessed weight goes stale as soon as the table behind it grows. -
For expensive composed shapes, precompute rather than resolving live: a materialized view, a denormalized read model, or a CQRS projection (see Choosing the Right Database).
-
Keep purpose-built REST/RPC endpoints for latency-critical hot paths even inside a GraphQL system — a graph is for composition, not for the one query you want hand-tuned.
Hardening the GraphQL surface
-
Trusted documents / persisted operations for anything public — accept only build-time-registered documents.
-
Introspection and field-suggestion hints off publicly, on internally.
-
Server-enforced pagination ceilings on every list field; a client-supplied
firstwithout a server clamp is not a mitigation. -
Depth, breadth, and alias limits as validation rules, plus cost analysis with a budget and complexity-based rate limiting rather than request counting; surface the remaining budget in
extensions. -
Execution timeouts that cancel the whole in-flight resolver tree, including downstream calls already started.
-
DataLoader batching everywhere, with filters, sorting, and pagination pushed into the datastore.
-
Redact error detail in production, correlated by a request ID the client may see.
Operating it
-
Require
operationNameand client name/version headers, so every request is attributable to a client build. -
Tag datastore queries with the originating GraphQL operation name and client build (SQL comments / sqlcommenter-style tagging), so slow-query logs attribute a bad plan back to an operation instead of to an anonymous resolver.
-
Instrument per-operation and per-field metrics, and alert on the
errorsarray andextensions.coderather than on HTTP status — otherwiseHTTP 200hides every failure from status-keyed monitoring. -
Keep circuit breakers and bulkheads in the data/service layer behind resolvers, where they behave exactly as they do behind a REST controller.
-
Design nullability for degradation: a
Non-Nullfield that fails discards its whole subtree up to the nearest nullable ancestor, so partial success (see Response and Error Handling) only works where Schema Design's nullability strategy was deliberately applied. -
Adopt GraphQL incrementally alongside REST rather than replacing it — the two coexist indefinitely in most real systems.
Most of this demand-control machinery — @cost, depth limits, request batching — is per-library convention,
not part of the GraphQL specification: exact directive names, argument shapes, and default weights differ across
server frameworks, so "harden it" is per-framework work, not a flag to flip.
Decision checklist
| Scenario | Recommendation |
|---|---|
One web client, simple CRUD, small stable domain |
REST |
Several client platforms iterating at different speeds |
GraphQL |
Public partner/M2M API with a contractual surface |
REST (or gRPC), versioned |
Read-heavy public content depending on CDN caching |
REST |
Internal backoffice/admin over many entities |
GraphQL, internal profile |
Screens composing several microservices |
GraphQL (BFF or federation) |
Mobile app on high-latency links |
GraphQL |
Binary upload/download, streaming |
REST |
Service-to-service hot path |
gRPC |
Public product API, team able to own hardening |
GraphQL, public profile (trusted documents) |
Latency-critical hot path (payment confirm, high-QPS tuned read) |
Purpose-built REST/RPC endpoint, even inside a GraphQL system |
Ad hoc reporting/analytics across many entities |
GraphQL, internal profile, on a read replica — never the primary |
Fixed, known query set but composed screens and many clients |
GraphQL with trusted documents + |
Team cannot own index/plan discipline for a client-driven argument surface |
REST |
Public API, team not able to own hardening |
REST |
The practical answer in most systems is hybrid — a few purpose-built endpoints for the latency-critical hot paths, alongside a graph for composition — rather than a once-and-forever, whole-system choice. The underlying dial is predictability and control of access paths vs. client iteration speed, not "performance vs. development speed": GraphQL relocates complexity onto the server platform instead of removing it, "internal" lowers abuse risk but not performance risk, and disabling introspection is a reconnaissance speed bump, not a load-bearing control — none of those three correct the premise that GraphQL is unsafe, only where its cost actually lands.
Related pages
-
Getting Started — the request-waterfall and endpoint-sprawl problems this page’s comparison starts from.
-
Security and Demand Control — the layered controls (depth, breadth, cost, trusted documents) this page explains the limits of.
-
Caching — why a single
POSTendpoint defeats HTTP caching, and how persisted queries and cache hints rebuild it. -
Performance & N+1 — the resolver-level pushdown pattern that this page’s query-plan-cost section builds on.
-
Pagination — cursor and offset pagination mechanics, and the index precondition this page adds to them.
-
Authorization — why there is no
/admin/*path to guard, and where authorization actually belongs. -
Serving Over HTTP — the single-endpoint,
HTTP 200-for-errors transport behavior this page’s error-signalling row summarizes. -
Schema Design — additive-only evolution and the nullability strategy partial degradation depends on.
-
Federation — the router’s query planning across subgraphs, and why no global planner spans them.
-
API-First REST and gRPC — building the REST/gRPC side of a hybrid deployment on Spring Boot.
-
Fetching and N+1 — the same batching and plan-flip concerns from the Hibernate/JVM side.
-
Performance and Statistics — measuring what a query actually costs once it reaches the database.
-
DDL — creating the composite indexes this page’s query-plan discipline tips depend on.
-
Choosing the Right Database — CQRS/read-model options for precomputing expensive composed shapes.
Further reading
-
graphql.org: Performance — official performance guidance (lookahead, pushdown, batching).
-
Use the Index, Luke: No Offset — why keyset pagination needs an index on the sort key, and why
OFFSETdegrades with depth. -
Use the Index, Luke: Fetch the Next Page — the indexed "fetch next page" access path a connection resolver depends on.
-
sqlcommenter — tagging datastore queries with application context, so a slow plan is attributable to a GraphQL operation.
-
pg_stat_statements — per-statement statistics for finding the bad plan once queries are tagged.
-
PostgreSQL client connection defaults —
statement_timeout, the datastore-level backstop distinct from a GraphQL execution timeout. -
OpenTelemetry: GraphQL semantic conventions — semantic conventions for per-operation GraphQL spans.
-
DataLoader — the batching pattern, including its batch-key assumptions.
-
graphql.org: Best Practices — official best-practices overview.
-
graphql.org: Thinking in Graphs — official framing of adopting GraphQL over existing services.
-
graphql.org: Security — the demand-control narrative this page summarizes.
-
graphql.org: Caching — caching as a layer on top rather than a wire-format guarantee.
-
GraphQL-over-HTTP specification — status-code rules,
GETvs.POST, media types. -
OWASP: GraphQL Cheat Sheet — a hardening checklist covering most of the tips above.
-
GitHub GraphQL API: rate and query limits — a public GraphQL API’s published node/point cost budget.
-
Shopify API rate limits — calculated query cost and leaky-bucket rate limiting in production.
-
Apollo GraphOS: Persisted Queries — trusted documents / persisted queries as an allowlist.
-
Spring for GraphQL: Security and Spring for GraphQL: Observability — the JVM-side security and per-operation/per-field observability hooks the operating tips depend on.
|
This page is generated with the assistance of AI. It is more opinionated than the rest of this section, since it
weighs a trade-off rather than documenting one specification — every recommendation above is either cited to
official documentation or marked as a judgement call, and it illustrates the pattern GraphQL and REST deployments
generally follow, not a guarantee for any specific server framework or database — verify the query-plan and
index-discipline tips against your own |