A mobile app team notices their home screen makes seven separate API calls before it can render anything, some of them returning far more data than the screen actually displays, others requiring a second round trip just to fetch a field that was missing from the first response. Someone proposes GraphQL as the fix, and within a sprint the team has migrated, solved the over-fetching problem cleanly, and then spent the next two months discovering that HTTP caching doesn’t work the way it used to, that a single query can accidentally trigger hundreds of database calls, and that rate limiting an API where every request can be a different size is genuinely harder than rate limiting a fixed set of REST endpoints.
Both halves of that story are true and worth taking seriously. GraphQL solves a real, specific problem that REST genuinely struggles with. It also introduces real, specific costs that don’t show up in the pitch, and a team that adopts it without understanding both sides tends to trade one category of pain for a different one, not eliminate pain entirely.
The Actual Problem GraphQL Solves
Over-fetching happens when a REST endpoint returns more data than a specific client actually needs, because the endpoint’s response shape is fixed and has to serve every consumer of that endpoint with the same payload. A mobile app’s home screen showing just a user’s name and avatar still receives the full user object, complete with every field the API happens to return for that resource, most of which the screen throws away immediately after receiving it. This wastes bandwidth, particularly costly on mobile networks, and it wastes parsing time on the client for data that was never going to be used.
Under-fetching is the opposite problem: a single REST endpoint doesn’t return enough related data to render a given view, forcing the client into multiple sequential requests, first fetching a list of posts, then making a separate request per post to get its author’s details, then another for each post’s comment count. Each additional round trip adds real, cumulative latency, particularly punishing on a slow or high-latency mobile connection where every request carries a meaningful fixed cost regardless of payload size.
GraphQL addresses both problems with one mechanism: the client specifies exactly which fields it wants, across however many related resources, in a single request, and the server returns precisely that shape, nothing more and nothing less. The home screen scenario above becomes one query, one request, returning exactly the fields actually needed across users, posts, and comment counts together, solving over-fetching and under-fetching simultaneously.
How GraphQL Actually Works
Unlike REST’s many endpoints, one per resource type or action, GraphQL typically exposes a single endpoint that accepts queries describing exactly what data is needed. The API is defined by a schema, a strongly typed description of every available object, field, and relationship, which serves simultaneously as documentation, a validation layer (a query requesting a field that doesn’t exist in the schema fails immediately, before it ever reaches your actual business logic), and the contract client developers build against.
Resolvers are the functions that actually fetch each piece of data a query asks for, one resolver per field, called by the GraphQL execution engine as it works through a query’s requested shape. Mutations handle anything that changes data, playing the equivalent role of POST, PUT, and DELETE in REST, but still routed through the same single endpoint and described within the same schema, rather than existing as a separate category of URL.
query {
user(id: "42") {
name
avatarUrl
posts(limit: 5) {
title
commentCount
}
}
}
This single query replaces what would otherwise be several separate REST round trips, and the response shape mirrors the query’s shape exactly, containing precisely the fields requested and nothing else.
What GraphQL Actually Costs You
HTTP caching is the cost that surprises teams the most, specifically because it’s invisible until traffic grows enough to matter. REST’s use of distinct URLs per resource lets standard HTTP caching, browser caches, CDNs, reverse proxies, cache a response keyed simply by its URL, entirely outside your application code, for free. GraphQL’s single endpoint, typically accessed via POST with the query in the request body, breaks this caching model almost entirely, since a POST request isn’t cacheable by standard HTTP semantics, and the exact same logical query might be sent with slightly different field selections across different clients, defeating simple response caching even where it could theoretically apply. Solving this requires deliberate, additional infrastructure, application-level caching keyed on the parsed query, or a specialized GraphQL caching layer, work that REST gets essentially for free from decades of existing HTTP infrastructure.
The N+1 query problem is the other major cost, and it’s subtle enough that it frequently ships to production undetected until a query with real relational depth gets used at scale. Requesting a list of posts along with each post’s author, naively resolved, executes one query to fetch the list of posts, and then a separate query per individual post to fetch that post’s specific author, meaning a list of 50 posts silently triggers 51 total database queries for what looks, from the query’s shape, like a single logical request. DataLoader, a batching and caching utility originally built at Facebook alongside GraphQL itself, solves this by collecting all the individual author lookups requested within a single tick of the event loop and combining them into one batched query, but this isn’t automatic. It has to be deliberately implemented in your resolvers, and a team unaware of the problem can ship a GraphQL API that works fine in testing with small, low-relational-depth datasets and falls over under real production data volume.
Rate limiting and query complexity budgeting are genuinely harder with GraphQL than with REST specifically because request cost varies enormously and isn’t visible from the outside. A REST API’s rate limiter can reasonably assume each request to a given endpoint costs roughly the same amount of server work, but a GraphQL query’s cost depends entirely on its shape, a shallow query touching one field costs almost nothing, while a deeply nested query pulling related data several levels deep can be dramatically more expensive, and a naive per-request rate limit either under-protects against expensive queries or over-restricts cheap ones. Real GraphQL APIs at scale typically need query complexity analysis, assigning a computed cost to each incoming query based on its depth and breadth before executing it, which is meaningfully more implementation work than a standard per-endpoint REST rate limit.
What REST Keeps That GraphQL Gives Up
Standard HTTP status codes, distinct URLs per resource, and the broader REST design conventions covered in this site’s REST API design guide all benefit from decades of mature, widely understood tooling: browser dev tools show REST requests clearly by URL and method, standard HTTP caching works without any extra effort, and essentially every piece of web infrastructure already knows how to work with REST’s conventions natively. GraphQL’s single endpoint and POST-based queries mean a lot of that tooling either doesn’t apply cleanly or requires a GraphQL-specific equivalent (a GraphQL-aware API gateway, GraphQL-specific browser extensions for inspecting queries) that isn’t as universally mature or as simply understood as the REST tooling ecosystem that’s been maturing for considerably longer.
Simplicity for straightforward CRUD APIs is a real, underrated REST advantage that gets lost in a lot of GraphQL enthusiasm. An API that’s genuinely just create, read, update, and delete operations on a handful of resources, with no complex nested relationships and no meaningfully different data needs across client types, doesn’t have the over-fetching or under-fetching problem GraphQL exists to solve in the first place, and adopting GraphQL for this kind of API adds real implementation and operational complexity, schema design, resolver implementation, N+1 mitigation, in exchange for a benefit that doesn’t apply to begin with.
Versioning: Two Genuinely Different Philosophies
REST typically versions at the API level, /v1/ versus /v2/, treating a breaking change as something requiring a new, explicitly numbered version consumers opt into deliberately. GraphQL’s more common philosophy is continuous schema evolution without explicit versioning at all: new fields get added freely, old fields get marked deprecated with a note about what replaces them, and deprecated fields are eventually removed only once client usage has genuinely migrated away, tracked through field-level usage analytics most GraphQL server implementations support out of the box.
This isn’t inherently better or worse, it’s a different discipline requiring different tooling. A REST API without proper version discipline risks silently breaking older clients on a supposedly non-breaking change. A GraphQL API without proper field-usage tracking risks removing a deprecated field that a forgotten, rarely-updated client integration still actually depends on, since there’s no version number forcing that client to explicitly acknowledge the change before it happens.
When GraphQL Genuinely Wins
Multiple client types with meaningfully different data needs, a mobile app, a web dashboard, and a third-party integration all consuming the same underlying data but needing very different shapes and levels of detail, is the clearest case for GraphQL’s core strength. Rather than building and maintaining separate REST endpoints tailored to each client’s specific needs, or forcing every client through the same generic, over-fetching endpoint, each client simply requests exactly the fields it needs from one shared schema.
Deeply nested, relationally complex data, a social feed requiring posts, their authors, comment counts, and like status all rendered together, is where GraphQL’s single-request model provides the clearest, most direct latency benefit over REST’s multiple sequential round trips, particularly meaningful for mobile clients on higher-latency networks where each additional round trip carries real, felt cost.
Rapidly evolving frontend requirements, where product and design iterate quickly on exactly what data a given screen needs, benefit from GraphQL’s flexibility to adjust the client’s query without requiring a corresponding backend endpoint change for every frontend iteration, reducing the coordination overhead between frontend and backend teams that a REST API with rigid, fixed endpoint shapes would otherwise require.
When REST Is Still the Better Call
A public API meant to be cached aggressively by CDNs and browsers, where cacheability is a genuine, load-reducing priority, favors REST’s native compatibility with standard HTTP caching infrastructure, since replicating that caching behavior for GraphQL requires meaningfully more deliberate engineering effort to achieve a comparable result.
A small team or early-stage project without the specific over-fetching or under-fetching pain GraphQL exists to solve gains little from adopting it and takes on real, avoidable complexity in schema design, resolver implementation, and N+1 mitigation for a problem that doesn’t actually exist in their specific application yet. This connects directly to a broader pattern worth recognizing: adopting a more complex technology because of its reputation rather than a concrete, current problem it solves is the same mistake covered in this site’s monolith vs microservices comparison, where the same underlying discipline, matching architectural complexity to an actual, present need rather than an anticipated future one, applies just as directly to the GraphQL versus REST decision.
File uploads and simple, single-purpose actions that don’t map naturally onto GraphQL’s query/mutation model, a health check endpoint, a webhook receiver, a file upload handler, are often simpler to implement as plain REST or even standalone HTTP endpoints sitting alongside a GraphQL API, rather than forcing every single interaction through the GraphQL layer for its own sake.
Real Scenarios
A student project or simple CRUD application
REST, without much debate. The over-fetching and under-fetching problems GraphQL solves generally don’t apply meaningfully at this scale and complexity, and REST’s simpler tooling and wider tutorial availability make it the more practical learning and building choice.
A product with a mobile app and a web dashboard consuming the same backend
A genuinely strong case for GraphQL, given the different data shape needs across client types and the real latency benefit of collapsing multiple related data needs into a single request, particularly valuable for the mobile client on less reliable network conditions.
A public API meant for broad third-party consumption
Often REST, specifically for the caching and tooling maturity benefits, unless the API’s actual use case involves genuinely varied, unpredictable data needs across many different third-party consumers that a fixed REST endpoint shape would struggle to serve well.
An internal API serving one, well-understood client application
REST is often sufficient here, since a single, well-understood consumer doesn’t face the varied-client-needs problem GraphQL solves, and the operational simplicity of REST’s tooling and caching tends to outweigh GraphQL’s flexibility for this specific, narrower use case.
Common Mistakes
Adopting GraphQL specifically to solve an over-fetching problem that a well-designed REST endpoint, with sensible field selection or a couple of targeted endpoint variants, could have solved with far less overall complexity, is a common overcorrection. Not every over-fetching problem requires GraphQL’s full machinery to fix.
Shipping resolvers without DataLoader-style batching, then discovering the N+1 problem only once production data volume makes it visible, is one of the more expensive mistakes specifically because it’s invisible in development and testing with small, low-relational-depth sample data, and only surfaces as a real performance problem once genuine production scale exposes it.
Treating GraphQL’s flexible querying as a substitute for actual API design discipline, letting the schema grow without deliberate naming conventions, deprecation practices, or documentation, produces a schema that’s technically flexible but genuinely difficult to navigate for anyone consuming it, undermining the discoverability GraphQL’s strong typing is supposed to provide in the first place.
And skipping query complexity limits entirely, assuming GraphQL’s type system alone provides adequate protection against expensive or abusive queries, leaves a real, exploitable gap, since a maliciously or accidentally deeply nested query can consume disproportionate server resources in a way a schema’s type definitions alone do nothing to prevent.
FAQ
Can I use GraphQL and REST together in the same project?
Yes, and this is common practice. A GraphQL API handling the majority of data queries alongside dedicated REST endpoints for file uploads, webhooks, or simple health checks that don’t fit GraphQL’s query/mutation model well is a reasonable, frequently used combination.
Is GraphQL always faster than REST?
Not inherently. It reduces the number of round trips for complex, related data needs, which can meaningfully improve perceived performance on high-latency connections, but a poorly implemented GraphQL API with unresolved N+1 query problems can be considerably slower than a well-designed REST equivalent.
Do I need Apollo or Relay to use GraphQL?
No, they’re popular client and server libraries that add caching, state management, and other conveniences on top of raw GraphQL, but a GraphQL API can be built and consumed with simpler, more minimal tooling for smaller projects that don’t need everything those larger libraries provide.
How do I prevent a single GraphQL query from being too expensive?
Query complexity analysis, assigning a computed cost to incoming queries based on their depth and breadth before execution and rejecting ones that exceed a defined threshold, is the standard approach, alongside setting reasonable maximum query depth limits at the schema level.

