8 min read
On this page

When GraphQL Is Wrong

GraphQL solves real problems. It also introduces real complexity. The decision to adopt GraphQL should be based on whether its benefits outweigh its costs for your specific situation. For many APIs — probably most — the answer is no. REST with well-designed endpoints is simpler, better supported, and easier to operate.

This is not a criticism of GraphQL. It is a recognition that every technology choice has tradeoffs, and GraphQL's tradeoffs are frequently underestimated by teams excited about its query flexibility.

Simple CRUD Does Not Need GraphQL

If your API is a thin layer over a database — users, products, orders, each with standard create/read/update/delete operations — GraphQL adds overhead without meaningful benefit.

A REST API for this:

GET    /products
GET    /products/123
POST   /products
PATCH  /products/123
DELETE /products/123

The same operations in GraphQL require a schema, resolvers, input types, payload types, and a client library:

type Query {
  product(id: ID!): Product
  products(first: Int, after: String): ProductConnection!
}

type Mutation {
  createProduct(input: CreateProductInput!): CreateProductPayload!
  updateProduct(input: UpdateProductInput!): UpdateProductPayload!
  deleteProduct(id: ID!): DeleteProductPayload!
}

input CreateProductInput {
  name: String!
  priceCents: Int!
  category: String!
}

input UpdateProductInput {
  id: ID!
  name: String
  priceCents: Int
  category: String
}

The REST version is 5 endpoints that any developer can call with curl. The GraphQL version requires understanding schemas, mutations, input types, connections, and a GraphQL client. For a simple CRUD API, this is overhead with no payoff.

GraphQL shines when data relationships are complex and different clients need different views. If every client needs the same fields, REST is simpler.

File Uploads Are Awkward

GraphQL was designed for structured data queries, not binary data transfer. Uploading a file through GraphQL requires workarounds.

The common approach uses the multipart request specification, which embeds the file in a multipart form alongside the GraphQL operation:

POST /graphql
Content-Type: multipart/form-data

--boundary
Content-Disposition: form-data; name="operations"
{"query": "mutation($file: Upload!) { uploadAvatar(file: $file) { url } }", "variables": {"file": null}}

--boundary
Content-Disposition: form-data; name="map"
{"0": ["variables.file"]}

--boundary
Content-Disposition: form-data; name="0"; filename="avatar.png"
Content-Type: image/png
<binary data>
--boundary--

Compare this to a REST upload:

POST /users/123/avatar
Content-Type: image/png

<binary data>

The REST version is straightforward. The GraphQL version requires a special client extension, a server plugin, and a non-standard encoding that most GraphQL tooling does not support out of the box.

The pragmatic approach: use REST endpoints for file uploads even in a GraphQL API. Upload the file via REST, get a URL or file ID, and reference it in GraphQL mutations.

Caching Is Harder

REST gets HTTP caching for free. Each endpoint has a unique URL, and HTTP infrastructure (CDNs, browser caches, reverse proxies) caches responses by URL:

GET /products/123
Cache-Control: max-age=3600
ETag: "v1-abc"

Every CDN, browser, and proxy in the chain understands this. The response is cached without any application-level logic.

GraphQL sends all requests to a single endpoint (POST /graphql), and the query is in the request body. HTTP caches cannot cache POST requests, and even if the server supports GET requests for queries, the query string makes each URL unique and unpredictable.

POST /graphql
Body: {"query": "{ product(id: \"123\") { name price } }"}

No CDN will cache this. You need application-level caching:

  • Persisted queries with GET — map query hashes to GET URLs so CDNs can cache them, but this requires build-time registration
  • Response-level caching — cache full GraphQL responses by query hash in Redis or Memcached
  • Field-level caching — cache individual resolver results, which requires DataLoader-style infrastructure and careful invalidation

All of these are solvable, but they are problems REST does not have. If your data is read-heavy and cache-friendly (product catalogs, content pages, configuration), REST's built-in HTTP caching is a significant advantage.

Tooling Is Less Mature

REST has decades of tooling maturity:

  • Testing — curl, Postman, Insomnia, HTTPie all work natively with REST
  • Monitoring — every APM tool groups metrics by endpoint and HTTP method
  • Documentation — OpenAPI/Swagger is a mature standard with code generation, mock servers, and interactive docs
  • Debugging — browser DevTools show each request with status code, headers, and timing
  • Load testing — tools like k6, wrk, and Apache Bench work directly with REST endpoints

GraphQL tooling exists but is less established:

  • Testing — requires GraphQL-aware clients (GraphiQL, Altair) or custom request construction
  • Monitoring — all requests hit POST /graphql, so APM tools see one endpoint. You need GraphQL-aware middleware to group by operation name.
  • Documentation — the schema is self-documenting via introspection, but generated docs (like those from SDL) are less rich than OpenAPI-generated docs
  • Debugging — browser DevTools show every request as POST /graphql 200 OK, making it hard to distinguish successful queries from errors
  • Load testing — you need to craft realistic query payloads; you cannot just hammer a URL

The monitoring gap is particularly painful. With REST, your dashboards show "GET /products is slow" or "POST /orders has a 5% error rate." With GraphQL, you see "POST /graphql has a 2% error rate" and need additional instrumentation to understand which operations are failing.

The Client-Side Complexity Tax

Using GraphQL on the client requires a GraphQL client library. The two dominant options — Apollo Client and urql — are substantial dependencies.

Apollo Client includes:

  • A normalized cache that stores entities by ID and type
  • Cache policies (cache-first, network-only, cache-and-network)
  • Optimistic UI updates
  • Local state management
  • Automatic refetching and polling
  • TypeScript code generation for query types

This is powerful when you need it. It is also a lot of machinery for an API that returns a list of products.

A REST client is a fetch wrapper:

const response = await fetch('/api/products');
const products = await response.json();

A GraphQL client with Apollo:

const { data, loading, error } = useQuery(GET_PRODUCTS, {
  variables: { first: 20 },
  fetchPolicy: 'cache-and-network',
  nextFetchPolicy: 'cache-first',
});

The GraphQL version gives you caching, loading states, and automatic re-renders. But it also requires understanding fetch policies, cache normalization, and how the cache updates after mutations. For simple data fetching, the REST version is easier to reason about.

Schema Management Overhead

Every field in a GraphQL schema is a contract. Adding fields is easy. Removing or changing fields requires:

  1. Marking the field as deprecated: @deprecated(reason: "Use newField instead")
  2. Monitoring to see if any clients still query the deprecated field
  3. Communicating the deprecation to all client teams
  4. Waiting for all clients to migrate
  5. Finally removing the field

With REST, you can version the entire endpoint (/v2/products) and run both versions in parallel. With GraphQL, you have one schema, and every change is incremental.

For teams that control all clients (mobile app + web app), this is manageable. For teams that serve third-party developers, schema evolution requires the same discipline as REST API versioning, but with less established tooling.

When REST Is Simpler & Better

REST is the better choice when:

  • Your data model is resource-oriented — users, products, orders with standard CRUD. Each resource maps to an endpoint. The data shape is fixed and predictable.
  • You need HTTP caching — CDNs, browser caches, and reverse proxies work out of the box with REST. If your API is read-heavy and public-facing, this is a significant performance advantage.
  • Your clients are external developers — third-party developers expect REST. They want to call your API with curl, Postman, or a basic HTTP library. GraphQL raises the barrier to entry.
  • You are a small team — GraphQL requires schema management, resolver optimization (DataLoader), security controls (depth/cost limiting), and specialized monitoring. A small team shipping a REST API can focus on the product instead of the infrastructure.
  • Your API is simple — fewer than 20 endpoints, fixed response shapes, no deep nesting. GraphQL's flexibility is a solution to a problem you do not have.

When GraphQL Earns Its Complexity

GraphQL is worth the cost when:

  • Multiple clients need different data shapes — a mobile app needs 3 fields, a web dashboard needs 30, and an admin tool needs everything. Building separate REST endpoints for each is worse than letting clients specify their needs.
  • Data is deeply relational — a repository has issues, issues have comments, comments have authors, authors have repositories. Fetching this with REST requires many round trips.
  • You are building a platform — internal teams build their own views against a shared data layer. A federated GraphQL schema lets each team own their subgraph while presenting a unified API.
  • Over-fetching is a real problem — mobile clients on slow connections where payload size directly affects user experience.

Common Pitfalls

  • Adopting GraphQL because it is trendy — choosing GraphQL without a specific problem it solves. The most common outcome is a GraphQL API that mirrors REST endpoints, with added schema management overhead.
  • Underestimating operational complexity — DataLoader, query cost analysis, persisted queries, and GraphQL-aware monitoring are not optional. They are requirements for production.
  • GraphQL as a gateway to REST — wrapping existing REST endpoints in GraphQL resolvers. This adds latency (GraphQL server + REST call) without reducing over-fetching, since the REST endpoint returns everything regardless.
  • All-or-nothing thinking — assuming you must use GraphQL for everything or nothing. Many companies use REST for public APIs and GraphQL for internal frontend consumption. Mix protocols based on the use case.
  • Ignoring the client-side cost — the backend team adopts GraphQL, but the frontend team must now learn Apollo, cache normalization, and query optimization. The total cost includes both sides.
  • No performance budget — deploying GraphQL without depth limits, cost analysis, or query monitoring. The first complex query that brings the server down is a matter of when, not if.

Key Takeaways

  • GraphQL is not a universal upgrade over REST. It solves specific problems (flexible queries, reducing over-fetching, deeply relational data) and introduces specific costs (schema management, resolver optimization, caching complexity, client library overhead).
  • Simple CRUD APIs are better served by REST. If every client needs the same data shape, GraphQL's flexibility is unnecessary.
  • HTTP caching is a major REST advantage. GraphQL requires application-level caching that is harder to implement and less efficient.
  • GraphQL's tooling ecosystem is maturing but still behind REST for monitoring, debugging, and load testing.
  • Choose GraphQL when you have multiple clients with different data needs, deeply nested relationships, or a platform architecture. Choose REST when simplicity, caching, and broad developer accessibility matter more.
  • The two are not mutually exclusive. Many successful architectures use REST for external APIs and GraphQL for internal frontend data fetching.