GraphQL vs REST API Tradeoffs

GraphQL versus REST in a headless CMS is a tradeoff between payload control and cacheability, not a question of which is better. Both decouple content from presentation; they diverge on payload shape, network efficiency, and client-side composition — and the API contract you pick drives engineering velocity, cache invalidation, and governance downstream. This is the input to Headless CMS Architecture & Platform Selection.

GraphQL and REST for headless deliveryA comparison of GraphQL and REST across payload control, HTTP caching, error signalling, versioning, tooling and operational overhead for headless CMS delivery APIs.ConcernGraphQLRESTPayload controlclient selects fieldssparse fieldsets if supportedRound tripsone query per viewseveral requests or includesHTTP cachingneeds GET or persisted queriesworks out of the boxErrors200 with errors arraystatus codesVersioningevolve with deprecationURL or header versionsToolingtyped codegen from schemaOpenAPI where provided
Neither wins everywhere; the deciding factors are caching strategy and the number of different clients.

Integration Contract

Whichever style you choose, the contract with the CMS has the same parts: an endpoint per environment, a delivery token for published content, a preview token for drafts, a documented rate limit and a way to generate types. Write them down in one place and read them from the environment, so switching a page from REST to GraphQL, or the reverse, changes code but not configuration. Most CMS platforms offer both styles against the same content, which makes a mixed approach practical: GraphQL for component-driven pages that need precise, nested data, REST for simple lists, feeds and sitemaps where HTTP caching does the heavy lifting.

Bash
# .env: one CMS, both API styles
CMS_GRAPHQL_URL=https://graphql.cms.example.com/content/v1/spaces/abc123/environments/master
CMS_REST_URL=https://cdn.cms.example.com/spaces/abc123/environments/master
CMS_DELIVERY_TOKEN=published_read_only_token
CMS_PREVIEW_TOKEN=draft_read_only_token
CMS_RATE_LIMIT_RPS=55                  # documented delivery limit, used by the fetch pool

Fetching paradigms and payload control

REST returns a server-defined payload per URI. GraphQL exposes one endpoint that accepts declarative queries, letting clients specify exact fields and nested relationships. That split is the over-fetching versus under-fetching debate.

A typical REST implementation for a product detail page requires coordinated parallel requests:

HTTP
GET /api/v1/products/42
GET /api/v1/products/42/reviews?limit=5
GET /api/v1/products/42/related?limit=3

The frontend must manage concurrency, handle partial failures, and merge payloads manually. With GraphQL, the same data shape is declared upfront:

GraphQL
query ProductPage($id: ID!) {
  product(id: $id) {
    title
    price
    reviews(limit: 5) { rating, comment }
    related(limit: 3) { title, slug }
  }
}

GraphQL cuts network chatter but moves the cost to query validation, schema design, and resolver optimization. Frontends gain precise data control; the platform team owns depth limits and complexity scoring to keep abusive payloads out.

One product page, two API stylesWith REST, the page makes three requests for the product, its reviews and related products and merges them; with GraphQL, it sends one query and receives exactly the fields it asked for.Product pageREST APIGraphQL APIGET /products/42GET /products/42/reviewsGET /products/42/related3 responses, merged in codequery ProductPageone response, exact shape
GraphQL saves round trips; REST keeps each response independently cacheable.

Caching, performance, and edge delivery

REST gets HTTP caching for free — Cache-Control, ETag, and CDN edge routing work on a GET /api/articles with no custom logic (see MDN’s HTTP Caching reference). GraphQL breaks that: queries hit one POST endpoint with a variable body, so standard HTTP caching doesn’t apply. Recovering comparable performance takes:

  • Persisted queries — pre-register query hashes server-side; the client sends only the hash.
  • Automatic Persisted Queries (APQ) — client falls back to the full query on cache miss, then the server caches the hash.
  • Normalized client caching — Apollo or Relay keep a local entity store and invalidate stale records from mutation responses.

Edge delivery then needs either a Worker-based CDN layer that understands query complexity or a BFF that translates GraphQL into cacheable REST before the origin.

Content modeling and schema design

The contract shapes the model. REST rewards flat, resource-aligned types that map to tables or documents; GraphQL rewards deeply nested, relationship-heavy schemas — which trigger N+1 resolver problems unless the data layer batches with DataLoader or joins. This is where Content Modeling Best Practices pays off.

Enforce schema boundaries either way. In GraphQL, use interfaces and unions for polymorphic blocks but cap recursion depth and run query cost analysis. In REST, versioning (/v1/, /v2/) is the governance lever, with deprecation discipline to avoid breaking consumers. The GraphQL Specification defines the type constraints that hold a schema stable across teams.

Error Handling & Resilience

The two styles signal failure differently, and client code must handle each correctly. REST uses status codes: 404 for a missing entry, 401 or 403 for token problems, 429 for rate limits, 5xx for server trouble. GraphQL usually answers 200 even when parts of the query failed, with an errors array next to partial data. A GraphQL client that only checks the HTTP status will render pages with silently missing sections. Treat GraphQL responses in three categories: transport errors (non-200, network failures), which are retried like REST; query errors (validation or schema mismatch), which are bugs and should fail builds and alert; and partial data with field errors, which each page must decide on, either rendering without the failed part or failing the whole page if the missing data is essential.

TypeScript
// lib/cms/graphql.ts: treat partial data explicitly
export async function cmsQuery<T>(query: string, variables: Record<string, unknown>, opts: { allowPartial?: boolean } = {}): Promise<T> {
  const res = await fetch(process.env.CMS_GRAPHQL_URL!, {
    method: "POST",
    headers: { "Content-Type": "application/json", Authorization: `Bearer ${process.env.CMS_DELIVERY_TOKEN}` },
    body: JSON.stringify({ query, variables }),
  });
  if (!res.ok) throw new Error(`CMS transport error ${res.status}`); // retryable, like REST
  const body = (await res.json()) as { data?: T; errors?: { message: string; path?: string[] }[] };
  if (body.errors?.length) {
    const partialOk = opts.allowPartial && body.data;
    console.warn(JSON.stringify({ kind: "graphql_errors", count: body.errors.length, paths: body.errors.map((e) => e.path?.join(".")) }));
    if (!partialOk) throw new Error(`CMS query error: ${body.errors[0].message}`);
  }
  return body.data as T;
}

Rate limits apply to both styles, but GraphQL platforms often count query complexity rather than requests, so a single expensive query can exhaust a budget that a hundred cheap REST calls would not. Read the platform’s rules and measure the cost of your heaviest queries.

Preview & Draft Workflow

Preview works in both styles through a separate token or a preview flag, but the details affect caching. With REST, preview usually uses a different host or a query parameter, which keeps preview responses out of production caches automatically. With GraphQL, preview is often a preview: true argument or a different token on the same endpoint, so make sure preview requests bypass every cache layer, including the framework’s data cache and any persisted query cache keyed only on the query hash. The preview and draft workflow section covers the mechanisms per framework.

Developer experience and velocity

Frontend developers lean GraphQL for self-documenting schemas and type safety — fetch exactly what a component needs without backend coordination. The cost is a learning curve: introspection, fragment composition, client-side normalization. Track the impact through DX & Developer Experience Metrics like time-to-first-query, schema-drift incidents, and cache hit ratio. REST’s explicit endpoints often fit editorial workflows where content types map 1:1 to UI sections.

Decision and migration paths

If you lean on CDN caching, serverless, or ISR, REST is lower operational overhead. If you serve one content source to many clients (web, iOS, Android, IoT), GraphQL’s flexibility cuts backend duplication. The How to choose between GraphQL and REST for headless CMS framework gives concrete decision trees. When migrating from WordPress to headless CMS architecture, REST is usually the safer first bridge — decouple gradually, then adopt GraphQL for new frontends.

Versioning and Schema Evolution

REST APIs traditionally version by URL or header: /v1/ keeps working while /v2/ introduces breaking changes, and clients move when they are ready. GraphQL APIs usually avoid versions altogether. Fields are added freely, old fields are marked @deprecated with a reason, and removed only after usage has dropped to zero, which the server can measure per field. For headless CMS delivery APIs, the difference matters less than it seems, because the schema is generated from your own content model. A renamed field in the model is a breaking change in both styles, and the protection comes from how you change the model, not from the API style: add before removing, migrate content, and switch clients before deleting the old field, as described in migrating content models.

Where the styles do differ is in how quickly you notice. GraphQL queries are validated against the schema, so CI can flag every query that uses a field about to disappear. REST clients read JSON, and a missing property shows up only at runtime unless responses are validated. If you use REST, add runtime schemas at the fetch boundary to get comparable early warning.

Pagination and Large Collections

Listing pages, sitemaps and search indexing jobs read many entries, and both styles need care. CMS REST APIs typically offer offset pagination with skip and limit, capped at a maximum page size, and some offer cursor or sync endpoints for full traversals. GraphQL APIs offer the same through arguments, often as Relay-style connections with cursors. Offset pagination is simple but becomes slow and inconsistent on large, changing collections, because entries published during the traversal shift the pages. For jobs that read everything, prefer the platform’s sync or cursor endpoints; for user-facing pagination, offsets are usually fine up to a few thousand entries.

Batch sizes matter for rate limits. A sitemap job that fetches ten thousand entries one hundred at a time makes one hundred requests; with GraphQL complexity-based limits, a large page with many nested fields can cost more than many small ones. Tune the page size to the platform’s limits and measure the cost of each page.

Choosing per Use Case

In practice, the right answer is often “both, per use case”. The table below summarizes the common patterns seen in production headless projects.

Use case Usual choice Reason
Component-driven pages with nested blocks GraphQL One query per page, exact shape, generated types.
Simple lists and feeds REST CDN-cacheable responses, no query layer.
Sitemaps and search indexing REST or sync API Full traversals with cursors or sync tokens.
Mobile apps with limited bandwidth GraphQL Precise payloads per screen.
Webhook-driven revalidation lookups REST Fetch one entry by id, cache by URL.
Several content sources on one page GraphQL layer or BFF One composed response for the client.

Revisit the choice whenever the constraint behind it changes, and write that constraint down when you decide. A team that chose REST for CDN caching can adopt GraphQL later once persisted queries make it cacheable too; a team that chose GraphQL for flexibility may add REST endpoints in a BFF when edge caching becomes the priority.

Testing & Observability

Test the contract, not the transport. For GraphQL, validate every query in the codebase against the schema in CI, which catches removed or renamed fields before deploy, and generate types from the same schema. For REST, validate recorded responses against the OpenAPI definition if the platform publishes one, or against your own runtime schemas otherwise. In production, record per-query or per-endpoint latency, payload size, error category and cache status. GraphQL needs operation names on every query for this to be readable; anonymous queries all look the same in logs and traces. Watch payload size in particular: a GraphQL query that grows a field at a time can end up larger than the REST response it replaced.

Payload size for one product pageBytes transferred to render a product page with the REST API's default responses, REST with sparse fieldsets and includes, and a GraphQL query selecting only rendered fields.REST, default responses214 KBREST, fields + includes61 KBGraphQL, selected fields38 KB
Most of the gap closes with REST sparse fieldsets where the CMS supports them.

Security Considerations

Both styles need token scoping, but GraphQL adds its own risks. Introspection reveals the full schema, including types editors never expose; disable it on production delivery endpoints where the platform allows, or accept that the schema is public and keep sensitive data out of it. Arbitrary queries let clients ask for deeply nested or very wide data, so rely on the platform’s complexity limits and, for your own GraphQL layers, add depth limits and trusted documents, as described in persisted queries for secure endpoints. REST exposes a fixed surface, which is easier to reason about, but beware of include parameters that resolve unbounded reference graphs.

The BFF Option

A backend for frontend, a small server layer owned by the frontend team, removes the need to choose globally. The BFF talks to the CMS in whichever style suits each source, GraphQL for nested page data, REST for simple lists, and exposes cacheable, page-shaped endpoints to the browser. It is also where tokens stay secret, where responses are validated and trimmed, and where several content sources are combined. The cost is one more service to run. For sites rendered on the server, the framework’s server components or loaders often play this role already. The BFF guide shows a complete setup.

Build-Time and Runtime Fetching

Rendering strategy changes the balance again. At build time, static generation fetches every page’s data once per build, from a single machine, so HTTP caching matters little and the number of requests and rate limits matter a lot. GraphQL’s one-query-per-page model is attractive here, and REST’s parallel requests need a concurrency pool to stay inside limits. At runtime, for server rendering and incremental regeneration, requests come from many edge or server instances, and a shared CDN or data cache in front of the CMS becomes the main performance lever, which favours REST or cacheable GraphQL GET requests. In the browser, fetching directly from the CMS exposes tokens and query shapes to the public, which is acceptable for delivery tokens that only read published content, but most teams route client-side requests through their own API routes anyway so they can cache, validate and rate limit them. Work out where each page type fetches before choosing, because a site that is mostly static has different needs from one rendered on every request.

The Cost of Switching Later

Teams worry about choosing wrongly, but switching is cheaper than it looks when the data layer is designed for it. Keep all CMS access behind functions that return domain objects, such as getPage(slug) or listArticles(cursor), and make components depend only on those objects. The API style then lives in a handful of files. A team that later moves a page from REST to GraphQL rewrites one fetch function and its mapping, and the generated types or runtime schemas confirm that the domain object is unchanged. The expensive migrations are the ones where components call the CMS directly, parse raw responses and depend on the platform’s field names. Those are costly whichever style you start with, so the boundary matters more than the choice.

Frequently Asked Questions

Is GraphQL always faster than REST?

No. It saves round trips and bytes for nested, component-driven views, but REST responses served from a CDN edge are often faster than any uncached GraphQL query. Measure end-to-end latency with realistic caching.

Can GraphQL responses be cached at the CDN?

Yes, if queries are sent as GET requests, usually with persisted query hashes, and the CMS or your layer returns cache headers. The CDN caching guide walks through it.

Should we mix both styles in one project?

It is common and often sensible: GraphQL for pages, REST for feeds, sitemaps and search indexing jobs. Keep both behind one data layer so components do not care which is used.

Does the choice affect content modeling?

Somewhat. GraphQL makes deep references cheap to request, which tempts teams into deep models; REST makes them expensive, which pushes toward flatter ones. Model for the content first, and cap depth either way.

Which is better for static site generators?

Both work. GraphQL fits generators with a data layer built around queries, and REST fits simple fetch-based builds. Rate limits and build concurrency usually matter more than the style, so pool requests and cache responses between builds where the generator allows it.