Confession: I once turned a perfectly fine REST API into a GraphQL server because a conference talk convinced me I was living in the past. Three weeks later I had a caching problem I didn’t understand, a resolver that quietly fired 200 database queries, and a very patient teammate asking why the profile page got slower. So when people ask me “graphql vs rest, which one should I use,” I don’t answer with a diagram. I answer with that story.
Here’s the short version for the impatient: REST is still the right default for most APIs, and GraphQL earns its keep when you have a lot of clients asking for a lot of different shapes of the same data. If you want to know why I keep changing my mind on this, read on.
The problem REST actually handed me
REST is lovely until one screen needs data from four places. Say you’re building a mobile profile page. You need the user, their last few orders, and the product name for each order. With a typical REST setup you end up doing a little dance:
GET /api/users/42
GET /api/users/42/orders?limit=3
GET /api/products/17
GET /api/products/91
GET /api/products/33
That’s five round trips, and the client is now doing join logic that really belongs on the server. You also get everything the /users/42 endpoint feels like returning, including the fourteen fields this screen doesn’t use. That’s over-fetching. And when the screen needs one field the endpoint doesn’t return, you’re either adding a query param nobody else uses or shipping a v2 of the endpoint. That’s under-fetching. I spent a good chunk of 2023 building ?include=orders,products params to paper over exactly this.
None of it is fatal. Mobile teams have shipped on REST for over a decade. But the friction is real, and it’s the friction GraphQL was built to remove.
What GraphQL actually changed for me
GraphQL flips the control. The client says what it wants, once, and the server assembles it. The same profile screen becomes a single request:
query ProfileScreen {
user(id: 42) {
name
orders(limit: 3) {
total
product { name }
}
}
}
One round trip. The client gets exactly the fields it asked for, no more. If next week the design needs the order date, a frontend dev adds date to the query and ships it without waiting on a backend release. The official GraphQL docs frame this as asking for a graph and getting back a graph, which sounds abstract until the day you delete three hundred lines of endpoint glue and feel genuinely lighter.
This is the part that sold me the first time. When you have a dozen screens across web, iOS, and Android all wanting slightly different slices of the same objects, a single flexible endpoint beats maintaining forty bespoke ones. GitHub moved a big chunk of its public API to GraphQL for basically this reason, and you can poke at their GraphQL API to see how a mature schema holds up.
The N+1 trap nobody puts on the slide
Here’s what the conference talk left out. That clean query above? The naive server implementation runs one query for the user, one for the orders, and then one query per order to fetch the product. Three orders, three extra queries. Fifty orders, fifty extra queries. This is the N+1 problem, and GraphQL makes it easy to write by accident because the resolver for product has no idea it’s being called in a loop.
My profile page got slower for exactly this reason. The fix isn’t exotic, but you have to know to reach for it. You batch the loads:
// Without batching: one DB hit per order's product
const resolvers = {
Order: {
product: (order) => db.product.findById(order.productId),
},
};
// With DataLoader: one DB hit for all products in the request
const productLoader = new DataLoader(async (ids) => {
const rows = await db.product.findMany({ where: { id: { in: ids } } });
return ids.map((id) => rows.find((r) => r.id === id));
});
const resolvers = {
Order: {
product: (order) => productLoader.load(order.productId),
},
};
DataLoader collects every product request in a single tick and fires one WHERE id IN (...) query instead of fifty. If you’re coming from an ORM background, this is the same class of problem I ran into when I wrote up Drizzle vs Prisma — the abstraction hides the query count until it bites you. GraphQL just moves the footgun somewhere new.
Where REST still quietly wins
I don’t want to oversell the migration, because I’ve paid for that mistake once already. REST has real advantages that don’t show up in a demo.
Caching is the big one. A GET /api/products/17 is a boring HTTP GET, which means your CDN, your browser, and every proxy in between already know how to cache it with an ETag and a Cache-Control header. GraphQL sends everything as a POST to a single URL, so all that free HTTP caching evaporates and you rebuild it yourself at the client or with persisted queries. For a read-heavy public API, that’s a lot of machinery to reinvent.
REST is also just simpler to reason about when the API is small. If you’re building a webhook receiver, an internal service with three endpoints, or anything where a curl command should be self-explanatory, a schema, a resolver layer, and a query language is overkill. File uploads, streaming, and binary responses are also less awkward over plain HTTP. And when something breaks at 2am, “which endpoint returned the 500” is an easier question than “which of the nested resolvers in this one query blew up.”
For real-time updates specifically, I usually don’t reach for GraphQL subscriptions at all. I wrote about why in SSE vs WebSockets, and the summary is that a simpler transport usually beats a clever one.
The heuristic I actually use
Strip away the tribal stuff and my decision comes down to one question: how many different clients need how many different shapes of this data?
If the answer is “one client, fairly stable shapes,” I reach for REST and don’t feel bad about it. A small team shipping a single web app almost never needs GraphQL, and the setup cost is real. If the answer is “several clients, all wanting different slices, changing often,” GraphQL starts paying rent. That’s the mobile-plus-web-plus-partners situation, and it’s where the flexible query surface saves you from endpoint sprawl.
The performance comparison people love to argue about mostly cancels out. A well-built REST API and a well-built GraphQL API land in the same ballpark, and both get wrecked by an unbatched N+1. The difference isn’t raw speed, it’s who does the assembly work and how many endpoints you maintain to allow it.
If you want to see where I’ve landed on this kind of trade-off across a few projects, I keep notes and case studies in my work.
What to do this week
Pick your slowest screen, the one that fans out into a handful of API calls. Count the actual round trips it makes and the fields it throws away. If it’s one or two calls, you don’t have a GraphQL problem, you have a normal API. If it’s five calls feeding one view and you’ve got other clients wanting different cuts of the same data, spin up a single GraphQL endpoint for just that slice and measure it, DataLoader included, before you migrate anything else. Let the round-trip count make the decision, not the conference talk.