All lessons
ArchitectureIntermediate11 min readWorth knowing

GraphQL

Schemas, resolvers, the N+1 problem, caching and security, and when REST or tRPC wins instead.

  • Schemas
  • Resolvers
  • N+1
  • tRPC
  • vs REST
Step 1/5
REST: the app needs a user, their posts and the comments. That's three endpoints and three round trips, one after another.

In 30 seconds

GraphQL is a query language for APIs: the server publishes a typed schema, and each client asks for exactly the fields it needs in a single request. It is excellent when many different clients need different shapes of the same data. It also shifts complexity to the server: batching to avoid N+1 queries, harder HTTP caching, and protection against expensive queries.

Not in my daily stack — I know the concepts and would learn the details when a project needs it.

Why it matters

  • GraphQL matters when many clients (web, mobile, partners) need different slices of a rich, connected data model.
  • A strongly typed schema becomes a contract and a source for generated client types and documentation.
  • It reduces over-fetching and under-fetching, which helps mobile clients on slow networks.
  • Knowing its costs explains why I usually build REST APIs with shared, typed clients instead, and when I would switch.

How it works

01Schemas, types and resolvers

Think of a REST API as a set menu: each endpoint returns a fixed plate. GraphQL is a buffet with a precise order form: the client lists the dishes and portions it wants. The server describes everything available in a schema written in the Schema Definition Language (SDL). Object types (Product, Order) have fields with types; scalars are the basics (String, Int, Boolean, ID); the exclamation mark means non-null; lists use brackets. Special root types define entry points: Query for reads, Mutation for writes, Subscription for real-time updates. Each field is backed by a resolver, a function that returns its value. The server parses a query, validates it against the schema, then calls resolvers field by field, building a response that mirrors the shape of the query. Unknown fields are rejected before any code runs.

graphql
type Product {
  id: ID!
  name: String!
  price: Int!
  category: Category!
}
 
type Category {
  id: ID!
  name: String!
}
 
type Query {
  products(first: Int = 20, after: String): [Product!]!
  product(id: ID!): Product
}
 
type Mutation {
  renameProduct(id: ID!, name: String!): Product!
}

02Queries, mutations and subscriptions

A query asks for data; the client chooses fields and can follow relationships in one request, such as products with their category names. Variables keep queries static and reusable, which also enables persisted queries. Fragments reuse field selections across components, and many client libraries colocate them with UI components. A mutation changes data and returns the updated object, so the client cache can refresh without another request; mutations in one request run in sequence. Design mutations around business actions (placeOrder, cancelSubscription) rather than generic table updates, and return structured errors for expected failures, such as validation, inside the payload. Subscriptions push updates to the client, typically over WebSockets or server-sent events, for things like order status or chat. They add stateful connections to scale and secure, so use them where live updates truly matter.

03The N+1 problem and DataLoader

Because each field has its own resolver, a naive server runs one query for the list of 50 products and then one query per product for its category: 51 queries for one request. That is the N+1 problem, and nested queries make it worse. The standard fix is batching with DataLoader. Instead of fetching immediately, each resolver asks a loader for a key; the loader collects all keys requested in the same tick of the event loop and fetches them with one query (WHERE id IN ...), then returns results in the right order. It also caches per request, so the same category is loaded once. Create loaders per request, never globally, so cached data cannot leak between users. Monitor resolver timings and query counts per operation to catch regressions.

ts
import DataLoader from "dataloader";
 
// One set of loaders per request, so caching never leaks between users
export function createLoaders(db: Db) {
  return {
    categoryById: new DataLoader<string, Category | null>(async (ids) => {
      const rows = await db.category.findMany({ where: { id: { in: [...ids] } } });
      const byId = new Map(rows.map((c) => [c.id, c]));
      return ids.map((id) => byId.get(id) ?? null);
    }),
  };
}
 
export const resolvers = {
  Product: {
    category: (p: Product, _args: unknown, ctx: Ctx) =>
      ctx.loaders.categoryById.load(p.categoryId),
  },
};

04Caching and security

REST benefits from HTTP caching: each URL is a resource that browsers, CDNs and proxies can cache. GraphQL usually sends POST requests to a single endpoint, so that layer disappears. The answers: normalised client caches (Apollo Client, urql, Relay) that store objects by type and ID; persisted queries sent as GET with a hash, which makes CDN caching possible again; and server-side caching of resolver results with careful per-user scoping. Security needs equal care. A client can write a deeply nested or huge query that costs the server a fortune, so enforce maximum depth, query complexity or cost limits, pagination limits and timeouts. Rate limit by cost, not just request count. In production, consider disabling introspection for private APIs or, better, allow only persisted queries from your own clients. Authorise in resolvers or a business layer, per field where needed, never only at the endpoint.

  • Depth and complexity limits, plus required pagination on lists.
  • Persisted queries: smaller requests, CDN caching, a safe allow-list.
  • Field-level authorisation and per-request DataLoaders.

05GraphQL vs REST vs tRPC

Choose by client landscape and team setup. GraphQL wins when many clients with different needs share a rich, connected domain, when a platform team serves several product teams, or when you aggregate several backends into one graph (federation). REST wins for public APIs, simple resource models, file uploads and downloads, webhooks and anything that benefits from HTTP caching and familiar tooling; with OpenAPI you still get typed clients. tRPC wins in TypeScript monorepos where the same team owns server and client: procedures are called like functions with end-to-end types and almost no schema overhead, but it is TypeScript-only and not meant as a public, language-neutral API. Many systems mix them: REST or tRPC for internal apps, a public REST API for partners, and GraphQL only where its flexibility pays for its complexity.

Use it when

  • Several clients (web, iOS, Android, partners) need different shapes of the same connected data.
  • Mobile apps where reducing round-trips and payload size matters.
  • A platform team exposing a unified graph over several backend services.
  • Product teams that iterate fast on UI and want to avoid new endpoints for every screen.

Skip it when

  • Simple CRUD APIs or public APIs where REST and HTTP caching are easier for consumers.
  • A TypeScript monorepo where one team owns both sides; tRPC or typed REST is simpler.
  • Teams without capacity to handle query cost limits, batching and monitoring.

Alternatives

  • REST + OpenAPI

    Resource-oriented, HTTP-cacheable, universal tooling; generate typed clients from the spec.

  • tRPC

    End-to-end TypeScript types with no schema layer; best inside one TypeScript codebase.

  • gRPC

    Fast binary contracts for service-to-service calls; awkward directly from browsers.

  • Backend-for-frontend

    A small REST layer per client shaping data exactly for that app.

Best practices

  1. Design the schema around the domain and client needs, not around database tables.
  2. Use DataLoader per request for every relationship resolver.
  3. Enforce depth, complexity and pagination limits, and set timeouts.
  4. Prefer persisted queries for first-party clients.
  5. Authorise in the business layer and test field-level permissions.
  6. Evolve the schema additively and deprecate fields with @deprecated before removing them.

Common mistakes

  • Shipping without batching and discovering hundreds of queries per request in production.
  • Exposing unbounded lists and deep nesting, inviting denial-of-service queries.
  • Checking permissions only at the endpoint, so nested fields leak data.
  • Creating a global DataLoader that caches one user's data for another.
  • Mirroring database tables one to one, which leaks internals into a public contract.

Deep dive

06Execution internals: nullability and partial results

A GraphQL response can contain both data and errors. When a resolver throws, the error is added to the errors array with a path to the failing field, and that field becomes null. If the field is declared non-null, null is not allowed, so the null bubbles up to the nearest nullable parent, possibly wiping out a whole list or the entire data object. That makes nullability a design decision, not decoration. Mark fields non-null when they are truly always present and cheap (id, name), and keep fields nullable when they depend on another service or permission check, so one failure degrades a single field rather than the page. Clients must handle partial data. Resolver execution order also matters: sibling fields of a query may resolve in parallel, while top-level mutation fields run serially.

  • Errors carry a path; the failing field becomes null.
  • Non-null fields propagate nulls upward to the nearest nullable parent.
  • Make fields that call fragile dependencies nullable.

07Cost analysis and pagination at scale

Depth limits alone are weak: a shallow query asking for 100 products, each with 100 reviews, each with 100 authors, can request a million objects. Static cost analysis assigns a cost to each field, multiplies list fields by their requested page size, and rejects queries above a budget before execution. Publish the cost in a response extension so clients can see it, and rate limit by cost per minute. Make pagination mandatory with a maximum page size. The cursor-based connections pattern (edges, node, pageInfo with endCursor and hasNextPage) is the de facto standard: cursors are opaque, stable under inserts and avoid the deep OFFSET scans that slow databases down. Encode the sort key and ID in the cursor, and use a keyset query (WHERE (created_at, id) < (...) ORDER BY created_at DESC, id DESC) underneath.

graphql
type ProductConnection {
  edges: [ProductEdge!]!
  pageInfo: PageInfo!
}
 
type ProductEdge {
  cursor: String!
  node: Product!
}
 
type PageInfo {
  hasNextPage: Boolean!
  endCursor: String
}
 
type Query {
  products(first: Int! = 20, after: String): ProductConnection!
}

08Federation: one graph from many services

Federation lets several teams each own a subgraph while clients see one supergraph. A subgraph declares entities with a key, for example a Product identified by id. Another subgraph can extend that entity with its own fields, such as reviews, and provides a reference resolver that loads a product's extra data given only its key. A router composes the subgraph schemas, plans each incoming query, calls subgraphs in the right order, often in parallel, and stitches results together. This keeps team autonomy while giving clients a single API. The costs: composition checks in CI become critical, because one subgraph can break the supergraph; query plans can fan out into many internal calls, so entity resolvers must batch; and debugging needs tracing through the router. It suits organisations with several backend teams; for a single team it is overhead.

09Errors as data, input design and newer spec features

Use the errors array for unexpected failures, and model expected business outcomes in the schema. A mutation can return a union such as PlaceOrderResult = OrderPlaced | OutOfStock | PaymentDeclined, and clients handle each case with fragments, getting typed fields for every outcome. Design inputs per action: placeOrder(input: PlaceOrderInput!) keeps arguments evolvable. The September 2025 edition of the GraphQL specification, the first since 2021, added OneOf input objects, where exactly one field must be provided, which neatly models 'find a product by id or by sku'. Incremental delivery with @defer and @stream, which lets slow parts of a response arrive later, is supported by several servers and clients but still moving through the specification process, so check your stack's support before relying on it.

graphql
union PlaceOrderResult = OrderPlaced | OutOfStock | PaymentDeclined
 
type OrderPlaced { order: Order! }
type OutOfStock { productIds: [ID!]! }
type PaymentDeclined { reason: String! }
 
input ProductLookup @oneOf {
  id: ID
  sku: String
}
 
type Mutation {
  placeOrder(input: PlaceOrderInput!): PlaceOrderResult!
}
 
type Query {
  product(by: ProductLookup!): Product
}

Real-world scenarios

01

A product page triggers 400 database queries

Problem

A new screen requests products with categories, variants and reviews. Monitoring shows hundreds of queries per request and p95 latency above two seconds.

How to solve it

Add tracing per resolver to see which fields fan out. Introduce DataLoaders, created per request in the context, for every relationship: category by ID, variants by product ID, reviews by product ID (returning arrays grouped by key). Check that list fields paginate with a sensible default. Re-measure query count and latency per operation name. Add a CI test that executes the main operations against a seeded database and fails if query count exceeds a threshold, so the regression cannot return silently.

02

Abusive queries against a public endpoint

Problem

Someone sends deeply nested queries that make the API time out for everyone. The API is used by the company's own web and mobile apps only.

How to solve it

Since only first-party clients use it, switch to persisted queries: extract operations at build time, register their hashes, and reject any operation not in the allow-list in production. Until that ships, add depth and cost limits, maximum page sizes and an execution timeout, and rate limit by cost per client. Disable field suggestions in error messages for unknown fields. Log rejected queries with client identifiers to see whether it is abuse or a bug in your own app.

03

Removing a widely used field

Problem

The Product.price field is an integer in cents, and the team wants a Money type with currency. Old mobile app versions will stay in use for months.

How to solve it

Add a new field, priceMoney: Money!, alongside the old one and mark price with @deprecated(reason: "Use priceMoney"). Ship clients that use the new field. Track usage of price by operation name and client version in your server metrics or schema registry. Keep both fields until usage of the old one falls to an agreed level, considering forced-update policies for the mobile app. Then remove it, with a CI schema check confirming no registered operation still requests it.

Cheat sheet

Deprecate a field
price: Int @deprecated(reason: "Use priceMoney")
Cursor pagination args
products(first: Int! = 20, after: String): ProductConnection!
Per-request loader
context: () => ({ loaders: createLoaders(db) })
Typed business errors
union PlaceOrderResult = OrderPlaced | OutOfStock
Exactly one input field
input ProductLookup @oneOf { id: ID sku: String }
Persisted query (APQ) request
extensions: { persistedQuery: { version: 1, sha256Hash: "..." } }
Reuse field selections
fragment ProductCard on Product { id name price }
Federated entity key
type Product @key(fields: "id") { id: ID! }
Inspect schema
query { __schema { types { name } } }

Questions & answers

Q1What problem does GraphQL solve compared with REST?

It addresses over-fetching and under-fetching. With fixed REST endpoints, a screen often receives fields it does not need, or must call several endpoints to assemble its data. GraphQL lets the client request exactly the fields and relationships it needs in one round-trip, validated against a typed schema. That helps most when many different clients consume the same domain. The trade-off is that complexity moves to the server: batching data access, controlling query cost, and replacing HTTP caching with client caches or persisted queries.

Q2What is the N+1 problem and how do you fix it?

Because each field has its own resolver, fetching a list of N items and then a related object for each item triggers one query for the list plus N queries for the relations. With nesting it multiplies. The standard fix is DataLoader: resolvers request keys from a loader, which collects all keys requested in the same tick and loads them with one batched query, then returns results in order. It also caches within a request. Loaders must be created per request to avoid leaking data between users. Some frameworks also offer query planning or join-based lookahead as alternatives.

Q3How do you secure a GraphQL API?

In several layers. Authentication at the entry point, but authorisation in the business layer or per field, because one query can reach many types. Limits against expensive queries: maximum depth, complexity or cost scoring, mandatory pagination with maximum page sizes, and execution timeouts. Rate limiting by query cost rather than request count. For first-party clients, persisted queries act as an allow-list, so arbitrary queries are rejected. Introspection can be disabled for private APIs, though that is obscurity, not a defence. And the usual rules still apply: validate input, avoid injection in resolvers and log errors without leaking internals.

Q4How do you cache GraphQL responses?

HTTP caching is harder because queries are usually POSTs to one endpoint with varying bodies. On the client, normalised caches like Apollo Client or Relay store objects by type and ID and update them from mutation results. Persisted queries let clients send a hash via GET, which makes CDN and browser caching possible again for public data. On the server, you cache expensive resolver results or upstream calls in Redis, keyed carefully by user or tenant when data is private. Some servers support per-field cache hints to compute a safe response-level cache policy.

Q5When would you pick tRPC over GraphQL or REST?

When the same team owns a TypeScript backend and TypeScript clients in one repository, such as a Next.js app with its API. tRPC gives end-to-end type safety with almost no ceremony: you define procedures on the server and call them from the client like functions, and type errors appear immediately. There is no schema language or code generation. It is not suited to public APIs, non-TypeScript consumers or many independent teams, because the contract is TypeScript types rather than a language-neutral schema. For those cases I would choose REST with OpenAPI or GraphQL.

Q6How do you evolve a GraphQL schema without versioning?

GraphQL encourages continuous, additive evolution instead of v1 and v2 endpoints. Adding new types and fields is safe because existing clients do not request them. To change a field, add the new one, mark the old one with @deprecated and a reason, and monitor field usage through operation tracking until no client uses it, then remove it. Never change a field's meaning or make a nullable argument required without a transition. Schema checks in CI that compare the proposed schema with production traffic catch breaking changes before they ship.

Senior-level questions

S1How would you introduce GraphQL into an organisation with existing REST services?

Incrementally, as a layer for clients rather than a rewrite. Start with one product surface, typically a mobile app suffering from many round-trips, and build a GraphQL gateway whose resolvers call the existing REST services, with DataLoaders batching calls and a cache in front of slow ones. Design the schema around client needs, not the shape of current endpoints. Put persisted queries, cost limits, tracing and schema checks in place from day one. If the pilot reduces client complexity and latency, expand by domain; once several backend teams contribute, consider federation so each owns its subgraph. The trade-offs: another hop and runtime to operate, a skill gap in the team, and the temptation to move business logic into resolvers, which should stay in the services.

S2How do you do field-level authorisation without making resolvers a mess?

Keep authorisation in the domain layer and let the GraphQL layer only call it. Resolvers fetch through services that already know the current user or tenant from context and apply the same policies as REST endpoints, so rules live in one place. For field-level rules, such as cost price visible only to managers, use a schema directive or middleware that maps fields to permission names and checks them before resolution, returning null with an error or a typed 'not allowed' result. Make sure batch loaders also apply scoping, since they bypass per-object resolvers. Test with operations run as different roles. The trade-off: declarative directives are tidy but can hide logic, so keep complex, data-dependent rules in services and use directives for simple role checks.

S3Apollo-style normalised caching versus simple request caching on the client: when is each better?

A normalised cache stores every object once by type and ID, so a mutation that returns an updated product instantly updates every screen that shows it, and overlapping queries can be served without a network call. It shines in rich, interactive apps with many views over the same entities. Its costs are complexity: you must provide stable IDs, write cache update logic for list insertions and deletions, and debug stale entries. Request-level caching, keyed by query and variables as in React Query or urql's document cache, is simpler and predictable: refetch or invalidate by query after mutations. It fits apps with mostly read-heavy, independent screens. I choose normalisation when the same entities are edited and displayed across many views, and simple caching otherwise.

Glossary

Schema
The typed description of every type, field and operation a GraphQL API offers.
Resolver
A function that returns the value for one field of a type.
DataLoader
A utility that batches and caches key lookups within one request.
Fragment
A reusable selection of fields on a type, shared between queries and components.
Persisted query
A pre-registered operation the client calls by ID or hash instead of sending full text.
Connection
A cursor-based pagination pattern with edges, nodes and pageInfo.
Introspection
Queries that let clients discover the schema's types and fields at runtime.
Federation
Composing several independently owned subgraphs into one supergraph behind a router.
Null propagation
An error in a non-null field nulls its nearest nullable parent in the response.

Have a product in mind?Let's build it.

Tell me about your idea, your product or your team. I usually reply within one working day.