Short answer
Choose REST when one known front end consumes the content and you want HTTP caching to work by default. Choose WPGraphQL when several consumers need genuinely different shapes of the same data. The decision is about caching and query cost, not about which query language reads better.
Both are mature and both are used in production at scale. The comparisons that circulate tend to argue aesthetics. The properties that actually decide a project are narrower than that.
The difference that matters: caching
REST responses are GET requests against stable URLs. Every layer you already own — CDN, reverse proxy, browser cache, stale-while-revalidate — works without configuration.
GraphQL is conventionally a POST to a single endpoint. Those layers do nothing. You get caching back through persisted queries, automatic persisted queries, or an application-level cache, all of which are real work and none of which is free.
| WordPress REST API | WPGraphQL | |
|---|---|---|
| Transport | GET per resource | POST to one endpoint |
| HTTP caching | Works by default | Needs persisted queries or GET support |
| Over-fetching | Common; fixed response shapes | Client selects fields |
| Round trips for related data | Several, or a custom endpoint | One nested query |
| Worst-case cost | Bounded by the endpoint | Unbounded without depth limits |
| In core | Yes | Plugin dependency |
| Debugging | Any HTTP client | Needs GraphiQL or similar |
Where REST actually hurts
The standard complaint is over-fetching: /wp/v2/posts returns rendered content, excerpts, meta and a dozen fields you did not ask for. _fields narrows that, and it is underused:
GET /wp-json/wp/v2/posts?_fields=id,slug,title,excerpt&per_page=10
The harder problem is relationships. Fetching ten posts with their authors, terms and featured images is either _embed — which returns a large, awkwardly shaped payload — or several round trips, or a custom endpoint.
Custom endpoints are the underrated answer. A purpose-built route that returns exactly what one template needs is fast, trivially cacheable, and honest about its cost:
register_rest_route( 'acme/v1', '/article-teasers', array(
'methods' => WP_REST_Server::READABLE,
'permission_callback' => '__return_true', // public read-only data
'callback' => function ( WP_REST_Request $request ) {
$query = new WP_Query( array(
'posts_per_page' => 10,
'no_found_rows' => true, // no pagination count needed
'fields' => 'ids',
) );
return array_map( 'acme_teaser_shape', $query->posts );
},
) );
This is a normal WordPress endpoint, so it obeys the same caching and query-budget rules as any template — see Full-Stack Web Performance.
Where WPGraphQL actually hurts
Flexibility is also exposure. A client can compose a query that traverses posts to authors to their posts to their terms, and generate hundreds of database queries from one HTTP request. Nothing in GraphQL prevents this by default.
Before exposing a GraphQL endpoint publicly, you need:
- Query depth limits — a hard ceiling on nesting.
- Complexity analysis — a cost budget per query, rejecting anything above it.
- Batching — dataloader-style resolution so nested fields do not become N+1 queries.
- Persisted queries — an allowlist of known documents, which also restores GET-based caching.
Persisted queries are the piece that changes the calculation. With them, GraphQL becomes cacheable at the edge and the arbitrary-query risk disappears, because only queries you shipped are executable. The cost is that adding a query requires a deploy — which for a single first-party front end is usually fine, and for a public API is not.
Authentication
Both need the same thing and neither ships it. Cookie authentication works only for same-origin requests with a nonce, which rules out a decoupled front end on another domain. For server-to-server calls use application passwords over HTTPS; for user-facing sessions use JWT via a plugin, with short expiry and refresh handled server-side.
The rule that matters more than the mechanism: tokens never reach the browser bundle. Authenticated calls go through your own backend, which is also the only place you can rate-limit them.
A decision you can defend
- One Next.js front end, content-heavy, mostly static — REST with custom endpoints. Cacheable, no plugin dependency, easy to reason about.
- Several consumers with different data needs — WPGraphQL with persisted queries and complexity limits.
- Editorial preview and draft access — either works; WPGraphQL’s ecosystem has more prior art here, which matters more than the protocol.
- Public API for third parties — REST. Bounded cost per endpoint is a feature when you do not control the caller.
The related build problems — preview, cache invalidation on publish, two deployments — are the same either way, and are covered in Headless WordPress with Next.js.
Fetching efficiently, whichever you choose
The protocol matters less than what happens behind it. Both APIs sit on the same database and both can be made slow the same way.
- Prime caches before loops. Rendering a list that then fetches author, terms and meta per item is an N+1 problem regardless of whether the request arrived as REST or GraphQL.
update_post_caches()and priming term and meta caches in one pass is the fix. - Bound every collection. A default
per_pagethat a caller can raise to 100 is a query you did not intend to allow. Cap it server-side. - Skip the count when you do not paginate.
'no_found_rows' => trueremoves a second expensive query per request. - Cache the response, not just the query. An object-cached query still re-runs serialisation and filters on every request; caching the assembled payload skips all of it.
Versioning and deprecation
Custom REST namespaces should carry a version from the first release — acme/v1 — so a breaking change becomes a new route rather than a modified one. GraphQL has no version in the URL, so the equivalent discipline is additive-only schema change: add fields, deprecate old ones with @deprecated, and remove them only once nothing queries them.
Either way, the sequence that avoids downtime is the same: add the new shape, deploy the consumer reading it with a fallback, then remove the old shape. That ordering is what lets two codebases deploy independently, which is covered further in Headless WordPress with Next.js.
Questions people actually ask
- Is WPGraphQL faster than the REST API?
- Per request it usually transfers less data, because the client selects fields. Overall it is frequently slower in production, because REST responses cache at the CDN and GraphQL POSTs do not. Measure the cached path, not a single cold request – that is what real users hit.
- Can I use both?
- Yes, and it is a reasonable pattern: REST for cacheable public content, GraphQL for authenticated or highly variable queries from your own front end. The cost is two API surfaces to secure, version and monitor, so it should be a deliberate choice rather than drift.
- How do I stop the REST API exposing data I do not want public?
- Audit what is actually exposed –
/wp-json/wp/v2/usersis public by default and enumerates author accounts. Restrict or disable routes you do not use, setshow_in_restdeliberately on custom post types and meta rather than accepting defaults, and never mark private meta as REST-visible. - Do I need persisted queries?
- If the GraphQL endpoint is public, yes – they are what makes the cost bounded and the responses cacheable. For an internal endpoint reachable only from your own server-side code, depth and complexity limits are usually sufficient.
- What breaks first when traffic grows?
- On REST, uncached custom endpoints doing unbounded queries. On GraphQL, nested resolvers generating N+1 database queries. Both are query-budget problems rather than protocol problems, which is why profiling the data layer matters more than the choice between them.