GraphQL in depth: schema, execution, caching and security
How does a GraphQL server actually execute a query, and what do you have to build around it for performance, caching, security and errors in production?
GraphQL moves the shape of every response to the client, so the server becomes a query engine: resolvers run per field, batching and cost limits replace endpoint-level tuning, caching moves into normalized client stores and persisted documents, and errors travel next to partial data in a 200.
THE MENTAL MODEL
A GraphQL request is a small program the client sends you. The server parses and validates it against the schema, then walks it field by field, calling a resolver for each one; a list of 50 posts with an author field is 50 author resolver calls unless something like DataLoader coalesces them into one query per tick. Because any client can write any program, the safety rails you used to get from fixed endpoints (predictable cost, cacheable URLs, per-route auth and rate limits) must be rebuilt explicitly: trusted documents for first-party clients, depth, alias and cost limits for everyone, authorization inside resolvers or the domain layer, and a client cache that knows how to normalize and invalidate entities by __typename and id. Nullability decides how far an error spreads: a failed non-null field nulls its parent, all the way up to data: null.
HOW IT FITS TOGETHER
- Client → GraphQL executorPOST /graphql (documentId, variables)Parse, validate against the schema, check depth and cost, all before any resolver runs.
- GraphQL executor → DatabaseQuery.posts: SELECT ... LIMIT 3
- Database → GraphQL executor3 posts, authorIds 7, 9, 404
- GraphQL executor → UserLoaderPost.author x3: load(7), load(9), load(404)Three resolver calls in the same tick. The loader queues keys instead of querying.
- UserLoader → DatabaseOne batch: WHERE id IN (7, 9, 404)Without the loader this is 1 + N queries; with nested lists it multiplies per level.
- Database → UserLoaderRows for 7 and 9 only
- UserLoader → GraphQL executor[User7, User9, Error(not found)]Results must match the key order. An Error in a slot fails only that key.
- GraphQL executor → Client200 data + errors[path: posts.2.author]author: null in data, one entry in errors with path and extensions.code. Had author been User!, posts[2] would be null instead.
| Caches | Key | Invalidation | Watch out for | |
|---|---|---|---|---|
| CDN / HTTP cache | Whole responses to GET queries | URL: documentId + variables | TTL from Cache-Control; purge by tag | POST is not cached; per-user data needs private |
| Server response cache | Whole results per operation | Document, variables, viewer | TTL or entity-tag purge on mutation | Leaking one user's result to another |
| DataLoader | Loaded entities for one request | Entity key | Ends with the request | Sharing a loader across requests or users |
| Data-source cache | Rows or upstream responses | Entity id or upstream URL | TTL, write-through, events | Stale reads after writes elsewhere |
| Normalized client cache | Entities across all queries | __typename + id (keyFields) | Mutation payloads, cache.modify, evict | New list items and deletes need manual updates |
| Document client cache | Whole results per query | Document + variables | Refetch queries sharing a __typename | Over-refetching; no cross-query consistency |
KEY TERMS
- Schema design
- Nullability is an error-handling decision: non-null (!) fields promise a value, and a resolver failure there nulls the nearest nullable parent. Default to nullable for fields backed by other services and non-null for ids and fields from the same row. Paginate lists with Relay-style connections (edges { cursor node }, pageInfo { hasNextPage endCursor }) rather than bare lists. Give each mutation a single input argument and a dedicated payload type that returns the changed entities, so normalized caches update without a refetch.
- Resolvers and N+1
- Every field resolver runs independently, and sibling items in a list resolve concurrently, so naive resolvers issue one query per parent. DataLoader collects keys requested during one tick and calls a batch function once; it also memoizes per key. Create loaders per request (in context), never globally, or one user's authorized data is served to another. Push filtering into the batch query and keep the result order aligned with the key order.
- Errors and partial data
- A GraphQL response can carry both data and errors; each error has message, path, locations and extensions (put a stable code there). The GraphQL-over-HTTP working draft uses 4xx/5xx only when there is no data at all (parse and validation errors) and recommends a custom 294 status for partial success with application/graphql-response+json, which older clients treat as 200. For expected domain outcomes (out of stock, not permitted) prefer result unions, union PlaceOrderResult = Order | OutOfStock, so clients handle them with types instead of parsing errors.
- Caching
- Clients send POST by default, which CDNs do not cache. Persisted or trusted documents let clients send a short id over GET, giving cacheable URLs and a server-side allowlist at the same time; GET must never execute mutations. On the client, Apollo Client, urql Graphcache and Relay normalize by __typename and id, so an updated entity in any response updates every screen. What they cannot infer is list membership: creating or deleting an item requires an updater (cache.modify, updater functions, Relay @appendEdge/@deleteRecord) or a refetch.
- Security
- Validation is not protection. Bound every operation by depth, aliases, tokens and estimated cost (graphql-armor, gqlgen complexity, router limits), cap batched operations per request, and rate limit by cost rather than request count, because one request can ask for 100 aliased copies of an expensive field. For first-party clients, trusted documents are the strongest control. Disabling introspection and field suggestions in production only hides the schema; it does not secure it. Authorize in resolvers or the domain layer on every field that exposes data, because a gateway check on the root field cannot see what nested fields reach.
- Subscriptions
- Subscriptions are long-lived operations that push results. graphql-ws implements the GraphQL over WebSocket protocol (graphql-transport-ws) and is not compatible with the deprecated subscriptions-transport-ws. graphql-sse runs the same idea over Server-Sent Events, which passes through HTTP infrastructure and HTTP/2 without an upgrade. Either way, authenticate at connection init, re-check authorization per event and plan for reconnect plus refetch.
- Federation and supergraphs
- Federation composes subgraphs owned by different teams into one schema, with a router planning queries across them by entity keys. Apollo Federation 2 is the common target (v2.15 LTS, July 2026, with composition rewritten in Rust and Router 2.x). The GraphQL Foundation's federation spec, formerly the Composite Schemas spec, standardizes a vendor-neutral model (@lookup, @key and friends) and is still a draft; ChilliCream Fusion implements it and can also front existing Apollo Federation subgraphs through a connector, while other routers (Hive Gateway, WunderGraph Cosmo) are built primarily around Apollo Federation subgraphs, with varying coverage of the newest v2 directives. Federation fixes organizational scaling, not performance: every hop between subgraphs is a network call in the query plan.
IN YOUR STACK
GraphQL SDL · Schema design patterns Server
type Query {
order(id: ID!): Order
orders(first: Int = 20, after: String, filter: OrderFilter): OrderConnection!
}
type OrderConnection {
edges: [OrderEdge!]!
pageInfo: PageInfo!
}
type OrderEdge { cursor: String! node: Order! }
type PageInfo { hasNextPage: Boolean! endCursor: String }
type Order {
id: ID!
total: Money!
customer: Customer # nullable: lives in another service
status: OrderStatus!
}
input PlaceOrderInput { cartId: ID! idempotencyKey: String! }
type OutOfStock { sku: String! available: Int! }
type PaymentDeclined { reason: String! }
union PlaceOrderResult = Order | OutOfStock | PaymentDeclined
type Mutation {
placeOrder(input: PlaceOrderInput!): PlaceOrderResult!
}- Expected business outcomes are union members, so clients switch on __typename; unexpected failures still go to errors.
- Returning the full Order lets a normalized cache update every view of it without a refetch.
- Adding a member to a union or a value to an enum can break exhaustive client switches; tell clients to handle unknown __typename values.
TypeScript · GraphQL Yoga + DataLoader Server
import DataLoader from "dataloader";
import { GraphQLError } from "graphql";
import { createSchema, createYoga } from "graphql-yoga";
const createLoaders = (db: Db, viewer: Viewer) => ({
user: new DataLoader<string, User>(async (ids) => {
const rows = await db.users.findMany({ where: { id: { in: [...ids] } }, viewer });
const byId = new Map(rows.map((u) => [u.id, u]));
// Same length and order as ids; an Error fails only that key.
return ids.map((id) => byId.get(id) ?? new GraphQLError("User not found", {
extensions: { code: "NOT_FOUND" } }));
}),
});
type Ctx = { viewer: Viewer; loaders: ReturnType<typeof createLoaders> };
export const yoga = createYoga({
schema: createSchema<Ctx>({ typeDefs, resolvers: {
Post: { author: (post, _args, ctx) => ctx.loaders.user.load(post.authorId) },
} }),
context: async ({ request }) => {
const viewer = await authenticate(request);
return { viewer, loaders: createLoaders(db, viewer) }; // per request, never global
},
});- Passing the viewer into the batch query keeps authorization inside the data access path, so batching cannot bypass row-level checks.
- Yoga masks unexpected errors by default; a GraphQLError you throw on purpose keeps its message and extensions.
- db, authenticate, typeDefs and the Db, Viewer and User types are your own modules. Yoga 6 no longer re-exports GraphQLError; import it from graphql.
TypeScript · Yoga + graphql-armor Server
import { createYoga } from "graphql-yoga";
import { EnvelopArmorPlugin } from "@escape.tech/graphql-armor";
import { usePersistedOperations } from "@graphql-yoga/plugin-persisted-operations";
import { useDisableIntrospection } from "@graphql-yoga/plugin-disable-introspection";
import manifest from "./persisted-documents.json" with { type: "json" };
const isProd = process.env.NODE_ENV === "production";
export const yoga = createYoga({
schema,
graphiql: !isProd,
plugins: [
// Only documents extracted from our clients at build time can run.
usePersistedOperations({
getPersistedOperation: (id) => (manifest as Record<string, string>)[id] ?? null,
allowArbitraryOperations: (request) =>
!isProd || request.headers.get("x-internal-token") === process.env.INTERNAL_TOKEN,
}),
// Depth, aliases, tokens, directives, cost and blocked field suggestions.
EnvelopArmorPlugin({ maxDepth: { n: 8 }, maxAliases: { n: 10 }, costLimit: { maxCost: 5000 } }),
...(isProd ? [useDisableIntrospection()] : []),
],
});- Trusted documents fit first-party clients only. A public GraphQL API must rely on cost limits and per-client cost budgets instead.
- Limits run during validation, before resolvers, so a rejected query costs almost nothing.
- Generate the manifest from client code (GraphQL Codegen, Relay compiler) and deploy it before the clients that use it.
Python · Strawberry + FastAPI Server
import strawberry
from fastapi import FastAPI, Request
from strawberry.dataloader import DataLoader
from strawberry.fastapi import GraphQLRouter
async def load_users(ids: list[str]) -> list[User | Exception]:
rows = {u.id: u for u in await repo.users_by_ids(ids)}
return [rows.get(i) or LookupError(f"user {i} not found") for i in ids]
@strawberry.type
class Post:
id: strawberry.ID
author_id: strawberry.Private[str]
@strawberry.field
async def author(self, info: strawberry.Info) -> User | None:
return await info.context["user_loader"].load(self.author_id)
async def get_context(request: Request) -> dict:
return {"user_loader": DataLoader(load_fn=load_users), "viewer": await auth(request)}
schema = strawberry.Schema(query=Query)
app = FastAPI()
app.include_router(GraphQLRouter(schema, context_getter=get_context), prefix="/graphql")- Return an exception in a slot to fail one key; raising inside load_fn fails every key in the batch.
- strawberry.Private keeps author_id off the public schema while resolvers can still use it.
- repo, auth, User and Query are defined elsewhere in your app.
Go · gqlgen Server
func main() {
c := graph.Config{Resolvers: &graph.Resolver{}}
// Lists cost their page size times the cost of each item.
perPage := func(child int, first *int) int {
n := 20
if first != nil {
n = *first
}
return n * child
}
c.Complexity.Query.Orders = func(child int, first *int, after *string, filter *model.OrderFilter) int {
return perPage(child, first)
}
c.Complexity.Order.Items = func(child int, first *int) int { return perPage(child, first) }
srv := handler.New(graph.NewExecutableSchema(c))
srv.AddTransport(transport.POST{})
srv.AddTransport(transport.GET{}) // queries only; mutations over GET are rejected
srv.Use(extension.FixedComplexityLimit(2000))
if os.Getenv("ENV") != "production" {
srv.Use(extension.Introspection{})
}
http.Handle("/query", srv)
log.Fatal(http.ListenAndServe(":8080", nil))
}- Without custom functions every field costs 1, so orders(first: 1000) looks as cheap as orders(first: 1).
- Complexity function signatures are generated from your schema arguments; regenerate after changing them.
- handler.New adds no transports or extensions; you opt in to each one, which keeps introspection off unless you add it.
TypeScript · Apollo Client (React) Client
import { gql, type TypedDocumentNode } from "@apollo/client";
import { useMutation } from "@apollo/client/react";
import type { PlaceOrderMutation } from "./gen/graphql"; // GraphQL Codegen output
const PLACE_ORDER: TypedDocumentNode<PlaceOrderMutation> = gql`
mutation PlaceOrder($input: PlaceOrderInput!) {
placeOrder(input: $input) {
__typename
... on Order { id status total { amount currency } }
... on OutOfStock { sku available }
}
}`;
export function usePlaceOrder() {
return useMutation(PLACE_ORDER, {
update(cache, { data }) {
const result = data?.placeOrder;
if (result?.__typename !== "Order") return;
// The Order entity is normalized automatically; list membership is not.
cache.modify({
fields: {
orders(existing = { edges: [] }, { toReference }) {
return { ...existing, edges: [{ __typename: "OrderEdge", node: toReference(result) }, ...existing.edges] };
},
},
});
},
});
}- Apollo normalizes by __typename + id (configure keyFields for other keys). Updated fields propagate everywhere; created and deleted items do not.
- For deletes, cache.evict({ id: cache.identify(obj) }) then cache.gc() to drop dangling references.
- If modelling the update is too complex, refetchQueries is correct but costs a round trip per affected query.
TypeScript · graphql-ws Client + server
// server
import { WebSocketServer } from "ws";
import { useServer } from "graphql-ws/use/ws";
const wss = new WebSocketServer({ port: 4000, path: "/graphql" });
useServer({
schema,
onConnect: async (ctx) => {
if (!(await verifyToken(ctx.connectionParams?.token))) return false; // 4403 Forbidden
},
// Runs per operation: new subscriptions re-verify the token (viewer is null once it expires).
context: async (ctx) => ({ viewer: await verifyToken(ctx.connectionParams?.token) }),
}, wss);
// client
import { createClient } from "graphql-ws";
const client = createClient({
url: "wss://api.example.com/graphql",
connectionParams: async () => ({ token: await getFreshToken() }),
retryAttempts: Infinity,
});
for await (const event of client.iterate({ query: "subscription { orderUpdated { id status } }" })) {
render(event.data);
}- connectionParams runs again on every reconnect, so tokens are refreshed instead of replayed after expiry.
- Subscriptions deliver changes, not history: after a reconnect, refetch the current state.
- Use graphql-sse instead when WebSockets are blocked or you want plain HTTP semantics.
WHEN TO REACH FOR WHAT
GraphQL
Use when Several first-party clients (web, iOS, Android) need differently shaped views over a shared domain, and you can afford a schema owner, cost limits and trusted documents.
Avoid when A single client with simple CRUD, file-heavy or binary APIs, or public APIs where consumers expect CDN-cached URLs and per-request rate limits.
GraphQL federation
Use when Many teams own parts of one product graph and a single monolithic schema has become an organizational bottleneck.
Avoid when One or two teams: a single GraphQL server over your services is simpler, cheaper and has fewer network hops.
REST + OpenAPI
Use when Resource-shaped APIs for partners or the public, cacheable reads, webhooks, and the widest tooling.
Avoid when Screens routinely need data from five endpoints in one view and the backend team cannot keep adding BFF endpoints.
BFF or tRPC
Use when One web app and its backend ship together in a TypeScript codebase and you mostly want typed calls.
Avoid when Independently released mobile clients or third parties need a stable, versioned contract.
WHERE IT BITES
- N+1 queries hidden by per-field resolvers: fine in development with five rows, a database storm in production with nested lists. Use per-request DataLoaders and watch query counts per operation in tracing.
- No cost limits on a public endpoint. Depth limits alone miss wide queries and alias amplification (a1: expensive, a2: expensive, ...); add alias, token and cost limits and rate limit by cost.
- Authorization only at the gateway or root field. Nested fields (order.customer.paymentMethods) bypass it; enforce access in resolvers or the domain layer on every path to sensitive data.
- Assuming HTTP 200 means success. Clients and monitoring must inspect errors[] and partial data; alert on error rates by extensions.code and path, not on status codes.
- Stale UI after mutations because new or deleted items never enter the normalized cache's lists. Return the affected entities in the payload and write explicit list updaters, or refetch.
- Overusing non-null: one flaky downstream field marked ! wipes out the whole parent object or the entire response.
CLOSE THE AI. EXPLAIN THIS.
A product page query returns data: null after the reviews service times out, a single crafted query takes down the database, and a new order does not appear in the list after placing it. Explain the GraphQL mechanism behind each and the schema, server or client change that fixes it.WHEN IT BREAKS IN PRODUCTION
SOURCES
- GraphQL over HTTP (working draft) (opens in new tab)Checked
- GraphQL: Security (opens in new tab)Checked
- Strawberry: DataLoaders (opens in new tab)Checked
- Apollo Federation versions (opens in new tab)Checked
- ChilliCream: Fusion (opens in new tab)Checked
- GraphQL Yoga: Persisted operations (opens in new tab)Checked
Explainer reviewed