GraphQL vs REST: Which API Style Should You Use?
GraphQL lets clients request exactly the data they need from a single endpoint, while REST exposes fixed resources over multiple URLs with standard HTTP verbs. Learn when each wins — with real N+1 examples, schema design, performance tradeoffs, tooling, and a decision matrix.
GraphQL and REST are two fundamentally different approaches to API design: REST exposes data as fixed resources over multiple URLs using standard HTTP verbs (GET, POST, PUT, DELETE), while GraphQL provides a single endpoint where clients declare exactly which fields they need in a typed query language. A decade after Meta (then Facebook) open-sourced GraphQL in 2015, most production APIs are still REST — but GraphQL solves specific problems REST structurally cannot.
Here's when each wins, where each fails, what the tradeoffs actually look like in production, and how to decide.
Over-fetching and Under-fetching: The REST Problem
REST's fundamental constraint is that the server decides the shape of the response. You request a resource; you get the whole resource — whether you need all of it or not.
Consider building a user profile page that needs: name, avatar URL, the last 5 post titles, and follower count.
GET /users/123
→ { id, name, email, bio, phone, address, preferences, createdAt, updatedAt, ... }
GET /users/123/posts
→ [{ id, title, content, tags, likes, comments, authorId, createdAt, updatedAt, ... }, ...]
GET /users/123/followers
→ [{ id, name, email, bio, avatar, ... }, ...]Three requests. You needed 4 fields total; you got 40+. This is the over-fetching problem — the server sends data the client doesn't need — compounded by under-fetching — needing multiple round trips to assemble the complete view.
On mobile networks, this matters. Each additional HTTP request adds latency (TCP handshake, TLS negotiation, server processing). Three 200ms requests feels fine on Wi-Fi and terrible on a 4G connection with high jitter. Unused JSON fields still consume bandwidth, parsing time, and memory on the client.
The N+1 Problem in REST
The pain compounds when data is relational. Suppose you're building a blog feed that shows 20 posts with their author names and avatars:
GET /posts?limit=20 → [{ id, title, authorId }, ...] # 1 request, 20 posts
GET /users/1 → author of post 1 # +1 request
GET /users/2 → author of post 2 # +1 request
...
GET /users/20 → author of post 20 # +1 request21 HTTP requests to render one page. Even with client-side deduplication (cache author IDs you've seen), you're still making N distinct HTTP calls where N = number of unique authors. REST doesn't have a native mechanism to batch these lookups.
Common workarounds in REST codebases:
- Custom batch endpoints:
GET /users?ids=1,2,3,4— works, but you're building a mini-query-language - Including nested resources:
GET /posts?include=author— couples the endpoint to specific client needs, starts to become endpoint proliferation - GraphQL-style field selection via
?fields=id,title,author— at this point you're reinventing GraphQL
How GraphQL Solves It
GraphQL gives the client a declarative query language. One HTTP request, exactly the shape you define, nothing more:
query UserProfile($userId: ID!) {
user(id: $userId) {
name
avatar
posts(limit: 5) {
title
createdAt
}
followerCount
}
}One HTTP request. The server resolves only the fields present in the query. No extra data transmitted over the wire, no multiple round trips, no custom endpoints for specific views. The client and server agree on a typed contract via the schema.
The same pattern for the blog feed:
query BlogFeed($limit: Int!) {
posts(limit: $limit) {
id
title
author {
name
avatar
}
createdAt
}
}One request. The server's job is to resolve this efficiently — which is where GraphQL's own N+1 problem enters.
GraphQL Schema Design
GraphQL APIs are built around a schema — a strongly typed contract between client and server defined in Schema Definition Language (SDL). The schema enumerates every type, every query, every mutation, and every subscription the API exposes. This schema is the source of truth for both tooling and runtime validation.
Types
type User {
id: ID!
name: String!
email: String!
avatar: String
bio: String
posts: [Post!]!
followerCount: Int!
createdAt: String!
}
type Post {
id: ID!
title: String!
content: String!
excerpt: String
createdAt: String!
updatedAt: String!
author: User!
tags: [String!]!
likeCount: Int!
comments: [Comment!]!
}
type Comment {
id: ID!
body: String!
author: User!
createdAt: String!
}The ! suffix means non-nullable — the server guarantees this field will never be null. [Post!]! means a non-nullable list where each element is also non-nullable. This type system enables client-side type generation (TypeScript types, Swift structs, Kotlin data classes) from the schema — eliminating a whole class of runtime errors.
Queries (Reads)
type Query {
user(id: ID!): User
users(limit: Int, offset: Int): [User!]!
post(id: ID!): Post
posts(limit: Int, offset: Int, tag: String): [Post!]!
searchPosts(keyword: String!): [Post!]!
me: User # Authenticated user
}Arguments in GraphQL queries are typed and validated before execution reaches your resolver — no need for manual input validation in application code.
Mutations (Writes)
type Mutation {
createPost(title: String!, content: String!, tags: [String!]): Post!
updatePost(id: ID!, title: String, content: String, tags: [String!]): Post!
deletePost(id: ID!): Boolean!
followUser(userId: ID!): User!
likePost(postId: ID!): Post!
addComment(postId: ID!, body: String!): Comment!
}Mutations look syntactically like queries but have write semantics. They execute sequentially (not in parallel like queries), which matters when multiple mutations in a single request depend on each other.
Subscriptions (Real-time)
type Subscription {
postCreated: Post!
userFollowed(userId: ID!): User!
commentAdded(postId: ID!): Comment!
postLiked(postId: ID!): Post!
}Subscriptions use WebSockets (or Server-Sent Events) under the hood. The client opens a persistent connection; the server pushes events when they occur. This is a built-in advantage over REST — REST requires polling, webhooks, or SSE setup for the same effect, none of which are standardized in the REST constraint model.
Resolvers
The schema defines what data is available. Resolvers define how to fetch it. Each field in the schema has a corresponding resolver function:
# Python — Strawberry GraphQL style
import strawberry
from typing import List, Optional
@strawberry.type
class Query:
@strawberry.field
def user(self, id: strawberry.ID) -> Optional["User"]:
return db.query("SELECT * FROM users WHERE id = %s", [id])
@strawberry.field
def posts(self, limit: int = 10, offset: int = 0) -> List["Post"]:
return db.query(
"SELECT * FROM posts ORDER BY created_at DESC LIMIT %s OFFSET %s",
[limit, offset]
)// Node.js — Apollo Server style
const resolvers = {
Query: {
user: (_, { id }, context) => context.db.users.findById(id),
posts: (_, { limit = 10, offset = 0 }, context) =>
context.db.posts.findMany({ limit, offset }),
},
Post: {
author: (post, _, context) => context.loaders.user.load(post.authorId),
comments: (post, _, context) => context.db.comments.findByPostId(post.id),
},
};Each field can have its own resolver. The Post.author resolver runs once per Post in the result set — which is precisely where the N+1 problem emerges.
GraphQL N+1 Problem
Here's the irony: GraphQL was created to solve REST's N+1 HTTP requests, but it introduces its own N+1 problem at the database layer.
When a client queries posts with their authors, GraphQL calls the Post.author resolver once per post:
def resolve_posts(obj, info):
return db.query("SELECT * FROM posts LIMIT 100") # 1 query → 100 posts
def resolve_post_author(post, info):
# Called 100 times — once per post in the result set
return db.query("SELECT * FROM users WHERE id = %s", [post.user_id])
# Result: 101 database queries for one client requestThe client sent one HTTP request. The server made 101 database queries. This is often worse than a well-implemented REST endpoint that uses a JOIN:
-- REST equivalent: one query with a join
SELECT posts.*, users.name, users.avatar
FROM posts
JOIN users ON posts.author_id = users.id
LIMIT 100;The DataLoader Pattern
Facebook released DataLoader specifically to solve this. DataLoader batches multiple individual .load(id) calls that occur within a single event loop tick into one batch query:
// Node.js — DataLoader's canonical environment
import DataLoader from 'dataloader';
// Batch function: receives array of IDs → returns array of results in SAME ORDER
const batchUsers = async (userIds) => {
const users = await db.query(
'SELECT * FROM users WHERE id = ANY($1)',
[userIds]
);
const usersById = Object.fromEntries(users.map(u => [u.id, u]));
// Critical: result order must match input order
return userIds.map(id => usersById[id] ?? null);
};
const userLoader = new DataLoader(batchUsers);
// In the Post.author resolver
async function resolvePostAuthor(post, _, context) {
return context.loaders.user.load(post.userId);
// DataLoader collects all .load() calls across the request,
// then fires ONE batch query after the current tick
}DataLoader reduces 101 queries to 2: one for posts, one batch query WHERE id = ANY(...) for all authors referenced across all posts.
Critical implementation detail: DataLoader instances must be created per request, not at module level. A module-level singleton persists across requests and leaks data between users:
// WRONG — module-level singleton (cross-request data leak)
const userLoader = new DataLoader(batchUsers);
// CORRECT — per-request, in context factory
function createContext(req) {
return {
currentUser: req.user,
loaders: {
user: new DataLoader(batchUsers),
post: new DataLoader(batchPosts),
comment: new DataLoader(batchComments),
}
};
}
// Apollo Server context setup
const server = new ApolloServer({
typeDefs,
resolvers,
context: ({ req }) => createContext(req),
});DataLoader also caches within a request by default — if user.load(42) is called 5 times in one request, the batch function runs once and the result is returned 5 times from the in-request cache.
Without DataLoader, GraphQL APIs at any meaningful scale are consistently slower than REST equivalents. DataLoader is not optional for production GraphQL — it's required infrastructure.
How GraphQL Works: Client Side
The client experience is where GraphQL's developer ergonomics shine. Tools like Apollo Client and urql provide hooks that handle fetching, caching, loading states, and cache updates:
// Client: Apollo Client with React
import { gql, useQuery, useMutation } from '@apollo/client';
const GET_USER_PROFILE = gql`
query GetUserProfile($id: ID!) {
user(id: $id) {
name
avatar
posts(limit: 5) {
id
title
createdAt
}
followerCount
}
}
`;
const FOLLOW_USER = gql`
mutation FollowUser($userId: ID!) {
followUser(userId: $userId) {
id
followerCount
}
}
`;
function UserProfile({ userId }) {
const { data, loading, error } = useQuery(GET_USER_PROFILE, {
variables: { id: userId },
});
const [followUser, { loading: followLoading }] = useMutation(FOLLOW_USER, {
variables: { userId },
// Optimistic UI update — instant feedback before server confirms
optimisticResponse: {
followUser: { id: userId, followerCount: data?.user.followerCount + 1 }
}
});
if (loading) return <Spinner />;
if (error) return <ErrorMessage message={error.message} />;
return (
<div>
<img src={data.user.avatar} alt={data.user.name} />
<h1>{data.user.name}</h1>
<span>{data.user.followerCount} followers</span>
<button onClick={followUser} disabled={followLoading}>Follow</button>
{data.user.posts.map(post => (
<PostCard key={post.id} post={post} />
))}
</div>
);
}Apollo Client normalizes query results by id into a local cache. When the followUser mutation returns an updated followerCount, Apollo automatically updates every component that rendered this user — without additional fetch calls.
Performance Comparison
GraphQL and REST have different performance profiles depending on what you're optimizing for. Understanding the tradeoffs prevents choosing one for the wrong reasons.
When REST Is Faster
Simple single-resource fetches. GET /products/123 — one URL, one DB query, one JSON response. No schema parsing, no query validation against the schema, no resolver chain traversal. REST has measurably lower per-request overhead for simple operations.
CDN and HTTP caching. REST GET requests are cacheable by URL at every layer — browser, reverse proxy, CDN edge nodes. GET /products/123 cached at a CDN edge means zero origin server load for repeat requests globally. GraphQL uses HTTP POST by default, bypassing CDN caching entirely. Every GraphQL request hits your origin unless you implement workarounds.
Schema validation overhead. GraphQL servers parse and validate every incoming query against the schema before execution begins. For high-throughput APIs serving millions of requests per minute with simple data access patterns, this validation adds meaningful latency. REST has no equivalent per-request schema parsing.
When GraphQL Wins
Complex nested data with multiple relations. A dashboard showing user profile + their recent posts + each post's comment count + each post's top tags + team members with their roles. REST requires 5+ requests or a bespoke fat endpoint. GraphQL resolves the exact shape in one request. With proper DataLoader batching, the database query count can actually be lower than a naive REST implementation.
Multiple clients with divergent data needs. A mobile app needs 3 fields for a list cell. Web needs 15 fields for a detail card. Admin panel needs 25 fields plus audit metadata. Partner integrations need entirely different subsets. With REST, you either over-serve every client (waste bandwidth), maintain multiple endpoints (maintenance burden), or add a ?fields= parameter that becomes its own undocumented query language. GraphQL handles this natively and explicitly via the schema.
Rapid frontend iteration. When a product team iterates quickly on UI, REST API changes require backend deploys. With GraphQL, frontend teams can query new field combinations the schema already exposes without touching the server. The schema is the contract; fields that exist in the schema are immediately available to any client.
Reducing mobile payload. Fetching exactly the fields you need shrinks response size — particularly significant for mobile clients on congested or slow connections. Smaller payloads reduce parse time, memory pressure, and bandwidth cost.
Caching Challenges with GraphQL
GraphQL's caching story requires deliberate engineering work:
Automatic Persisted Queries (APQ): Apollo's APQ converts POST queries to GET requests using a hash. Clients send GET /graphql?extensions={"persistedQuery":{"hash":"abc123"}}. CDNs cache GET requests by URL — this makes GraphQL edge-cacheable. Requires both client and server support.
@cacheControl directives: Apollo Server supports per-field cache hints:
type Post {
id: ID!
title: String! @cacheControl(maxAge: 300) # Cache 5 minutes
content: String! @cacheControl(maxAge: 300)
likeCount: Int! @cacheControl(maxAge: 0) # Never cache — real-time counter
author: User! @cacheControl(maxAge: 3600) # Cache 1 hour
}The overall response TTL is the minimum maxAge across all fields requested — the most volatile field determines the cache lifetime for the whole response.
Application-level caching: Redis/Memcached caching of resolver results. This is the most practical and flexible approach but requires careful cache invalidation. When a post is updated, you must invalidate the cached resolver result for that post, any cached queries that included it, and potentially denormalized caches elsewhere.
REST's caching model is fundamentally simpler because URLs are natural, stable cache keys. GraphQL's fetching flexibility is precisely what makes caching harder — the same data appears in countless different query shapes.
Where REST Wins
Simplicity and debuggability. REST is easier to understand, debug, and operate. HTTP status codes, standard verbs, curl-testable URLs — every developer on the team knows how to use it:
# REST — transparent, debuggable, curl-testable
curl https://api.example.com/users/123
curl -X POST https://api.example.com/orders \
-H "Content-Type: application/json" \
-d '{"items": [{"productId": "abc", "qty": 2}]}'
# GraphQL — requires schema knowledge and query syntax
curl -X POST https://api.example.com/graphql \
-H "Content-Type: application/json" \
-d '{"query": "query { user(id: \"123\") { name email posts { title } } }"}'When something breaks at 3am, the REST debugging path is shorter.
Public APIs for external developers. REST APIs are partially self-documenting via URL structure and HTTP conventions. GraphQL requires a schema explorer (GraphiQL, Apollo Sandbox), understanding of the query language, and schema familiarity. The onboarding barrier is higher. Stripe, Twilio, and GitHub's original v3 API are REST for this reason — broad developer accessibility matters for APIs consumed by thousands of external teams.
File uploads. REST handles multipart/form-data natively via HTTP — upload progress tracking, chunked transfer, CDN acceleration work out of the box. GraphQL requires the multipart request spec extension supported by Apollo and graphql-upload. For APIs where file operations are the primary use case, REST is simpler.
Simple CRUD. If your API is straightforward create/read/update/delete on 5 resources, GraphQL's schema definition, resolver setup, DataLoader configuration, and caching strategy is overhead you don't need. A REST API with OpenAPI documentation serves this perfectly.
Versioning
API versioning strategy is one of the starkest REST vs GraphQL differences.
REST: Version in the URL or via the Accept header:
GET /v1/users/123 # URL versioning — most common
GET /v2/users/123 # Breaking change → new version
Accept: application/vnd.api+json;version=2 # Header versioningURL versioning lets you run v1 and v2 side by side, deprecate v1 with clear timelines, and sunset it when usage drops. It's explicit, simple to implement, and easy to monitor (traffic per version endpoint).
GraphQL: No versioning by convention. You evolve the schema by adding fields and marking old ones deprecated using the @deprecated directive:
type User {
id: ID!
name: String!
# New separate fields replacing fullName
firstName: String!
lastName: String!
# Old field — clients should migrate to firstName + lastName
fullName: String! @deprecated(reason: "Use firstName + lastName instead")
avatar: String
email: String!
}
type Post {
title: String!
# Old name for the field, kept for backward compatibility
body: String! @deprecated(reason: "Use 'content' instead")
content: String!
}Critical: @deprecated does not remove the field. It continues returning data for any client that requests it. It's an advisory signal visible in schema introspection and IDE tooling (deprecated fields appear greyed out in autocomplete). Actual removal requires coordinating with all clients, monitoring field usage in your GraphQL observability tool, and deploying a breaking schema change only when confirmed unused.
GraphQL schema evolution eliminates the version proliferation problem REST creates. In practice, managing deprecated field lifecycle requires GraphQL schema registry tooling (Apollo Studio, Hive, The Guild's platform) and intentional team process.
GraphQL Tooling Ecosystem
The GraphQL ecosystem has matured significantly since 2015. The right tool choice significantly affects development velocity and operational complexity.
Apollo — The dominant GraphQL platform. Apollo Client (React, iOS, Android), Apollo Server (Node.js), Apollo Federation (distributed schema composition across microservices with schema stitching), and Apollo Studio (schema registry, field-level usage analytics, performance monitoring). Apollo Federation is the production standard for large-scale GraphQL deployments — it lets multiple teams own separate GraphQL subgraphs that compose into a single federated graph for clients.
Relay — Meta's GraphQL client for React. More opinionated than Apollo — requires the Relay Cursor Connections spec for pagination and the Global Object Identification spec (every object needs an id: ID! field fetchable at the root). Higher learning curve, but Relay's compiler generates optimized query artifacts and its data masking prevents component prop drilling. Used internally at Meta at Facebook-scale traffic.
Hasura — Instant GraphQL API over PostgreSQL, MySQL, SQL Server, MongoDB, and REST APIs. Auto-generates the entire GraphQL schema (queries, mutations, subscriptions) from your database schema, with fine-grained permission rules per role per table per field. Excellent for rapid internal tooling development. Less control over custom resolver logic — Hasura's extension points are Actions (calling REST endpoints) and Remote Schemas (stitching external GraphQL APIs). For teams that want schema-first without building resolvers, Hasura is compelling.
PostGraphile — PostgreSQL-only, open-source-first, highly extensible via a plugin system. Where Hasura is a managed product, PostGraphile is a library you own. Full PostgreSQL feature support — row-level security, stored procedures, custom types. The plugin system (PostGraphile's plugin library) supports custom queries, mutations, and schema extensions. If your entire API is Postgres-backed and you want full control plus open-source guarantees, PostGraphile is worth evaluating seriously.
GraphQL Yoga — Lightweight, spec-compliant GraphQL server from The Guild. Runs on Node.js, Cloudflare Workers, Deno, Bun, and any fetch-compatible runtime. Good alternative to Apollo Server when you want a smaller dependency footprint, multi-runtime support, or don't need Apollo's full platform features.
Pothos — Code-first GraphQL schema builder for TypeScript. Defines the schema in TypeScript with full type inference, without SDL strings. The TypeScript types and GraphQL types stay in sync automatically — no codegen step required for server-side type safety.
GraphQL Code Generator — Generates TypeScript types, React hooks, and operation types from your schema and client queries. Eliminates hand-written types and ensures client-side code is always in sync with schema changes. Essential for typed GraphQL client development.
When to Choose REST vs GraphQL
Decision Matrix
Choose REST when:
- Building a public API for external developers — lower onboarding friction, familiar conventions, curl-testable
- Your API is simple CRUD over a small number of well-defined resources
- CDN edge caching is critical — REST's URL-based caching model is significantly simpler to operate
- Your team is unfamiliar with GraphQL and the project timeline doesn't allow for the learning investment
- Server-to-server communication in a microservices architecture — REST is simpler, lower overhead, easier to trace
- The API is file-heavy — uploads, downloads, streaming content
Choose GraphQL when:
- You have multiple clients (iOS, Android, web, partner integrations) needing different data shapes from the same backend
- Frontend teams iterate rapidly and need new data combinations without backend deploys
- Your data is inherently graph-shaped — social connections, content hierarchies, deeply relational data where relationships are traversed in queries
- You need real-time features — subscriptions are a first-class schema primitive, not a bolt-on
- Building a developer platform where third parties query your data (GitHub GraphQL API, Shopify Storefront API, Stripe's emerging GraphQL API)
- Optimizing mobile — smaller, precise payloads matter on slow or metered connections
Mobile apps: GraphQL wins — smaller payloads, flexible queries for different screen sizes, subscriptions for live updates.
Public APIs: REST wins — lower barrier to entry, easier to document and explore, familiar to every developer.
Internal APIs (same team owns frontend and backend): Either works. GraphQL shines when the frontend team is large or iterates frequently on data requirements. REST is fine when the API surface is stable and simple.
Microservices: REST or gRPC for service-to-service. Apollo Federation for aggregating multiple GraphQL subgraphs into a single client-facing graph.
The Hybrid Approach
Most mature engineering organizations use both. REST for stable, simple, or external-facing endpoints. GraphQL for complex data-fetching needs, multi-client scenarios, or internal tooling. They're not mutually exclusive — a GraphQL gateway commonly calls internal REST services as its data sources, composing their responses into a unified graph. This is the Backend for Frontend (BFF) pattern applied to GraphQL.
gRPC as a Third Option
When neither REST nor GraphQL is the right tool, consider gRPC.
gRPC is a high-performance RPC framework built on HTTP/2 and Protocol Buffers (protobuf). Where REST is resource-oriented and GraphQL is query-oriented, gRPC is procedure-oriented — you define service methods, not resources or queries. Communication is binary (protobuf) rather than text (JSON), which is significantly more efficient for high-throughput services.
// users.proto — the service contract
syntax = "proto3";
service UserService {
rpc GetUser (GetUserRequest) returns (User);
rpc ListPosts (ListPostsRequest) returns (stream Post); // Server streaming
rpc CreateUser (CreateUserRequest) returns (User);
rpc StreamFeed (FeedRequest) returns (stream FeedEvent); // Bidirectional streaming
}
message GetUserRequest {
string user_id = 1;
}
message User {
string id = 1;
string name = 2;
string email = 3;
string avatar_url = 4;
int64 created_at = 5;
}
message ListPostsRequest {
string user_id = 1;
int32 limit = 2;
}
message Post {
string id = 1;
string title = 2;
string author_id = 3;
int64 created_at = 4;
}From this .proto file, gRPC generates client and server code in any supported language — Go, Java, Python, Node.js, Kotlin, Swift, Rust. Service teams publish a .proto file; consuming teams generate a typed client. The contract is compile-time verified, not runtime-discovered.
Use gRPC when:
- Service-to-service communication in polyglot microservices — generated typed clients from a single source of truth
- High throughput, low latency internal APIs — binary protobuf serialization is 3-10x more efficient than JSON for equivalent data
- Streaming — gRPC natively supports server-side streaming, client-side streaming, and bidirectional streaming without protocol extensions
- Strict compile-time contracts — breaking proto changes fail at build time, not at runtime in production
Do not use gRPC when:
- Browser clients need direct access — gRPC-Web adds a translation proxy and limited feature support
- External developers consume your API — protobuf tooling is a high barrier to entry vs. REST's curl-testability
- Your team has no Protocol Buffers experience and the project doesn't warrant the investment
- You need rich query flexibility — gRPC methods are fixed; you can't query arbitrary subsets of fields
REST vs GraphQL vs gRPC: Full Comparison
| Criterion | REST | GraphQL | gRPC |
|---|---|---|---|
| Protocol | HTTP/1.1+ | HTTP/1.1+ | HTTP/2 |
| Data format | JSON (typically) | JSON | Protocol Buffers (binary) |
| Schema/contract | OpenAPI (optional) | SDL (required) | Proto files (required) |
| Fetching flexibility | Fixed by server | Client-defined | Fixed by server |
| Over-fetching | Common | Eliminated | Common |
| Caching | Native HTTP/CDN | Requires APQ or app-level | Limited |
| Real-time | Polling / webhooks | Subscriptions (WebSocket) | Streaming (native HTTP/2) |
| Browser support | Native | Native | Requires gRPC-Web proxy |
| Tooling maturity | Very mature | Mature | Mature (backend-only) |
| Learning curve | Low | Medium | Medium-High |
| File uploads | Native multipart | Extension spec required | Streaming (chunked) |
| Type safety | Optional (OpenAPI) | Built-in (SDL) | Built-in (proto) |
| N+1 risk | HTTP layer | DB layer (needs DataLoader) | N/A (fixed methods) |
| Performance | Good | Good (requires DataLoader) | Excellent |
| Best for | Public APIs, simple CRUD | Multi-client, complex graphs | Internal microservices |
| Versioning | URL versioning | Schema evolution + @deprecated | Proto field numbering |
Key Takeaways
- GraphQL: clients declare exactly the fields they need — eliminates over-fetching and multiple round trips for related data in one typed query
- REST: simpler, natively cacheable, universally familiar — the right default for most APIs, especially public-facing or simple CRUD
- gRPC: binary serialization over HTTP/2 with streaming support — best for high-throughput internal service-to-service communication
- GraphQL wins with multiple clients needing different data shapes, rapid frontend iteration, real-time subscriptions, and complex nested data access patterns
- REST wins with CDN caching requirements, public APIs, simple resources, and teams without GraphQL experience to invest
- GraphQL's N+1 problem is real and production-breaking — always implement DataLoader, always scope loader instances per-request (never module-level singletons)
@deprecatedin GraphQL does not remove fields — it's advisory; removal requires monitoring field usage and coordinating client migration explicitly- Apollo Federation is the production answer for GraphQL at microservice scale — multiple subgraphs composing into one client-facing graph
- Most teams start with REST and add GraphQL when REST's fixed response shapes become genuinely painful — not as a default from day one
Start REST. Add GraphQL when you have a concrete fetching problem it solves.
FAQ
What is the difference between GraphQL and REST?
REST exposes data as multiple URL-based resources using HTTP verbs (GET, POST, PUT, DELETE). The server defines the response shape for each endpoint — clients get what the server sends, whether they need all of it or not. GraphQL provides a single endpoint where clients send queries that specify exactly which fields they need. The key differences: REST returns fixed resource shapes (causing over-fetching), while GraphQL returns exactly what you ask for; REST uses multiple endpoints, GraphQL uses one; REST caches natively via HTTP URL semantics, GraphQL requires explicit caching strategy work (APQ, app-level caching).
Is GraphQL always better than REST?
No. GraphQL solves specific, concrete problems — over-fetching, multiple round trips for nested data, and serving multiple clients with divergent data needs from one backend. For simple CRUD APIs, public-facing APIs, server-to-server microservice communication, or file-heavy workloads, REST is simpler to implement, easier to cache, has lower per-request overhead, and requires less infrastructure. The right choice depends on your access patterns, client diversity, and team context — not which technology is newer.
What is over-fetching and under-fetching in REST?
Over-fetching means the server returns more data than the client needs — a GET /users/123 endpoint returns 20 fields when the calling screen needs 3. Under-fetching means one request doesn't return enough data, requiring additional round trips — fetching a post doesn't include the author, so you need a second request to /users/:id. Both waste bandwidth, add latency, and increase client-side parsing work. GraphQL eliminates both by letting clients define the exact response shape in the query — the server returns precisely what was asked for and nothing more.
What is the N+1 problem in GraphQL?
The N+1 problem occurs when GraphQL resolvers make one database query per item in a list. If you fetch 100 posts and each post's author field resolver fires a separate SELECT * FROM users WHERE id = ? query, you make 101 database queries (1 for posts + 100 for authors) instead of 2. The solution is the DataLoader pattern — it batches multiple individual .load(id) calls within a single request tick into one batch query (WHERE id = ANY(...)). DataLoader instances must be created per-request in the GraphQL context factory, not as module-level singletons, to prevent cross-request data leaks between users.
Can I use GraphQL with a REST backend?
Yes — this is a common and practical architecture. A GraphQL server acts as an aggregation layer (Backend for Frontend pattern) that calls multiple internal REST services and composes their responses into a unified GraphQL schema. The GraphQL resolvers call REST endpoints, apply DataLoader batching across multiple REST calls where IDs overlap, and return a composed response in the exact shape the client queried. Apollo Federation scales this pattern — multiple GraphQL subgraphs (each potentially backed by different REST services or databases) compose into a single federated client-facing graph.
Does GraphQL work well with caching?
Not by default. GraphQL sends queries as HTTP POST requests, which CDNs do not cache. Production solutions: (1) Automatic Persisted Queries (APQ) — Apollo converts POST queries to GET requests using a hash, enabling CDN edge caching; (2) @cacheControl directives — per-field TTL hints that determine the response's maximum cacheable lifetime; (3) Application-level caching — Redis/Memcached caching of resolver results with field-specific invalidation logic; (4) DataLoader — deduplicates DB queries within a single request (not cross-request caching). REST's GET-based URL model is fundamentally simpler to cache at every layer. If edge caching is a primary architectural concern, REST has a significant structural advantage.
When should I choose REST over GraphQL?
Choose REST when: your API is consumed by external developers (lower onboarding friction), your data model is simple with few resources and stable access patterns, CDN/HTTP caching is critical to your performance architecture, your team lacks GraphQL experience and the project doesn't justify the investment, you're building service-to-service microservice communication (REST or gRPC is simpler here), or file uploads and downloads are primary operations. GraphQL is worth the added complexity specifically when you have multiple clients needing different data shapes, frontend teams that need to iterate on data requirements without backend changes, or genuinely graph-shaped relational data that requires traversal in client queries.
Related reading: REST API Design Best Practices · API Gateway Pattern · Rate Limiting Your API
Enjoyed this article?
Get weekly insights on backend architecture, system design, and Go programming.
Related Posts
Continue reading with these related posts
REST API Design Best Practices: The Complete Guide (2026)
REST API design best practices covering URL naming, HTTP status codes, versioning, pagination, error responses, security, and request/response design. With real HTTP examples.
DNS (Domain Name System) Explained: How It Works, Record Types, Security, and Cloud
Complete guide to how DNS works: resolution process, record types, TTL, DNSSEC, DNS over HTTPS, Route 53, Cloudflare, troubleshooting with dig, and common DNS mistakes engineers make.
Idempotency in APIs: Preventing Duplicate Operations
Learn what idempotency means in API design and why it matters for payments, retries, and distributed systems. With practical implementation patterns.