GraphQL is easy to start and easy to get wrong in ways that only show up under load. These are the lessons that arrive in roughly the order below.
The schema is a public contract that outlives your resolvers. Two decisions made on day one are expensive to reverse. First, nullability: in GraphQL every field is nullable unless you add !, and a non-null field that errors nulls out its entire parent object, which cascades upward. Being liberal with ! feels rigorous and produces outages where one bad row blanks a whole response. Second, connections: use the cursor-based Relay connection shape (edges, node, pageInfo) for lists from the beginning, even if you only need simple pagination now. Retrofitting it means versioning every list field you shipped.
A query for 50 posts each with an author executes one query for posts and then 50 more for authors, because resolvers run per field per object and know nothing about their siblings. This is not a bug in your code; it is the default behaviour of the execution model.
const authorLoader = new DataLoader(async (ids) => {
const rows = await db.user.findMany({ where: { id: { in: ids } } });
const byId = new Map(rows.map(r => [r.id, r]));
return ids.map(id => byId.get(id) ?? null); // order must match input
});
// resolver
author: (post, _args, ctx) => ctx.loaders.author.load(post.authorId)
DataLoader collects every .load() call within one tick of the event loop and issues a single batched query. Two rules make it work: create the loaders per request, never globally, or you will serve one user cached data belonging to another; and return results in exactly the order of the input keys, because DataLoader matches by position.
A public GraphQL endpoint lets clients ask for anything, including deeply nested cycles. You need three defences, and none of them are optional in production:
Also turn off introspection in production for a non-public API, and be aware that error messages leak schema details through "did you mean" suggestions unless you disable that too.
REST gets edge caching, ETag revalidation and browser caching for free because a GET URL is a cache key. GraphQL POSTs everything to one endpoint, so none of that works. What you get instead is normalised client-side caching — Apollo Client, urql and Relay store entities by __typename plus id, so a mutation returning an updated object refreshes every view of it automatically. That is genuinely powerful and it is a different thing from what you gave up. If you need CDN caching, use automatic persisted queries over GET, which restores a cacheable URL.
Code generation (graphql-codegen) turns your schema into TypeScript types for both server resolvers and client hooks, and it is the single largest quality-of-life improvement available. Schema linting plus a breaking-change check in CI stops someone removing a field a mobile client still uses. Per-resolver tracing, via the Apollo tracing format or OpenTelemetry, is how you find the slow field — average response time on a single endpoint tells you nothing when every request is a different query.
If you have one client, a handful of endpoints and no aggregation problem, GraphQL adds a schema, a resolver layer, a caching library and a security surface in exchange for flexibility you are not using. It earns its cost when several clients with different data needs hit the same backend, or when a screen would otherwise require five round trips.
Free tools, guides, and resources across the SPUNK13 network.
Visit spunk.bet400+ Free Tools