GraphQL vs REST: Technical Tradeoffs and When to Choose Each

Quick answer

💡GraphQL uses a single endpoint where clients specify exactly which fields they need, eliminating over-fetching and reducing round trips for nested data. REST uses multiple endpoints with server-defined response shapes, which are simpler to cache on CDNs and easier to document publicly. Choose GraphQL when clients have diverse data requirements or you need to reduce mobile bandwidth. Choose REST when CDN caching, public API documentation, or simple CRUD operations matter most.

Error symptoms

  • REST API returns 40 fields when the UI only needs 3, wasting bandwidth on mobile clients
  • Displaying a list with user details requires 1 request for the list and N additional requests for each user profile
  • GraphQL POST requests are not cached by the CDN, causing high origin load for public catalog pages
  • GraphQL always returns 200 OK even for errors, making monitoring dashboards miss real failures
  • REST API requires 4 round trips to assemble data for a complex dashboard view
  • New frontend feature needs a custom data shape that does not fit any existing REST endpoint

Common causes

  • REST endpoints designed around database tables return full rows when clients need only specific columns
  • REST API designed for one client type does not fit a second client with different data requirements
  • GraphQL resolvers implemented naively without DataLoader, executing one database query per list item
  • GraphQL deployed behind a CDN without persisted queries, preventing GET-based caching
  • Monitoring system counts 200 responses as successes without checking the GraphQL errors array
  • API schema designed without considering mobile clients that have bandwidth and latency constraints

When it happens

  • When adding a mobile app to an existing web platform with different data needs from the same backend
  • When multiple teams consume the same API and need different subsets of the same resources
  • When the number of REST endpoints grows faster than the team can maintain their documentation
  • When a dashboard or aggregation page requires data from many resource types in a single view

Examples and fixes

A mobile user list that only needs name and avatar but a REST endpoint returns the full user object.

Over-fetching in REST versus precise GraphQL query

❌ Wrong

// REST — returns all fields regardless of what client needs
GET /api/users

// Response: 40 fields per user
[
  {
    "id": "usr_1",
    "email": "[email protected]",
    "displayName": "Alice",
    "avatarUrl": "https://cdn.example.com/alice.jpg",
    "createdAt": "2024-01-15T09:00:00Z",
    "lastLoginAt": "2026-05-05T14:30:00Z",
    "preferences": { /* 20 more fields */ },
    "billingInfo": { /* sensitive, included unnecessarily */ }
  }
]

✅ Fixed

# GraphQL — client requests exactly what it needs
query GetUserList {
  users {
    id
    displayName
    avatarUrl
  }
}

# Response: only the 3 requested fields
{
  "data": {
    "users": [
      {
        "id": "usr_1",
        "displayName": "Alice",
        "avatarUrl": "https://cdn.example.com/alice.jpg"
      }
    ]
  }
}

The REST endpoint returns all user fields including sensitive billing information that the mobile list view does not need. The entire payload is transferred over the network for every request, which on a 4G connection with 1000 users means transferring perhaps 500KB of data when 15KB would suffice. The GraphQL query names only the three fields the component renders. The GraphQL server resolves only those fields, reducing resolver computation and database projection scope. This is GraphQL's core advantage for clients with constrained bandwidth or clients that only need a subset of available data. The tradeoff is that REST's GET response is trivially cacheable by a CDN, while this GraphQL query requires either persisted queries or a caching layer that understands GraphQL operation semantics.

Fetching a post list with author names requires N additional requests in REST, and replicates the same pattern in GraphQL unless DataLoader is used.

N+1 problem in REST and naive GraphQL resolvers

❌ Wrong

// REST — N+1 round trips
const posts = await fetch('/api/posts').then(r => r.json());
// Now need author name for each post:
const postsWithAuthors = await Promise.all(
  posts.map(async (post) => {
    const author = await fetch(`/api/users/${post.authorId}`)
      .then(r => r.json());
    return { ...post, author };
  })
);
// 1 request for posts + N requests for authors = N+1 total

✅ Fixed

# GraphQL with DataLoader — single batched query
query GetPostsWithAuthors {
  posts {
    id
    title
    author {
      id
      displayName
    }
  }
}

// Server-side: DataLoader batches all author lookups
const userLoader = new DataLoader(async (userIds) => {
  const users = await db.users.findMany({
    where: { id: { in: userIds } }
  });
  return userIds.map(id => users.find(u => u.id === id));
});

// Resolver uses loader — all N author IDs batched into 1 query
Post: {
  author: (post) => userLoader.load(post.authorId)
}

The REST pattern issues one request for the post list, then one request per post to retrieve the author — N+1 total requests. This is slow because each round trip has latency, and the requests are often sequential when the frontend uses await inside map. GraphQL with DataLoader solves this by batching. The user resolver calls userLoader.load(id) for each post. DataLoader collects all the load() calls from a single event loop tick and executes one database query with all the IDs. The result is 2 database queries total — one for posts, one for all authors — regardless of how many posts are returned. Without DataLoader, naive GraphQL resolvers execute one database query per post, replicating the N+1 problem in the resolver layer.

A GraphQL query that partially fails returns 200 with an errors array, while REST uses HTTP status codes to signal errors.

GraphQL error handling versus REST status codes

❌ Wrong

// REST — status code drives error handling
const res = await fetch('/api/orders/999');
if (!res.ok) {
  // 404: order not found
  // 403: no permission
  // 500: server error
  // Status code distinguishes each case clearly
  throw new Error(`HTTP ${res.status}`);
}
const order = await res.json();

✅ Fixed

// GraphQL — must check errors array even on 200
const res = await fetch('/graphql', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    query: `query { order(id: "999") { id total } }`
  })
});
const result = await res.json();

if (result.errors) {
  // Could be: not found, permission denied, server error
  // HTTP status is always 200 — check error extensions
  const code = result.errors[0]?.extensions?.code;
  if (code === 'NOT_FOUND') handleNotFound();
  if (code === 'FORBIDDEN') handleForbidden();
} else {
  const order = result.data.order;
}

GraphQL always returns HTTP 200 when the HTTP transport succeeded, even if the query encountered an error. This means monitoring tools that count non-2xx responses as errors will miss all GraphQL application errors. The REST pattern is straightforward: the HTTP status code communicates success or failure type, and standard error handling middleware catches it uniformly. GraphQL errors are conveyed in the errors array of the response body, alongside a code in the extensions field for machine-readable error classification. For partial success — a query where some fields resolve successfully and others fail — GraphQL returns both data and errors simultaneously. REST has no equivalent for partial success; an endpoint either succeeds or fails entirely. Both approaches are valid; the important thing is to know which pattern your API uses and handle it consistently on the client.

Core architectural differences between the two

REST and GraphQL represent two fundamentally different philosophies about where data-shaping responsibility lives. REST places that responsibility on the server. A REST server defines endpoints that correspond to resources — /users, /orders, /products — and determines what data each response contains. Every client that calls GET /users receives the same response shape. This predictability is valuable for public APIs, documentation, and caching, but it creates friction when different clients need different subsets of the same data.

GraphQL places data-shaping responsibility on the client. The server exposes a schema that describes every type and field available. The client writes a query that specifies exactly which fields to return, which relationships to traverse, and with what arguments to filter. Two clients hitting the same /graphql endpoint can request completely different response shapes in the same operation. This flexibility eliminates over-fetching — receiving more data than needed — and reduces under-fetching — needing multiple requests to gather related data.

The schema in GraphQL is more than documentation. It is enforced at runtime. Every field in a client query must exist in the schema. Every argument must have the correct type. The GraphQL server validates queries against the schema before executing them, returning detailed validation errors that tell the client exactly which field name was wrong or which argument type did not match. This introspection capability powers tooling like GraphiQL and Apollo Studio, which can auto-complete queries and provide documentation without a separate API reference.

REST's resource-oriented design maps naturally to HTTP semantics. GET retrieves, POST creates, PUT replaces, PATCH modifies, DELETE removes. These methods carry cache semantics: GET is safe and idempotent, making it cacheable by browsers and CDNs. GraphQL operations are typically sent as HTTP POST to a single endpoint, even read operations, which breaks CDN caching. GraphQL read operations can be sent as GET requests with the query as a URL parameter, but this only works for short queries. Persisted queries — where the client sends a hash of the query instead of the full text — enable GET-based caching for GraphQL but require additional infrastructure setup.

Error handling differs fundamentally between the two. REST uses HTTP status codes as the primary error signal: 404 for not found, 401 for unauthenticated, 403 for forbidden, 422 for validation failure. Clients can implement a single error handler that interprets status codes uniformly across all endpoints. GraphQL always returns HTTP 200 for successfully processed requests, including requests where the query returned errors. Application errors appear in an errors array in the response body alongside any partial data. Monitoring infrastructure that counts non-2xx HTTP responses as errors will miss all GraphQL application-level failures.

Identifying which API style fits your use case

The choice between GraphQL and REST is rarely binary in practice. Most organizations use both: REST for public-facing APIs that need CDN caching and stable documentation, and GraphQL for internal or partner APIs that serve multiple clients with different data needs. The diagnostic question is not 'which is better' but 'which fits this specific endpoint or service better given the actual constraints.'

Start by counting the number of distinct client types that will consume the API. A single-client API — one web frontend and nothing else — can be well-served by either approach. REST is simpler to implement and operate. As the number of client types grows — mobile app, web app, third-party integrations, analytics pipelines — the pressure to support different response shapes increases. REST responds to this pressure by proliferating endpoints (dedicated endpoints for mobile), adding query parameters for field filtering, or accepting that some clients will over-fetch. GraphQL responds by letting each client define exactly what it needs without server changes.

Measure actual over-fetching by examining the fields you send versus the fields clients use. If a REST endpoint returns 30 fields and the primary consumer renders 5 of them, that is 83 percent of the response being thrown away. On desktop with a fast connection this may be acceptable; on a mobile app with a metered data plan it is a real cost. Use /tools/http-request-builder to send the REST endpoint call and inspect the full response payload to quantify the over-fetch ratio.

Analyze your database query patterns to understand N+1 risk. If your REST handlers make a query per list item to fetch related data, you are already paying the N+1 cost. GraphQL with DataLoader does not automatically fix N+1 — it provides a mechanism (batching) that you must implement correctly in each resolver. If your team does not implement DataLoader, GraphQL resolvers can have worse N+1 behavior than equivalent REST handlers that use SQL JOIN clauses.

Evaluate your caching requirements honestly. If significant traffic hits the same data repeatedly — a product catalog, a public blog feed, a pricing table — CDN caching with REST GET endpoints can reduce origin load dramatically. The cache key is the URL, which is deterministic and predictable. GraphQL POST requests are not cacheable by CDN without persisted queries, which require client-side and server-side tooling to implement. Measure the cache hit rate on existing REST endpoints before concluding that CDN caching is not important for your use case.

Fixing over-fetching, N+1, and caching in both styles

For REST over-fetching, the standard solution is sparse fieldsets — a query parameter that lets the client request specific fields. The pattern GET /api/users?fields=id,displayName,avatarUrl returns only the requested fields without GraphQL's type system or schema. Most REST frameworks support this with query parameter parsing and database projection. The tradeoff is that the field selection logic must be implemented and maintained in every endpoint, while GraphQL provides it universally at the schema level.

For REST N+1 problems, batch endpoints are the direct solution. Instead of GET /api/users/1, GET /api/users/2, GET /api/users/3, create a GET /api/users?ids=1,2,3 endpoint that returns all three in one response. Many REST APIs provide batch endpoints alongside individual endpoints. When batch endpoints are not available, client-side batching with Promise.all turns sequential awaits into parallel requests, reducing N individual round trips to one round trip with N parallel fetches. This still sends N HTTP requests but they execute concurrently, constrained only by the browser's connection limit per origin.

For GraphQL N+1, implementing DataLoader is the essential fix. DataLoader's batching mechanism collects all load() calls from a single event loop tick and executes them as one batch. In a Prisma-based GraphQL server, the batched loader executes WHERE id IN (1, 2, 3, ...) for all referenced IDs. Place the DataLoader instance in the GraphQL context object so it is shared across all resolvers for a single request execution. Create a separate DataLoader per data type (users, orders, products) and per request to avoid cross-request data leakage.

For GraphQL CDN caching, persisted queries are the standard approach. The client registers a query string on the server (usually at build time or first use) and receives a hash. Subsequent requests send GET /graphql?hash=abc123&variables={...} instead of a POST with the full query body. The server looks up the query by hash and executes it. GET requests with a consistent URL are cacheable by CDN. Apollo Studio and Relay both support persisted queries out of the box. The operational cost is managing the hash registry, but the caching benefit for high-traffic read queries is significant.

For GraphQL monitoring, configure your observability tools to check the response body for errors even when the HTTP status is 200. Add middleware that inspects the response JSON for the errors array and increments an error counter. Log the extensions.code from each error to enable error-rate dashboards by error type. Use /tools/http-request-builder to send GraphQL queries manually and inspect the raw response body to verify your error response format before deploying monitoring rules.

Where each approach has hidden complexity

GraphQL subscriptions add real-time capability through a WebSocket-based protocol. The client sends a subscription operation to the /graphql endpoint over a persistent WebSocket connection. The server pushes updates whenever the subscribed data changes. This eliminates polling for real-time feeds, chat, and live dashboards. REST achieves the same result through Server-Sent Events (SSE) or polling, which are simpler to implement and do not require WebSocket infrastructure but lack the query flexibility of GraphQL subscriptions.

File uploads are awkward in both approaches but worse in GraphQL. REST accepts multipart form data natively — POST with Content-Type: multipart/form-data is well-understood by every HTTP client and server. GraphQL's standard transport is JSON, which does not support binary data natively. The graphql-multipart-request-spec provides a convention for file uploads in GraphQL, but it is not part of the official specification and requires specific client and server library support. For APIs that upload files alongside structured data, REST is the significantly simpler choice.

Versioning strategies differ fundamentally. REST APIs version by URL (/api/v1/, /api/v2/) or Accept headers (Accept: application/vnd.example.v2+json). Breaking changes are isolated to a new version. GraphQL evolves through deprecations — marking fields as deprecated in the schema without removing them, encouraging clients to migrate to replacement fields over time. The schema's backward compatibility constraint means you can add fields freely but removing fields risks breaking existing clients unless you enforce a deprecation window. This makes GraphQL schema evolution more like managing a database schema than API versioning.

Authorization at the field level is a GraphQL-specific challenge. In REST, authorization is typically checked at the endpoint level before any business logic runs. In GraphQL, a single query can touch multiple types and fields, each potentially requiring different authorization. A user might be authorized to see their own profile but not other users' profiles — yet both are accessible through the same user type. Field-level authorization must be enforced in each resolver individually, which is easy to forget. Libraries like graphql-shield provide middleware for GraphQL authorization rules, but the permission model must be designed upfront.

Introspection — GraphQL's built-in schema documentation capability — is both a strength and a security consideration for public APIs. Any client can query __schema to enumerate every type, field, and relationship in your API. This enables powerful tooling but also gives potential attackers a detailed map of your data model. Consider disabling introspection in production for internal APIs where the schema is not meant to be public, while keeping it enabled in development and staging environments.

Mistakes teams make when choosing or implementing each

Adopting GraphQL to solve an organization problem rather than a technical problem is a common mistake. GraphQL does not fix an under-resourced API team, poor documentation practices, or slow backend services. Teams that adopt GraphQL because REST felt 'messy' often find that GraphQL introduces different complexity — schema management, N+1 prevention, caching infrastructure — without removing the organizational problems they hoped to escape. Start with REST, identify specific concrete problems that GraphQL solves, and migrate specific APIs rather than the entire backend at once.

Implementing GraphQL without DataLoader creates APIs that perform worse than equivalent REST endpoints. A naive GraphQL resolver for a posts query with author field will execute one database query per post to fetch the author. A REST endpoint for the same data can use a single JOIN. Teams that benchmark a new GraphQL API against their existing REST API and find GraphQL slower are often measuring a DataLoader implementation gap rather than a fundamental GraphQL limitation. Profile resolver execution to confirm DataLoader is batching correctly before concluding that GraphQL is too slow.

Ignoring the REST caching advantage when designing for high public read traffic is a strategic mistake. If an e-commerce product catalog serves millions of anonymous page views per day, the CDN cache hit rate on REST GET endpoints might be 90 percent, meaning 90 percent of requests never reach the origin server. Switching this traffic to GraphQL POST requests without implementing persisted queries would multiply origin load by 10x. Measure the existing cache hit rate before migrating any high-traffic public endpoint to GraphQL.

Treating GraphQL as a silver bullet for mobile clients without measuring actual bandwidth is another common error. GraphQL reduces over-fetching, but the savings are only realized if the original REST responses were significantly over-fetched and the mobile client is genuinely bandwidth-constrained. On a modern 5G connection the difference between a 100KB and 15KB response is imperceptible. Profile actual API response sizes in production before assuming over-fetching is a real performance problem for your users.

Not handling the GraphQL errors array consistently across all clients leads to silent failures. When a resolver throws an exception, GraphQL catches it and adds it to the errors array without changing the HTTP status from 200. Monitoring tools, log aggregators, and error trackers that watch for HTTP status codes will not capture these failures. Configure your observability infrastructure to parse the GraphQL response body and alert on the presence of the errors array, just as you would alert on HTTP 500 responses for REST APIs. Use /tools/http-request-builder to verify that your error response format is consistent across different failure types before deploying monitoring rules.

Practical guidance for building and operating each

Design REST endpoints around resources, not operations. An endpoint like POST /api/calculateDiscount is a remote procedure call disguised as REST and will grow into an unmaintainable collection of verbs over time. Instead, model the discount as a resource: POST /api/carts/{id}/discounts creates a discount resource associated with the cart. This approach stays consistent with HTTP semantics and makes the API predictable. Use plural nouns for collections, singular nouns for individual resources, and avoid verbs in URLs.

For GraphQL, design the schema around the client's view of the domain, not the database schema. Tables have primary keys, foreign keys, and normalization. Clients think in terms of entities and relationships. A GraphQL schema that directly mirrors a relational database schema produces a poorly usable API — clients must understand join tables and foreign key conventions instead of natural entity relationships. Design the schema by starting with what data clients need and working backward to the resolvers, rather than starting with the database and projecting upward.

Always implement DataLoader for any GraphQL field that resolves by ID from a parent object. This applies to every to-one and to-many relationship resolver. Make DataLoader instances request-scoped by creating them in the context factory, not as module-level singletons. A module-level DataLoader would accumulate IDs across different users' requests, creating data leakage. Use separate loaders per data type to keep batching logic clean and testable.

For public APIs with external consumers, REST's stability and documentation advantages often outweigh GraphQL's flexibility benefits. REST endpoints have stable URLs that can be bookmarked, linked, and accessed from browser address bars. They integrate with existing HTTP tooling — curl, wget, browser DevTools, CDN rules — without custom client libraries. OpenAPI specifications provide machine-readable documentation that generates client SDKs and interactive documentation. Use /tools/http-request-builder to verify that your REST API contract matches the OpenAPI specification and returns the correct status codes for each scenario.

For internal APIs consumed by teams within the organization, GraphQL's flexibility often provides more value than REST's caching advantage. Internal traffic typically flows over fast internal networks where the bandwidth savings from precise field selection matter less, but the ability for frontend teams to evolve their data requirements without backend API changes is a significant productivity advantage. Maintain a GraphQL schema linting step in CI that enforces deprecation before removal, prevents breaking schema changes from merging, and validates that new fields have descriptions. Use /tools/cors-tester to verify that your GraphQL endpoint's CORS configuration is correct for all the internal origin domains that will consume it.

Quick decision checklist

  • Count the number of distinct client types — more client types with different data needs favor GraphQL
  • Measure over-fetching in existing REST endpoints — if clients use under 30 percent of returned fields, over-fetching is real
  • Check CDN cache hit rates on current REST traffic — high cache rates mean REST's caching advantage is significant
  • Verify DataLoader is implemented for every to-one and to-many relationship resolver in GraphQL
  • Confirm monitoring checks the errors array in GraphQL responses, not just HTTP status codes
  • For public APIs with external consumers, favor REST for documentation stability and tooling compatibility
  • For file upload requirements, use REST multipart/form-data endpoints regardless of overall API style
  • Test both API styles with real request patterns using /tools/http-request-builder before committing to a migration

Related guides

Frequently asked questions

What is the main difference between GraphQL and REST?

REST exposes multiple endpoints shaped around resources, with the server determining the response structure. GraphQL exposes a single endpoint where clients write queries that specify exactly which fields and relationships to return. REST uses HTTP methods and status codes to communicate semantics. GraphQL uses HTTP as a transport layer and communicates operation intent through the query language and schema, returning HTTP 200 for both success and error responses.

Is GraphQL always better than REST for modern APIs?

No. GraphQL is better when clients have diverse data requirements, when mobile bandwidth matters, or when rapid frontend iteration is a priority. REST is better when CDN caching is critical, when the API is public with external consumers, or when the team needs simple CRUD endpoints. Many production systems use both: REST for public and cached endpoints, GraphQL for internal and complex data-fetching scenarios.

What is the N+1 problem in GraphQL?

A GraphQL query for a list of posts with author names triggers one resolver call per post to fetch the author, resulting in N database queries for N posts. DataLoader solves this by collecting all the author ID load() calls from a single event loop tick and batching them into one database query with a WHERE id IN clause. Without DataLoader, naive GraphQL resolvers can perform worse than equivalent REST endpoints that use SQL JOIN clauses.

Why can't you cache GraphQL like REST?

REST GET requests have a URL that functions as a natural cache key. CDNs cache by URL and return the cached response for matching requests. GraphQL queries are typically sent as POST requests with the query in the body — CDNs do not cache POST by default. Persisted queries solve this by assigning each query a hash, enabling GET requests with the hash in the URL as a stable cache key.

Why does GraphQL return 200 for errors?

GraphQL's HTTP transport conveys whether the HTTP request was processed successfully, not whether the GraphQL operation succeeded. HTTP 200 means the server received and processed the request. GraphQL application errors — resolver exceptions, permission failures, missing fields — are reported in the errors array of the JSON response body. This enables partial success: some fields in a query can succeed while others fail, with both data and errors returned together.

Can a team use both GraphQL and REST in the same system?

Yes, this is common. A typical architecture exposes REST endpoints for public APIs, webhooks, and file operations, while using GraphQL for internal dashboards and mobile apps that need flexible data queries. The two styles serve different constraints and can coexist in the same backend. Many teams start with REST and add a GraphQL layer for specific use cases rather than migrating everything at once.

What are persisted queries in GraphQL?

Persisted queries replace the full GraphQL query string with a hash in HTTP requests. The client registers each query with the server at build time and receives a hash identifier. Production requests send GET /graphql?hash=abc123 instead of a POST with the query text. This enables CDN caching because GET requests have a stable URL. It also reduces request payload size and prevents clients from sending arbitrary queries in production, which is a security benefit.

Which is better for file uploads: GraphQL or REST?

REST is significantly simpler for file uploads. HTTP multipart/form-data is natively supported by every browser, curl, and HTTP server framework. GraphQL's standard transport is JSON, which cannot represent binary data. The graphql-multipart-request-spec provides a convention for file uploads through GraphQL, but it is unofficial, requires specific library support on both client and server, and adds complexity. Use REST endpoints for file uploads even if the rest of your API is GraphQL.

How do I handle authorization differently in GraphQL versus REST?

REST authorization is typically checked per-endpoint before any business logic runs. GraphQL authorization must be checked per-resolver because a single query can access multiple types. Field-level authorization libraries like graphql-shield help apply authorization rules consistently across the schema. Without explicit field-level authorization, a GraphQL query might access data that a higher-level endpoint check would have blocked in a REST design.

All tools run in your browser. Your data never leaves your device. Last updated: 2026-05-06.