Tips: When to Use GraphQL or a Typical REST API

This section documents the current GraphQL specification (October 2021), plus the working draft for @defer/@stream/@oneOf, and the GraphQL-over-HTTP specification, as published at graphql.org and spec.graphql.org — which are the references these pages are written and verified against — and, for the integration pages, against Spring for GraphQL (2.0.x), Strawberry, Ariadne, and the client docs for Apollo Client / urql / Relay. No specific patch version is pinned. Some surfaces (Apollo Router/managed federation, graphql-ws internals, the Relay compiler internals, GraalVM native) are linked rather than documented in depth.

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 orderBy option 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 large IN list 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=orders has 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; ?fields=/?expand= bolted on as needed

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 + @deprecated; fields are hard to ever remove

/v1/v2 alongside each other

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 POST; rebuilt via persisted queries + GET, cache hints, normalized client cache

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 /admin/* path to guard

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

HTTP 200 with an errors array; status-keyed retries and breakers go blind

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 EXPLAIN and index for

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/@cost bound query shape; none of them see the plan

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 extensions

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

  1. Serve the two APIs under separate paths (/graphql vs. /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 /api can reach /graphql.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. Put the public REST surface behind a CDN; do not put an authenticated backoffice GraphQL endpoint behind one.

Query-plan and index discipline

  1. 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.

  2. Assert that contract in CI: every orderBy enum value and every supported filter combination has a backing index, with an EXPLAIN assertion per combination that fails the build on an unexpected sequential scan.

  3. Where trusted documents are in use, EXPLAIN the entire production query set in CI — the set is finite, so treat that as the performance counterpart of the allowlist’s security guarantee.

  4. Index the sort key plus tiebreaker behind every connection, and treat each additional orderBy option as a new required composite index; prefer keyset over offset pagination for large lists (see Pagination).

  5. Ensure every DataLoader batch key is an indexed column, and cap batch sizes so a large IN list cannot flip the query plan or exceed parameter limits.

  6. 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.

  7. 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.

  8. Calibrate @cost weights from measured latency rather than intuition, and re-calibrate them periodically — a guessed weight goes stale as soon as the table behind it grows.

  9. 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).

  10. 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

  1. Trusted documents / persisted operations for anything public — accept only build-time-registered documents.

  2. Introspection and field-suggestion hints off publicly, on internally.

  3. Server-enforced pagination ceilings on every list field; a client-supplied first without a server clamp is not a mitigation.

  4. 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.

  5. Execution timeouts that cancel the whole in-flight resolver tree, including downstream calls already started.

  6. DataLoader batching everywhere, with filters, sorting, and pagination pushed into the datastore.

  7. Redact error detail in production, correlated by a request ID the client may see.

Operating it

  1. Require operationName and client name/version headers, so every request is attributable to a client build.

  2. 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.

  3. Instrument per-operation and per-field metrics, and alert on the errors array and extensions.code rather than on HTTP status — otherwise HTTP 200 hides every failure from status-keyed monitoring.

  4. Keep circuit breakers and bulkheads in the data/service layer behind resolvers, where they behave exactly as they do behind a REST controller.

  5. Design nullability for degradation: a Non-Null field 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.

  6. 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 + EXPLAIN in CI

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.

  • 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 POST endpoint 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

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 EXPLAIN output before relying on them.