GraphQL vs REST: You're Asking the Wrong Question

February 2, 202615 min readAndrew Fribush
GraphQLRESTAPI DesignSystem Design

"GraphQL vs REST" is a technology preference masquerading as an architectural decision. Engineers argue about it the way earlier generations argued about tabs versus spaces, with conviction inversely proportional to the stakes. The framing itself is the problem. Asking whether GraphQL is better than REST is like asking whether Spanish is better than Mandarin. It tells you nothing about the answer and everything about the absence of a real question underneath.

The real question is: what data access pattern does your system need? GitHub moved their public API to GraphQL. Stripe stayed on REST. Both decisions were correct, both were made for specific, articulable reasons, and neither had anything to do with which technology is "better." GitHub had thousands of third-party integrators each needing a different slice of deeply relational data. Stripe had millions of merchants performing predictable CRUD on well-bounded resources. The technology followed the constraint. Name the data access pattern, the caching needs, the client diversity, and the answer usually presents itself without requiring a single Hacker News thread.

Two Tools, Two Problems

Roy Fielding defined REST in his 2000 doctoral dissertation at UC Irvine as a set of architectural constraints for distributed hypermedia systems. Not a URL naming convention. Not "use GET for reads and POST for writes." Not "return JSON from an HTTP endpoint." The constraints that matter in practice are statelessness, resource orientation, a uniform interface, and HATEOAS (Hypermedia As The Engine Of Application State). That last constraint is the one almost nobody implements and almost everybody ignores when they call their API "RESTful." What most teams build and ship as REST is, in Fielding's own frustrated words from a 2008 blog post, just HTTP-based RPC wearing a resource-shaped hat. Most "REST APIs" in production satisfy about two of Fielding's six constraints. That's not RESTful. That's a bicycle you're calling a car because both have wheels.

The distinction matters because it reveals where REST's genuine architectural power lives. REST was designed to align with the infrastructure of the web itself. HTTP caching, CDNs, proxy servers, reverse proxies, load balancers, browser caches: all of this machinery was built to understand resource-oriented interactions. When an API returns a response for GET /users/123, the entire caching stack from the browser to the CDN to the reverse proxy can store, invalidate, and serve that response without any custom configuration. ETags, Cache-Control headers, conditional If-None-Match requests. These are not REST features. They are HTTP features that REST was explicitly designed to exploit. Economists call this infrastructure complementarity: the value of a technology increases when it can leverage existing infrastructure rather than building its own. REST got the entire internet's caching infrastructure for free, the same way that electric vehicles benefit from an existing road network while flying cars would need to build one. REST's greatest strength is not simplicity. It's that thirty years of web infrastructure was engineered to understand it natively. That's a moat you can't replicate with a clever library.

But infrastructure complementarity has a ceiling. In a resource-oriented API, the server decides what data to return. A client requesting a user profile gets the full user object (name, email, avatar URL, preferences, last login timestamp, account creation date, notification settings) whether it needs all twenty-two fields or just the name. If the client also needs the user's recent posts, that is a second request. If it needs the authors of comments on those posts, that is a third. Each response carries data the client does not need, and each missing piece demands another round trip. For decades, teams just ate the cost because the caching benefits and infrastructure compatibility outweighed the waste.

JavaScript
// Three requests to render one profile pageconst user = await fetch('/api/users/123');const posts = await fetch('/api/users/123/posts?limit=5');const followers = await fetch('/api/users/123/followers/count');// Each response includes fields the client doesn't need.// The user object returns 22 fields; the client uses 4.// The posts array includes full post bodies; the client// only renders titles and dates.// Total payload: ~45KB. Useful payload: ~3KB.

For Stripe, the cost was never worth worrying about. Stripe's domain maps cleanly to resources: charges, customers, subscriptions, invoices, payment intents. A charge is a charge is a charge. The fields a client needs from a charge object are the same fields every client needs. The operations are create, read, update, list. Resource orientation is not a constraint for Stripe; it is a description of the domain itself. Stripe benefits enormously from HTTP caching, has millions of integrations relying on URL-based API versioning, and serves a developer audience that values predictability above all else. REST is not a compromise for Stripe. It's the architecture that fits the problem like a key fits a lock.

Facebook's problem looked nothing like Stripe's. In 2012, their mobile app needed 14 REST calls to render a single news feed page, and on 2G networks in Southeast Asia, that meant the news feed took eleven seconds to load. The news feed is a deeply nested, polymorphic data structure: posts contain authors, comments, reactions, shared links with previews, tagged users, each with their own relationships branching outward. A REST API serving this data had two options: create a custom endpoint that returned exactly the right shape (and maintain a new custom endpoint every time the mobile team changed the UI), or let the client make fourteen requests and stitch the data together locally. Facebook chose a third option: let the client specify exactly what it needs in a single request. That third option became GraphQL.

GraphQL is not a replacement for REST. Not an upgrade. It is a query language for APIs that shifts data selection from server to client. A single GraphQL query can traverse relationships, select specific fields at each level, and return a precisely shaped response in one round trip. The schema serves triple duty: type system, contract, and documentation, all in a single artifact. Because the schema is introspectable, tooling comes built in (autocomplete, validation, interactive playgrounds, automatic documentation generation). Because the client declares its data requirements explicitly, frontend teams can iterate on what they fetch without filing a ticket for a backend engineer to add a new endpoint.

GraphQL
# One request. Exactly the fields the client needs.# No over-fetching. No under-fetching.# Payload: ~2KB instead of ~45KB across three REST calls.query ProfilePage {  user(id: "123") {    name    avatarUrl    bio    posts(first: 5, orderBy: CREATED_AT_DESC) {      edges {        node {          title          createdAt          commentCount        }      }    }    followerCount  }}

REST is like a phrasebook: you get pre-composed responses to pre-composed questions, and they work well when your needs are predictable. GraphQL is like learning the grammar: you can compose any sentence you need, but you have to learn the rules first, and you can construct sentences that are grammatically valid but computationally absurd. The phrasebook is safer. The grammar is more expressive. Neither is inherently superior. It depends on whether you are ordering coffee or negotiating a contract.

GitHub's migration to GraphQL for their v4 API is the clearest case study of when client-driven fetching becomes necessity. GitHub has thousands of third-party integrators: CI/CD tools like CircleCI, project management apps like Linear, code review bots, analytics dashboards, internal tooling at every Fortune 500 company that writes software. Each integrator needs a different slice of the same underlying data. A CI tool needs repository metadata, branch protection rules, and commit statuses. A project management integration needs issues, labels, milestones, and assignees. A code review bot needs pull request diffs, review comments, and approval states. Under REST, GitHub was maintaining hundreds of endpoints with different field selections, include parameters, and sparse fieldset specifications. Every new integrator surfaced a data access pattern that demanded yet another endpoint variation. GraphQL eliminated the combinatorial explosion: one schema, one endpoint, every integrator queries exactly what it needs. The backend team stops being a bottleneck for third-party data requirements.

Honestly, the schema-first design might matter more than the query efficiency in most codebases. A GraphQL schema is a machine-readable contract between client and server. Types are explicit. Nullability is declared. Relationships are first-class. When a frontend engineer opens a GraphQL playground and begins typing a query, the schema tells them exactly what exists, what types those fields return, and which arguments are available, before they write a single line of application code. This is not just documentation. This is what documentation wants to be when it grows up: always accurate, always in sync, always queryable.

GraphQL's value scales with client diversity. One client with stable needs? GraphQL adds complexity without proportional benefit. A hundred clients with divergent needs? GraphQL is the difference between a sustainable API and an endpoint factory that grows without bound.

The Price of Every Advantage

Every advantage GraphQL provides is purchased with a specific operational cost that REST does not have. Not a caveat. The central fact of the tradeoff. Teams that adopt GraphQL without understanding the price end up worse off than they were with REST, which is how you get blog posts titled "Why We Moved Back to REST" that are really just confessions of insufficient due diligence.

Start with caching, because it is the cost that determines whether the other costs even matter. In REST, a URL is a cache key. GET /users/123 returns the same resource, and every layer of the HTTP stack (browser, CDN, reverse proxy, API gateway) caches it automatically using mechanisms battle-tested since the 1990s. In GraphQL, every request is a POST to /graphql with a different query body. HTTP caches cannot distinguish between them. Caching moves from infrastructure to application: normalized client-side caches (Apollo Client, Relay, urql), persisted queries mapped to cache keys, custom CDN rules that hash query bodies. All of this is solvable. None of it is free. The operational difference is "caching works by default" versus "caching works after you build and maintain a caching system." Whether that cost breaks the deal depends entirely on how much caching buys you. A product catalog served through REST can be cached at every layer from the browser to the CDN edge, with cache invalidation handled by standard HTTP headers and zero custom code. For high-traffic public APIs, HTTP caching can reduce backend load by 90% or more. Giving that up is a real cost that requires a real justification. But if your data is highly dynamic, personalized, or changes frequently enough that caching provides minimal benefit (a real-time dashboard, a personalized social feed, a collaborative editing interface), REST's caching advantage becomes irrelevant. The question is not "does REST cache better?" (it does). The question is "does caching matter for your specific data?"

The second cost compounds the first. REST endpoints have bounded cost: the server controls what data it fetches, so the computational work is predictable and profilable. You can cache it, rate-limit it by URL. A GraphQL query is whatever the client asks for, and a malicious or naive client can ask for deeply nested, widely branching data that generates hundreds of database queries in a single HTTP request. All repositories for an organization, all issues for each repository, all comments for each issue, the full profile of each commenter. That query is syntactically valid, semantically meaningful, and computationally catastrophic. Defending against this requires query complexity analysis, depth limiting, breadth limiting, and cost-based rate limiting. None of these are features GraphQL provides. They are guardrails you build yourself. GraphQL gives the client more power. More power requires more guardrails. The guardrails are not included.

Those guardrails become harder to build when you consider what happens at the resolver layer. In a naive GraphQL implementation, fetching a list of ten users and each user's posts generates one query for the user list, then one query per user for their posts. Ten users, eleven database queries. A hundred users, a hundred and one queries. The resolver function looks clean and modular (each field resolves independently), but the database pays for that modularity with redundant round trips. Facebook created DataLoader specifically to solve this: it batches individual loads into a single query per resolution cycle, turning N+1 queries into two queries regardless of list size.

JavaScript
// WITHOUT DataLoader: N+1 problem// Fetching 100 users with posts = 101 database queries// 1 query for user list + 100 queries for each user's postsconst naiveResolvers = {  Query: {    users: () => db.users.findAll()  // 1 query  },  User: {    // Called once PER user in the list: N queries    posts: (user) => db.posts.findAll({      where: { userId: user.id }    })  }};// WITH DataLoader: batched to 2 queries totalconst postLoader = new DataLoader(async (userIds) => {  // Single query: SELECT * FROM posts WHERE userId IN (...)  const posts = await db.posts.findAll({    where: { userId: userIds }  });  // Map results back to input order  return userIds.map(id =>    posts.filter(p => p.userId === id)  );});const batchedResolvers = {  Query: {    users: () => db.users.findAll()  // 1 query  },  User: {    // DataLoader collects all calls within a tick,    // then fires one batched query    posts: (user) => postLoader.load(user.id)  // 1 query total  }};

DataLoader is elegant. It is also a pattern you must implement on every single resolver that touches a data source, and forgetting it on one resolver can turn a fast query into a database bottleneck that only surfaces under load. In REST, the N+1 problem can exist in application code, but it is never structural. You control the queries your endpoint executes. In GraphQL, the resolver architecture incentivizes the exact pattern that causes N+1, and the fix is a discipline tax on every resolver you write. The tax is worth paying. But you have to know it exists before you see the bill. For teams with strong infrastructure engineering, this is manageable. For teams running lean, it is a cost that multiplies quietly as the schema grows.

Then there is observability. REST gives you monitoring for free. An access log tells you which endpoints were hit, how often, how long they took, and what status codes they returned. You can build a meaningful operational dashboard with grep, awk, and a spreadsheet. In GraphQL, every request hits the same endpoint: POST /graphql. Your access logs are a wall of identical lines. Meaningful monitoring requires parsing incoming queries, naming them (either through client-supplied operation names or server-side extraction), measuring resolver-level performance, tracking error rates per field, and correlating query patterns with database load. Apollo Studio, GraphQL Mesh, and custom Prometheus exporters exist for this purpose. But they are additional infrastructure with additional operational cost, and without them, you are flying blind. A REST API can be understood from its access logs. A GraphQL API without purpose-built observability is a black box.

Notice the pattern. Each cost points in the same direction: GraphQL shifts work from infrastructure to application code, from convention to configuration. If your domain is resource-shaped with predictable fields and CRUD operations, REST fits without friction, and none of these costs apply. If your domain is graph-shaped with variable traversal depth and polymorphic results, GraphQL absorbs that complexity structurally, and the costs are the price of admission. If your data benefits from aggressive HTTP caching (product catalogs, public content, configuration data), REST has a structural advantage no amount of Apollo configuration can fully replicate. If your data is personalized and ephemeral, that advantage evaporates. If one team builds one client, REST is simpler to build, deploy, and monitor. If many consumers need many shapes from the same data, GraphQL eliminates the coordination overhead that would otherwise grow linearly with each new client. The tradeoffs are not abstract. They are a decision framework. You just have to answer them honestly about your system instead of someone else's.

The ISO container changed global shipping by being inflexible: every container is exactly the same size, so ports, trucks, and trains can all handle them without negotiation. Containerization works because the problem space is uniform. But you cannot ship a wind turbine blade in a standard container. When the cargo varies enough, you need custom solutions. The question is never whether flexibility is better than rigidity. The question is whether your problem space rewards uniformity or diversity.

Draw the Boundary

The cleanest answer to "REST or GraphQL?" is usually "both, in different places." Different parts of a system have different data access patterns, different caching requirements, different client profiles. Forcing a single paradigm across all of them is architectural dogma, not architectural thinking. Dogma is a principle applied without regard to context. Thinking is a principle applied because of context.

Shopify is the most instructive example. Their Storefront API, used by thousands of merchants with wildly different store designs, product structures, and frontend frameworks, is GraphQL. A luxury fashion brand needs product images, variant pricing, and size charts. A digital goods store needs download links and license keys. A subscription box service needs recurring billing metadata and customization options. Same API, radically different query patterns, exactly the combinatorial problem GraphQL was built to absorb. But Shopify's Admin API, used by merchants and apps performing resource-oriented CRUD on orders, products, and customers, is REST. The operations are predictable, the data shapes are stable, and the caching benefits for high-volume storefronts are substantial. Shopify did not choose one paradigm because it was better. They chose each paradigm where its strengths aligned with the actual constraints of that part of the system.

The Backend-for-Frontend pattern offers another path to the same destination. A GraphQL layer sits in front of existing REST microservices, aggregating data from multiple services into a single, client-shaped response. The microservices remain resource-oriented, independently deployable, and cacheable at the HTTP layer. The GraphQL layer handles the translation between what the backend offers in resource-shaped pieces and what the frontend needs as a composed view.

JavaScript
// BFF Pattern: GraphQL aggregating REST microservices// Each REST service stays simple and cacheable.// The GraphQL layer composes them for the client.const resolvers = {  Query: {    dashboard: async (_, { userId }) => {      // Parallel REST calls to independent services      const [user, orders, recommendations] = await Promise.all([        fetch(`http://user-service/users/${userId}`).then(r => r.json()),        fetch(`http://order-service/users/${userId}/orders?limit=5`).then(r => r.json()),        fetch(`http://rec-service/users/${userId}/recommendations`).then(r => r.json()),      ]);      return { user, recentOrders: orders, recommendations };    }  },  User: {    // Resolved lazily; only fetched if the client asks for it    loyaltyPoints: (user) =>      fetch(`http://loyalty-service/users/${user.id}/points`).then(r => r.json())  }};

This pattern gives frontend teams the independence and precision of GraphQL without requiring any backend service to change its architecture. The REST services keep their caching, their simplicity, their independent deploy cycles. The GraphQL layer absorbs the composition complexity that would otherwise live in the client as waterfall fetch chains or in the backend as bespoke aggregate endpoints.

Most production systems eventually need both REST and GraphQL. Shopify drew the boundary between their Storefront and Admin APIs. Netflix draws it between their client-facing BFF layer and their internal microservices. The boundary is the architecture. Where you draw it matters more than which technologies sit on either side of it.

A technology decision that cannot name the constraint it is solving is not a decision. It's a preference with a justification stapled on afterward. Name the constraint. The technology follows.

Andrew Fribush

Software engineer building production AI, commerce, and public-data systems. More about Andrew →