GraphQL is marketed as REST killer but each excels in different scenarios. Blind GraphQL adoption brings N+1, complexity explosion, cache invalidation; pure REST suffers over/under-fetching. Compare rationally.
Reference Architecture and Complexity Guards
GraphQL exposes one endpoint accepting declarative queries fetching exactly requested fields - fixing REST's "20 endpoints to assemble" and "80% fields unused" problems. But gateways MUST enforce complexity analysis, field-level auth, and depth limits.
import express, { Request, Response } from "express";
import { graphqlHTTP } from "express-graphql";
import { buildSchema, parse, visit, Kind } from "graphql";
const app = express();
app.use(express.json());
const schema = buildSchema(`
type User { id: ID!; username: String!; orders(limit: Int = 10): [Order!]! }
type Order { id: ID!; total: Float!; items(limit: Int = 20): [OrderItem!]! }
type OrderItem { productId: ID!; qty: Int!; price: Float! }
type Query { user(id: ID!): User; users(limit: Int = 20): [User!]! }
schema { query: Query }
`);
class ComplexityGuard {
private costs: Record<string, number> = { orders: 5, items: 2, users: 10 };
private readonly MAX = 150;
analyze(query: string): { total: number; exceeded: boolean } {
const doc = parse(query); let total = 1;
visit(doc, {
Field: (node) => {
const base = this.costs[node.name.value] || 1;
const args = Object.fromEntries((node.arguments || []).map(a => [a.name.value, (a.value as any).value]));
const mult = args.limit ? Math.ceil(Number(args.limit) / 10) : 1;
total += base * mult;
},
});
return { total, exceeded: total > this.MAX };
}
}
const root = {
user: ({ id }: { id: string }) => ({
id, username: `u${id}`,
orders: ({ limit = 10 }: any) => Array.from({ length: limit }, (_, i) => ({
id: `o${i}`, total: 99.5 + i,
items: ({ limit = 20 }: any) => Array.from({ length: limit }, (_, k) => ({ productId: `p${k}`, qty: k + 1, price: 9.9 })),
})),
}),
users: ({ limit = 20 }: any) => Array.from({ length: limit }, (_, i) => root.user({ id: String(i + 1) })),
};
const guard = new ComplexityGuard();
app.get("/rest/users/:id", (req: Request, res: Response) => {
res.json({ data: { id: req.params.id, username: `u${req.params.id}` }, _links: { orders: { href: `/rest/users/${req.params.id}/orders` } } });
});
app.use("/graphql", (req: Request, res: Response, next) => {
const q = (req.body?.query) || (req.query.query as string) || "";
const r = guard.analyze(q);
if (r.exceeded) return res.status(400).json({ error: `Complexity ${r.total} exceeds cap 150` });
res.setHeader("X-GraphQL-Complexity", String(r.total));
next();
}, graphqlHTTP({ schema, rootValue: root, graphiql: process.env.NODE_ENV !== "production" }));
app.listen(3000, () => console.log("[compare] REST+GraphQL on :3000"));
Seven Dimensions Side by Side
| Dimension | REST (JSON:API style) | GraphQL | Winner |
|---|---|---|---|
| Network efficiency | Multi-trip over/under fetch | Single exact fetch, POST hurts CDN | REST wins simple/GraphQL complex views |
| Frontend DX | New fields = new backend endpoint | Self-service + introspect TS types | GraphQL |
| Backend evolution | Versioned routes /v1 /v2 | Smooth deprecation but resolver complexity explodes | REST |
| HTTP caching | Native GET support | Requires persisted query IDs + custom | REST |
| Type safety | Needs OpenAPI codegen | Strong schema + SDK generation built in | GraphQL |
| Security surface | Standard CRUD auth | Depth/complexity/batch injection guard needed | REST smaller surface |
| Tooling ecosystem | OpenAPI/Postman mature | Apollo Studio/GraphiQL mature | Tied |
Best Practices
Decision tree: Team runs BFF with fast frontend iteration → GraphQL. Inter-service calls or pure resource CRUD → REST. Hybrid (GraphQL BFF composing multiple REST microservices) is today's major-factory mainstream pattern.