There’s a peculiar cycle in tech where something new arrives, and suddenly everyone who isn’t using it feels personally attacked. GraphQL arrived about a decade ago, and we’ve been watching the echo chamber ever since. “REST is dead,” they said. “GraphQL is the future,” they proclaimed. Meanwhile, REST APIs quietly powered 90% of the internet and went about their business unbothered. Don’t get me wrong—I’m not here to tell you GraphQL is bad. It’s a genuinely useful tool. But somewhere between the hype and reality lies a more boring, practical truth: REST is still absolutely fine for most applications, and the industry’s obsession with GraphQL has caused real problems for real teams. Let me explain why, backed by actual tradeoffs, not just nostalgia.
The REST Advantage Nobody Talks About: Simplicity
When you build a REST API, you’re not inventing a new query language. You’re not asking your frontend team to learn GraphQL schema design. You’re not solving novel caching problems. You’re doing something that’s been battle-tested for decades. Here’s what that means in practice:
GET /api/users/123
GET /api/users/123/orders
GET /api/posts/456
Your frontend developer doesn’t need to know about resolvers, query complexity analysis, or depth limiting. They know that:
GETmeans fetchPOSTmeans createPUTmeans updateDELETEmeans remove That’s it. Everyone on the team, from the intern to the architect, understands this immediately. There’s no learning curve. There’s no surprise resolver overhead. There’s just basic HTTP semantics that literally every developer has been using since 2010. Compare that to GraphQL, where your frontend dev needs to understand:- Schema introspection
- Query structure and syntax
- Fragment composition
- Alias usage
- Resolver execution order For a simple CRUD application? That’s overkill. It’s like using a blowtorch to light a candle.
The Caching Elephant in the Room
Here’s something that genuinely surprises developers who’ve only lived in the GraphQL world: REST caching is almost automatic.
Cache-Control: max-age=3600
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
Last-Modified: Wed, 18 Feb 2026 12:00:00 GMT
With REST, browsers cache your responses. CDNs cache your responses. Proxies cache your responses. Varnish, Nginx, CloudFlare—they all know how to handle HTTP caching without you writing a single line of custom code. GraphQL? It POSTs to a single endpoint. No URL to cache on. No HTTP semantics to leverage. You’re now responsible for:
- Building custom caching layers
- Implementing query complexity analysis (so clients can’t DOS you with complex queries)
- Persisting queries (to lock down what clients can actually request)
- Managing cache invalidation at the resolver level That’s infrastructure tax. Real infrastructure tax. Real cost. Real operational burden. A 20-person startup doesn’t need this complexity. A 200-person company that’s not infrastructure-obsessed doesn’t need this complexity. Even a 2000-person company might be better off with a simple REST API and a CDN.
The N+1 Problem Just Moved, It Didn’t Disappear
GraphQL evangelists love pointing out REST’s N+1 problem. Want a user and their last 5 orders? That’s 6 requests:
// REST: N+1 problem (client side)
const user = await fetch(`/api/users/123`);
const orders = await fetch(`/api/users/123/orders?limit=5`);
// 2 requests to get related data
GraphQL claims to solve this with a single request:
# GraphQL: Single request
query {
user(id: 123) {
name
email
orders(limit: 5) {
id
product
total
}
}
}
Brilliant! Except… GraphQL just moved the N+1 problem to the server side. Now your resolvers are executing N queries. If a poorly-written resolver is exposed to your database, a single GraphQL query can trigger a table scan. A client constructs a complex nested query, and suddenly you’ve got thousands of database queries hitting your server. With REST, that scenario is literally impossible. You control the endpoint. You control how much data is exposed. A client can’t accidentally DOS your database through a malformed request because the endpoint doesn’t exist yet. Which is safer? REST.
Let’s Talk About What GraphQL Actually Solved
To be fair, GraphQL does solve real problems—but only specific problems:
Problem 1: Multiple clients with different data needs
If you have a web app, a mobile app, and a third-party API, they probably want different fields. REST? You’re building multiple endpoints or returning bloated responses. GraphQL? Client asks for what it needs.
Problem 2: Deeply nested data structures
Modern UIs are complex. They need related data. GraphQL handles this elegantly in a single request.
Problem 3: API versioning
REST versioning is annoying. /v1/users, /v2/users, /v3/users. GraphQL adds new fields without breaking existing queries.
These are real problems with real solutions. If you have them, GraphQL makes sense.
But here’s the thing: most applications don’t have these problems.
The Decision Tree Nobody Follows
Looking at the practical guidance from real-world API design:
├─ Is it a simple CRUD app?
│ └─ REST (seriously, just REST)
│
├─ Do you have 3+ significantly different clients?
│ └─ GraphQL (now you have a real use case)
│
├─ Is data deeply nested and complex?
│ └─ GraphQL (it's actually useful here)
│
├─ Do you need real-time bidirectional updates?
│ └─ gRPC or GraphQL subscriptions (GraphQL struggles, gRPC is better)
│
├─ Is caching your primary concern?
│ └─ REST (don't even consider GraphQL)
│
└─ Are you building this in the next 48 hours?
└─ REST (GraphQL has startup overhead)
Notice what most startups and small teams fall into? The REST category. Yet they’re building GraphQL APIs anyway because it’s what they think they’re “supposed” to do.
A Practical Example: The Todo App Nobody Needs GraphQL For
Let me show you what a real application looks like. Not a hypothetical. Not a tutorial. An actual production API that serves multiple frontend applications:
// REST API - Clean, simple, caches beautifully
app.get('/api/todos', async (req, res) => {
const page = req.query.page || 1;
const limit = req.query.limit || 20;
const todos = await db.todos
.find({ userId: req.user.id })
.skip((page - 1) * limit)
.limit(limit)
.exec();
res.set('Cache-Control', 'max-age=300');
res.json(todos);
});
app.get('/api/todos/:id', async (req, res) => {
const todo = await db.todos.findById(req.params.id);
res.set('Cache-Control', 'max-age=600');
res.json(todo);
});
app.post('/api/todos', async (req, res) => {
const todo = await db.todos.create({
title: req.body.title,
userId: req.user.id
});
res.status(201).json(todo);
});
app.put('/api/todos/:id', async (req, res) => {
const todo = await db.todos.findByIdAndUpdate(
req.params.id,
req.body,
{ new: true }
);
res.json(todo);
});
app.delete('/api/todos/:id', async (req, res) => {
await db.todos.findByIdAndDelete(req.params.id);
res.status(204).send();
});
That’s it. That’s the entire API. A frontend can fetch todos, filter by page, get details, create, update, delete. A mobile app can do the same thing. A CLI tool can do the same thing. No schema to maintain. No resolver overhead. No query complexity analysis. No persisted query lists. No specialized caching infrastructure. Now let’s look at the GraphQL equivalent:
type Query {
todos(page: Int, limit: Int): [Todo!]!
todo(id: ID!): Todo
}
type Mutation {
createTodo(title: String!): Todo!
updateTodo(id: ID!, title: String): Todo!
deleteTodo(id: ID!): Boolean!
}
type Todo {
id: ID!
title: String!
completed: Boolean!
createdAt: DateTime!
updatedAt: DateTime!
}
const resolvers = {
Query: {
todos: async (_, { page = 1, limit = 20 }, context) => {
return await db.todos
.find({ userId: context.user.id })
.skip((page - 1) * limit)
.limit(limit)
.exec();
},
todo: async (_, { id }, context) => {
return await db.todos.findById(id);
}
},
Mutation: {
createTodo: async (_, { title }, context) => {
return await db.todos.create({
title,
userId: context.user.id
});
},
updateTodo: async (_, { id, title }, context) => {
return await db.todos.findByIdAndUpdate(id, { title }, { new: true });
},
deleteTodo: async (_, { id }, context) => {
await db.todos.findByIdAndDelete(id);
return true;
}
}
};
We’ve written more code. We’ve added a schema layer. We’ve added resolver execution. We’ve added zero additional value for this use case. The frontend still makes the same logical requests. The mobile app still fetches the same data. But now we’re managing two API specifications instead of one. We’re debugging resolver order and field-level resolution overhead. We’re explaining to new team members why we use GraphQL for what is fundamentally a CRUD API.
The Real Cost: Operational Burden
Here’s what gets glossed over in the GraphQL marketing materials: At scale, you need:
- Query complexity analysis: Because a client can write a query that triggers a full table scan
- Depth limiting: To prevent recursive queries from destroying your database
- Persisted queries: To prevent arbitrary query injection
- Custom APM tooling: Standard APM tools don’t understand resolver-level execution
- Caching infrastructure: Redis or similar for query result caching (because HTTP caching doesn’t work)
- Rate limiting by query complexity: Not by endpoint, because you only have one For a team of 2-3 engineers? That’s months of work. For a team of 20? It’s still a meaningful project. For a team of 200 with an SRE organization? Sure, you build it once and it’s worth it. But there’s a middle ground—the vast majority of companies—where REST never required any of that infrastructure. Your CDN handled caching. Your API gateway handled rate limiting. Your standard APM tools understood what was happening.
When REST Actually Loses
Let me be specific about scenarios where GraphQL genuinely wins: Scenario 1: A public API serving hundreds of different clients Netflix’s UI needs 50 fields. Third-party developer’s mobile app needs 8. Your own dashboard needs 35. GraphQL stops you from returning unnecessary data. REST forces you to pick a lowest common denominator or have multiple endpoints. Scenario 2: Complex, interconnected data with multiple client types You’re building a social network where users have posts, comments, likes, followers, following, etc. A mobile app wants minimal data. A desktop web app wants everything. A third-party wants specific subsets. GraphQL’s flexibility is genuinely valuable here. Scenario 3: Rapid frontend iteration You’re at a startup. Your UI changes weekly. With REST, you need backend changes constantly. With GraphQL, your frontend can explore the schema and ask for what it needs without backend intervention. These scenarios exist. They’re real. And in those cases, GraphQL is legitimately the right tool. But they’re not every scenario. They’re not even most scenarios.
The Diagram Nobody Asked For But Everybody Needs
significantly different
client types?"] -->|Yes| B["Does caching matter?"] A -->|No| C["Is your data
deeply nested?"] B -->|High importance| D["REST with
strategic endpoints"] B -->|Low importance| E["GraphQL"] C -->|Yes| E C -->|No| F["Is this API
internal or public?"] F -->|Internal| G["How many engineers
on your team?"] F -->|Public| H["GraphQL
flexibility wins"] G -->|Less than 10| I["REST
Keep it simple"] G -->|More than 10| J["Either works;
pick based on
data complexity"] I --> K["REST API"] J --> L["Consider both"] D --> K E --> M["GraphQL API"] H --> M
The Personal Take
I’ve built APIs in both. I’ve maintained both. I’ve debugged both at 3 AM. REST is boring. It’s predictable. It’s not sexy. You won’t get Twitter engagement writing about how awesome HTTP status codes are. There’s no conference talk in “here’s how we use standard HTTP caching.” But boring and predictable is a feature when you’re running a business. It means your junior engineer can maintain it. It means your CDN actually works. It means you can reason about performance without becoming a resolver execution expert. It means when something breaks at 2 AM, you don’t need the original author on a video call to understand what’s happening. GraphQL is brilliant when you need its features. When you don’t, you’re paying a cognitive and operational tax for flexibility you don’t use. The industry has treated this like a religion, and that’s the real problem. It’s not “REST vs GraphQL.” It’s “right tool for the job vs what everyone else is using.” A boring REST API that ships fast, caches well, and nobody needs to maintain is worth more than a sophisticated GraphQL implementation that solves problems you don’t have. Use REST until you have a genuine reason not to. That day might come. For many teams, it never does, and that’s perfectly fine.
