GraphQL vs REST: When Each Actually Wins
Backend9 min read·2025-02-28

GraphQL vs REST: When Each Actually Wins

We compare GraphQL and REST with practical guidance on when to choose each for reliability, performance, and developer experience.

BTLE

Binary Tech Lab Engineering

Backend Architect

GraphQL was supposed to replace REST. It didn't — and it shouldn't have. After building both in production across dozens of projects, we've developed a clear picture of when each is genuinely the better choice.

The mistake is treating this as an ideological choice. Both are good tools. Both have clear contexts where they excel and contexts where they create unnecessary friction.

What REST does well

REST's simplicity is a feature, not a limitation. HTTP caching works out of the box. Every developer already understands it. Every API gateway, CDN, and monitoring tool has native support for it.

For public APIs that third parties will integrate with, REST is almost always the right choice. The consumer writes standard HTTP calls. Documentation is straightforward. Error handling follows HTTP conventions every developer already knows.

REST also wins for simple CRUD APIs — internal services that mostly map 1:1 to database operations. The overhead of a GraphQL schema, resolvers, and query planning isn't justified.

What GraphQL solves that REST doesn't

GraphQL was built to solve specific problems that REST handles poorly at scale. Understanding those problems tells you exactly when GraphQL is worth the additional complexity.

Over-fetching and under-fetching: REST endpoints return fixed shapes. A mobile client that needs five fields gets 40. A dashboard that needs data from three resources makes three requests. GraphQL lets clients request exactly what they need in a single query. For mobile clients on limited bandwidth, this matters.

Frontend-driven development: When frontend teams are evolving rapidly and constantly need slightly different data shapes, a REST API becomes a bottleneck — every new view potentially requires a new endpoint. GraphQL gives frontend teams the flexibility to query the graph as needed without backend involvement.

Multiple clients with different needs: A web app, mobile app, and third-party partner all consuming the same data but needing different shapes. REST gives you a choice between three different endpoints or one bloated endpoint that serves everyone poorly. GraphQL gives each client a tailored query against one schema.

The trade-offs that GraphQL advocates understate

⚡ HTTP caching breaks

REST resources are cacheable by URL. GraphQL queries are POST requests with dynamic bodies — standard HTTP caching doesn't work. You need query-level caching in your implementation, which is more complex and less well-tooled.

🔒 Authorization complexity multiplies

In REST, you authorize at the endpoint level. In GraphQL, any query can request any combination of fields — authorizing at the field and resolver level is significantly more complex to implement correctly and audit.

📈 N+1 queries are your problem

GraphQL's nested query model maps naturally to the N+1 database query problem. You need DataLoader or a similar batching solution — otherwise 100 orders with customer fields triggers 100 separate database queries.

📚 Learning curve is real

Every engineer who touches the API needs to understand the schema, resolvers, and query language. For teams that rotate engineers frequently, this ramp-up cost is a recurring overhead.

Schema design principles

A GraphQL schema is a public API contract. Design for the consumer, not the data model. A users type that mirrors your database table one-to-one is a leaky abstraction — it exposes implementation details and makes schema evolution painful.

Nullable fields are a contract: making a field nullable means clients must handle null. Making it non-nullable means you guarantee it will always be present. Be conservative — start fields as nullable and tighten as you gain confidence. Changing a non-nullable field to nullable is a non-breaking change. The reverse is breaking.

Choose one pagination pattern and use it everywhere. We use Relay-style cursor pagination (edges, node, pageInfo) for all list fields. Never return raw arrays from list fields that could grow unbounded.

# Good: domain-driven type design
type Order {
  id: ID!
  status: OrderStatus!   # Enum, not String
  items: [OrderItem!]!   # Non-null items in non-null list
  customer: Customer!    # Relationship, not customerId: ID
  shippedAt: DateTime    # Nullable — only present after shipping
}

enum OrderStatus { PENDING PROCESSING SHIPPED DELIVERED CANCELLED }

Solving N+1 with DataLoader — properly

The N+1 problem is GraphQL's most important performance concern. A query for 100 orders, each with a customer field, naively triggers 100 separate database queries to resolve each customer.

DataLoader solves this through batching and caching. Instead of each customer resolver firing immediately, DataLoader collects all customer IDs requested within a single event loop tick, then fires one batched query for all of them.

// Without DataLoader: 1 + N queries
const resolver = { customer: (order) => db.findCustomer(order.customerId) };

// With DataLoader: 1 + 1 queries
const customerLoader = new DataLoader(async (ids) => {
  const customers = await db.customers.findMany({ where: { id: { in: ids } } });
  return ids.map(id => customers.find(c => c.id === id) ?? null);
});

const resolver = { customer: (order) => customerLoader.load(order.customerId) };

Critical: create a new DataLoader instance per request, not per server. DataLoader's per-request cache prevents data leaking between users' requests. A server-level DataLoader would serve one user's cached data to another.

Persisted queries for production APIs

Standard GraphQL sends the full query string with every request. For high-traffic production APIs this has two problems: large query strings waste bandwidth, and any client can send arbitrary queries that may be expensive or unbounded.

Persisted queries solve both: queries are registered with a hash at build time, clients send only the hash at runtime. The server looks up the full query and executes it. Unknown hashes are rejected — clients can only execute pre-approved queries.

This effectively converts your public GraphQL API into something closer to REST from a security perspective — the available queries are a fixed, auditable set. Apollo Server supports persisted queries natively; Apollo Client tooling generates and registers query hashes as part of your build pipeline.

Federation: GraphQL across multiple services

If your architecture has multiple services but you want a unified GraphQL API for clients, Apollo Federation lets you compose a single schema from multiple subgraph services. Each service owns and exposes part of the graph; a gateway stitches them together.

Federation is not a light undertaking. It requires understanding entity references, @key directives for cross-subgraph relationships, and the operational complexity of running a gateway in front of multiple services. The payoff is justified at scale — multiple teams each owning their piece of the graph without coordination overhead. For a single team or early-stage product, it's premature architecture.

Our decision framework

Scenario Recommendation
Public API for third-party integrators REST — simpler to consume, better documented
Simple CRUD internal service REST — overhead not justified
Mobile + web with different data needs GraphQL — single schema, tailored queries
Rapid frontend iteration, multiple teams GraphQL — unblocks frontend from backend
Real-time subscriptions GraphQL subscriptions or dedicated WebSocket service
Microservices internal communication REST or gRPC — GraphQL overhead not needed

What we actually ship

In practice: REST for most backend-to-backend communication and public APIs. GraphQL for product APIs that serve multiple clients with evolving data needs — SaaS dashboards, mobile apps, and products with active frontend teams who need to move fast.

We've also had success with a hybrid approach: REST for mutations and simple reads, GraphQL for complex data fetching. This sounds messy but works cleanly when the boundary is clear and the team understands why it exists.

The honest take

GraphQL is a powerful tool that solves real problems in specific contexts. Those contexts are more specific than its advocates suggest and more common than its critics acknowledge.

Don't choose GraphQL because it's modern. Don't choose REST because it's familiar. Identify the actual friction in your API layer, trace it to a specific technical cause, and pick the tool that addresses that cause.

Have a project in mind?

We'd love to hear about what you're building. Let's talk about how we can help bring it to life.

Start a Conversation