The first GraphQL API I shipped fell over in staging about a week after launch. Not because of some exotic edge case. A product listing page asked for 40 items, each item asked for its author, and my resolver happily fired 41 database queries to serve one request. The page took four seconds. I sat there staring at the logs going “why is it hitting the DB forty-one times for ONE page load,” and that was the moment GraphQL stopped being magic and started being a thing I actually had to understand.
So here’s the stuff I wish someone had told me before I started, instead of the “GraphQL solves over-fetching!” marketing I’d absorbed.
the schema is the actual job
Everyone talks about resolvers and DataLoader and caching. But the thing that decides whether your API is nice to work with or a lifelong regret is the schema. And you design it early, when you know the least.
I used to sketch schemas that mirrored my database tables one-to-one. Big mistake. Your schema is a contract with the frontend, not a mirror of your storage. The client doesn’t care that author_id is a foreign key. They want post.author.name.
type Post {
id: ID!
title: String!
body: String!
author: User!
comments(first: Int = 10): [Comment!]!
}
type User {
id: ID!
name: String!
posts: [Post!]!
}
Notice author returns a User, not an ID. That relationship is where all the interesting problems live. Also notice comments takes a first argument with a default. Pagination arguments belong in the schema from day one, because retrofitting them later means every client that wrote post.comments now gets a breaking change. Ask me how I know.
One more: think hard about nullability. String! means “I promise this is never null.” If a resolver ever returns null for a non-null field, GraphQL doesn’t just null that field - it bubbles the error up and nulls the whole parent object. I marked a field non-null, an upstream service occasionally returned nothing, and suddenly entire posts vanished from responses instead of one harmless empty field. Non-null is a promise. Don’t make promises you can’t keep.
the N+1 problem will get you, guaranteed
Back to my four-second page. Here’s the naive resolver that caused it:
const resolvers = {
Post: {
// called once PER post in the list
author: (post) => db.users.findById(post.authorId),
},
};
Looks fine. Reads fine. It’s a bomb. GraphQL resolves fields per-object, so if you return 40 posts, this author resolver runs 40 times, each doing its own query. One query for the posts, forty for the authors. N+1.
The fix is DataLoader, and honestly it’s the single most important tool in the Apollo/Node ecosystem. It batches all the calls made in one tick of the event loop into a single lookup.
const userLoader = new DataLoader(async (ids) => {
const users = await db.users.findByIds(ids);
// MUST return in the same order as ids, one per id
return ids.map((id) => users.find((u) => u.id === id));
});
const resolvers = {
Post: {
author: (post) => userLoader.load(post.authorId),
},
};
The non-obvious part that bit me: DataLoader must return results in exactly the same order as the input IDs, and exactly one result per ID. Your DB’s WHERE id IN (...) won’t preserve order and won’t return rows for missing IDs. If you skip the re-mapping, DataLoader silently hands the wrong user to the wrong post. That’s a data-leak-flavored bug, and it will not throw. It just quietly returns garbage.
Second thing nobody tells you: create a fresh DataLoader per request. It caches, and you do not want request A’s cached user showing up in request B’s response after that user changed their name. New loaders in your context function, every request.
over-fetching isn’t the win they sold you
The pitch for GraphQL is “ask for exactly what you need, no over-fetching.” And yeah, on the wire, the client gets a lean response. Cool.
But over-fetching didn’t disappear. It moved. It moved to your backend, where your resolvers are now potentially hammering three services and a database to assemble whatever combination of fields the client dreamed up. A REST endpoint you can profile and cache as a unit. A GraphQL query is a different shape every time, so caching is genuinely harder and one expensive nested field can tank a whole request.
I’m not saying it’s bad. I’m saying the over-fetching problem became a backend problem you now own, and if your mental model is “GraphQL fixed fetching” you’ll get blindsided. You didn’t remove the work. You moved where it happens.
error handling is just different, accept it early
Coming from REST, this one messed with my head. In REST, a 404 is a 404, a 500 is a 500, the status code tells the story. GraphQL returns 200 OK for basically everything. Even when half your query blew up.
A partial failure comes back looking like this:
{
"data": { "post": { "title": "Hello", "author": null } },
"errors": [
{ "message": "Failed to load author", "path": ["post", "author"] }
]
}
You get data AND errors in the same body. The frontend has to check the errors array, not the status code. I’ve seen client code that only looked at res.ok and assumed everything was fine because it got a 200 - meanwhile half the fields were null and the errors array was screaming into the void.
For actual expected failures - validation, “email already taken,” that kind of thing - don’t throw. Model them in the schema as part of the result type. Return a union or a payload with a typed error field so the client can handle it properly instead of parsing error strings. Throwing should be reserved for genuinely exceptional stuff.
when I’d just use REST
I like GraphQL. I still reach for plain REST more often than my past self would’ve guessed.
Skip GraphQL if your API is basically a handful of resource endpoints that one frontend consumes. You’re paying for the schema, the resolver plumbing, the DataLoader setup, the caching headaches - and getting flexibility nobody’s asking for. A boring REST controller ships faster and any dev on the team can debug it at 2am without a mental map of your resolver graph.
GraphQL earns its keep when you’ve got many different clients wanting different shapes of the same data - a web app, a mobile app, some internal tool - and they’d otherwise be nagging you for a new endpoint every sprint. Or when your data is genuinely graph-shaped and clients want to walk relationships however they please. That’s the sweet spot. Mobile teams especially love it because they control exactly what comes down over a flaky connection.
If it’s file uploads and simple CRUD? REST. Don’t overthink it.
that’s basically it
The one-line version of everything I learned the hard way: GraphQL isn’t a better REST, it’s a different set of tradeoffs. It moves complexity around rather than deleting it. Design your schema like the long-lived contract it is, assume N+1 is lurking behind every relationship field, reach for DataLoader before you think you need it, and teach your frontend that a 200 doesn’t mean it worked.
Get those four right and it’s a genuinely lovely way to build an API. Skip them and you get my four-second product page.