GraphQL

Query language for APIs — clients ask for exactly what they need

GraphQL is a query language and runtime for APIs that shifts control from the server to the client. Where REST has fixed response shapes defined by the server, GraphQL lets clients declare the exact fields they want — and fetch them in a single request. The server returns a JSON object that mirrors the query structure exactly.

The schema is the contract: a typed, introspectable definition of every object, field, mutation, and subscription in your API. Resolvers execute the query by traversing the schema, calling functions for each field, and assembling the response. The power — and the challenge — is that GraphQL executes an arbitrary graph traversal per request, not a fixed resource hierarchy.

Anatomy of a Query

A GraphQL operation names the operation type, optionally names the operation, declares variables, and selects fields. The response shape mirrors the query structure exactly.

Query (client → server)

query GetUser($id: ID!, $includeEmail: Boolean!) {
  user(id: $id) {
    id
    name
    email @include(if: $includeEmail)
    posts(first: 5) {
      title
      commentCount  # aliasing to avoid conflict
      tags
      author {
        name
      }
    }
  }
}
# Variables:
# { "id": "123", "includeEmail": true }

Response (server → client)

{
  "data": {
    "user": {
      "id": "123",
      "name": "Ada Lovelace",
      "email": "[email protected]",
      "posts": [
        {
          "title": "How I invented the first compiler",
          "commentCount": 47,
          "tags": ["history", "compilers"],
          "author": { "name": "Ada Lovelace" }
        }
      ]
    }
  }
}

Operation Types

query
Read data — like GET
mutation
Write data — like POST/PUT/PATCH
subscription
Long-lived socket — push updates
fragment
Reusable field selection set

Schema Definition Language (SDL)

The schema describes every type and operation in your API using SDL (Schema Definition Language). It's self-documenting and serves as the contract between frontend and backend teams.

type User {
  id: ID!
  name: String!
  email: String
  createdAt: DateTime!
  posts: [Post!]!
  role: UserRole!
}

enum UserRole {
  ADMIN
  EDITOR
  VIEWER
}

type Post {
  id: ID!
  title: String!
  body: String!
  published: Boolean!
  author: User!
  tags: [String!]!
  comments(limit: Int = 10): [Comment!]!
  createdAt: DateTime!
}

interface Comment {
  id: ID!
  body: String!
  createdAt: DateTime!
}

type TextComment implements Comment {
  id: ID!
  body: String!
  createdAt: DateTime!
  edited: Boolean!
}

union SearchResult = User | Post | Comment

input CreatePostInput {
  title: String!
  body: String!
  tags: [String!]
}

type Query {
  user(id: ID!): User
  users(limit: Int, offset: Int): [User!]!
  search(query: String!): [SearchResult!]!
  posts: [Post!]!
}

type Mutation {
  createPost(input: CreatePostInput!): Post!
  deletePost(id: ID!): Boolean!
  updatePost(id: ID!, input: UpdatePostInput!): Post
}

type Subscription {
  postCreated: Post!
  commentAdded(postId: ID!): Comment!
}

scalar DateTime

Type Markers

  • String! — non-nullable (always returns a value)
  • [Post!] — list of nullable Post objects
  • [Post!]! — non-nullable list of non-nullable Posts
  • ID! — unique identifier scalar

What Each Schema Element Means

  • type — object with fields
  • interface — abstract type with shared fields
  • union — "is one of" (no shared fields)
  • input — complex argument shape
  • enum — closed set of string values
  • scalar — custom leaf value (DateTime, JSON)

Resolver Chain — How a Query Executes

When a query arrives, the GraphQL runtime traverses it field by field, calling resolver functions at each step. Each resolver returns a value (primitive or object), and the runtime continues down the tree until it reaches leaf fields.

// Query:  user(id: "123") { name posts(first: 2) { title author { name } } }

// Execution trace:
Query.user       → calls user(id: "123") resolver       → User object
User.name        → calls name resolver on User obj      → "Ada Lovelace" (String)
User.posts       → calls posts resolver (first: 2)      → [Post, Post]
Post.title       → calls title on each Post in list     → ["First Post", "Second"]
Post.author      → calls author resolver on each Post  → [User, User]
User.name        → calls name on each returned User    → ["Ada Lovelace", "Alan T."]
// All of the above runs synchronously per request tick
The critical insight: Each field in the query potentially triggers a resolver function. A 10-field query doesn't mean 10 function calls — a list field with 100 items means 100 calls for that field alone. Resolver performance is the primary determinant of GraphQL server throughput.
// Default resolver behavior (Apollo Server)
const resolvers = {
  Query: {
    user(parent, args, context, info) {
      // parent  = parent resolver's return value
      // args    = { id: "123" }
      // context = { user: req.user, db: context.db }  (per-request)
      // info    = { query, fragments, variableValues, operationName }
      return context.db.users.findById(args.id);
    }
  },
  User: {
    posts(user, args, context, info) {
      // parent = the User object returned by Query.user resolver
      // args   = { first: 2 }
      return context.db.posts.findByAuthorId(user.id, { limit: args.first });
    },
    // name: (user) => user.name  ← default resolver uses parent[name]
  }
};

The four resolver arguments

parent
Return value of the parent resolver — the object containing this field
args
Arguments passed to this field (from query)
context
Per-request shared state — auth, DB connection, DataLoaders
info
Full AST of the query, variable values, operation name

The N+1 Problem

N+1 is GraphQL's biggest performance footgun. It arises because each field resolves independently, and a naive resolver fetches data at the moment it's requested, with no knowledge of what other fields need the same data.

The Problem

// Query with a nested list:
query {
  users {
    posts {
      comments {
        author {
          name   // ← this field is fetched per comment
        }
      }
    }
  }
}
// Naive resolver execution:
// SELECT * FROM users;                 → 1 query
// for each user: SELECT * FROM posts   → N queries
// for each post: SELECT * FROM comments → N×M queries
// for each comment: SELECT * FROM users → N×M×K queries
// Total: 1 + N + N×M + N×M×K + ...   ← explodes fast

DataLoader Solution

// DataLoader batches all user ID lookups
// across a single event-loop tick into one query

const userLoader = new DataLoader(keys =>
  db.users.findByIds(keys)          // fired once, all IDs
);

const resolvers = {
  User: {
    posts(user) {
      return postLoader.loadMany(user.postIds);
    },
    name(user) { return userLoader.load(user.id); }
  }
};
// Before: N user lookups = N DB round trips
// After:  N user lookups = 1 batched DB query

Why REST Doesn't Have This Problem

REST responses are statically shaped by the endpoint — the server knows in advance what related resources to include. A GET /users/123?include=posts,comments endpoint can JOIN posts and comments in SQL and return everything in 1–2 queries. The server controls the response shape, so it can plan the data fetch. In GraphQL, the client controls the shape, so the server can't know which related resources will be requested until it parses the query.

DataLoader: Batching + Caching

DataLoader is Facebook's open-source utility that solves N+1 through two mechanisms: batching (collecting IDs across resolver calls within a tick) and caching (same-request deduplication). It's not part of the GraphQL spec but is the de facto standard for production GraphQL servers.

// Classic N+1 scenario: user with posts, each post with author
// Without DataLoader: 1 + N + N queries (N posts, each author)
// With DataLoader:    1 + 2 queries (one for posts, one batched for all authors)

class DataLoader<K, V> {
  constructor(batchLoadFn: (keys: K[]) => Promise<V[]>)
  load(key: K): Promise<V>
  loadMany(keys: K[]): Promise<V[]>
  // clear(key) — invalidate a cache entry
  // prime(key, value) — pre-populate cache
}

// Usage in resolver context (per-request lifecycle):
function createContext({ db }) {
  return {
    db,
    userLoader: new DataLoader(keys => db.users.findByIds(keys)),
    postLoader: new DataLoader(keys => db.posts.findByIds(keys)),
    authorLoader: new DataLoader(keys => db.users.findByIds(keys)), // reuses same table
  };
}
// All DataLoaders are fresh per request — no cross-request cache pollution
// Cache lives for the duration of one query execution

How Batching Works — Step by Step

// In a single resolver execution:
// Query: users { posts { author { name } } }

// Step 1: Query.user resolver fires, returns [user1, user2, user3]
// Step 2: User.posts resolver fires 3 times (once per user)
//   DataLoader.loadMany([id1, id2, id3]) → queued, not yet fired
//   But DataLoader waits for the event loop to drain before flushing
// Step 3: Three post objects returned [post1, post2, post3]
// Step 4: Post.author resolver fires 3 times
//   authorLoader.load(authorId) → queued (now 6 pending IDs)
// Step 5: Event loop drains → batchLoadFn([...all IDs]) fires once
// Step 6: Results distributed back to each .load() caller

// Without batching: 1 + 3 + 3 = 7 DB round trips
// With batching:    1 + 2 = 3 DB round trips
Caching nuance: DataLoader caches by key within a single request execution. If the same load(123) is called twice within one query, only one DB call fires. But after the response is sent, the DataLoader is garbage-collected. Cross-request caching is Apollo Client's job (normalized cache by entity ID), not the server-side DataLoader's.

DataLoader API Reference

// load(key) — single key → Promise<V>
// loadMany(keys) — K[] → Promise<V[]>  (maintains order, skips missing)
// clear(key) — invalidate cache entry, next load() fires batch again
// prime(key, value) — pre-load cache (useful for test setups)
// clearAll() — wipe all cache and pending batches

// Batch function must return results in same order as input keys
// and same length. Missing items → throw, or return null for nullable fields.
const userLoader = new DataLoader(async keys => {
  const users = await db.query(`SELECT * FROM users WHERE id = ANY($1)`, [keys]);
  const userMap = new Map(users.map(u => [u.id, u]));
  return keys.map(id => userMap.get(id) ?? null); // must match key order
});

Schema Types Deep Dive

ObjectType — the core building block

// In SDL:
type User {
  id: ID!
  name: String!
  email: String   # nullable — server might not have it
}

// In JS (Apollo Server / graphql-js):
const UserType = new GraphQLObjectType({
  name: 'User',
  fields: {
    id:    { type: GraphQLNonNull(GraphQLID) },
    name:  { type: GraphQLNonNull(GraphQLString) },
    email: { type: GraphQLString },   // nullable — no GraphQLNonNull wrapper
  }
});

Interfaces vs Unions — when to use each

// Interface — when types share fields and you want to query shared fields
interface Node {
  id: ID!
}

interface Timestamped {
  createdAt: DateTime!
  updatedAt: DateTime!
}

type Post implements Node, Timestamped {
  id: ID!
  createdAt: DateTime!
  updatedAt: DateTime!
  title: String!
}

// Query shared fields without knowing concrete type:
query {
  search(text: "graphql") {
    ... on Node { id }        // inline fragment on interface
    ... on Timestamped { updatedAt }
  }
}

// Union — when types have NO shared fields (not even id)
union SearchResult = User | Post | Comment

// Must use conditional fragments with unions:
query {
  search(text: "graphql") {
    ... on User { name email }
    ... on Post { title }
    ... on Comment { body }
  }
}

Input Types — mutations and complex arguments

input CreatePostInput {
  title: String!
  body: String!
  tags: [String!]
  publishedAt: DateTime
}

type Mutation {
  createPost(input: CreatePostInput!): Post!
}

// Query:
mutation {
  createPost(input: {
    title: "Hello GraphQL"
    body: "GraphQL is a query language..."
    tags: ["graphql", "api"]
  }) {
    id
    title
  }
}

Enums and Custom Scalars

enum OrderStatus {
  PENDING
  PROCESSING
  SHIPPED
  DELIVERED
  CANCELLED
}

scalar DateTime

scalar JSON

// Custom scalar — you define serialize/parse/parseLiteral
const DateTimeScalar = new GraphQLScalarType({
  name: 'DateTime',
  serialize(value) { return value instanceof Date ? value.toISOString() : value; },
  parseValue(value) { return new Date(value); },
  parseLiteral(ast) { return ast.kind === 'StringValue' ? new Date(ast.value) : null; }
});

Mutations — Write Operations

Mutations are GraphQL's equivalent of POST/PUT/PATCH. They follow the same execution model as queries but are identified as side-effect-bearing operations. The input/payload pattern (separate input type from return payload) is the canonical design for production mutation schemas.

// ✅ The canonical pattern: Input + Payload
input CreatePostInput {
  title: String!
  body: String!
  tags: [String!]
}

type CreatePostResult {
  post: Post!
  success: Boolean!
  errors: [UserError!]
}

type UserError {
  field: String
  message: String!
  code: String!
}

type Mutation {
  createPost(input: CreatePostInput!): CreatePostResult!
}

// Query:
mutation {
  createPost(input: { title: "Hello", body: "World" }) {
    success
    post { id title }
    errors { field message }
  }
}

Optimistic Updates — the client-side pattern

// Apollo Client: optimistic mutation
const CREATE_POST = gql`
  mutation CreatePost($input: CreatePostInput!) {
    createPost(input: $input) {
      post { id title }
    }
  }
`;

const mutation = useMutation(CREATE_POST);

mutation({
  variables: { input: { title: "Hello" } },
  optimisticResponse: {
    createPost: {
      post: { id: null, title: "Hello", __typename: 'Post' },
      __typename: 'Mutation'
    }
  },
  update(cache, { data: { createPost } }) {
    // Manually write to cache — Apollo doesn't know the schema layout
    cache.modify({
      id: cache.identify({ __typename: 'Query', id: 'ROOT_QUERY' }),
      fields: {
        posts(existing = []) {
          return [createPost.post, ...existing];
        }
      }
    });
  }
});
// User sees the new post immediately. On server error, Apollo rolls back.

Mutation execution ordering

By default, GraphQL resolves mutations sequentially (parent field resolves before children). If you need parallel mutation execution, use the async... pattern:

// Sequential (default) — each waits for the previous
mutation {
  createUser(input: { name: "Ada" }) { ... }
  createPost(input: { title: "My first post" }) { ... }  // waits for createUser
}

// Parallel — fire both at once
async function resolveMutation(...args) {
  const [user, post] = await Promise.all([
    createUser(args),
    createPost(args)
  ]);
  return { user, post };
}

Subscriptions — Real-Time Updates

Subscriptions are long-lived operations that push data from server to client over a persistent connection (usually WebSocket). The client subscribes to an event; the server executes the resolver once and then streams results whenever the underlying data changes.

type Subscription {
  postCreated: Post!
  commentAdded(postId: ID!): Comment!
  userStatusChanged(userId: ID!): UserStatus!
}

// SDL defines the subscription shape
type Post {
  id: ID!
  title: String!
  createdAt: DateTime!
}

// Server implementation (Apollo Server + graphql-ws):
const resolvers = {
  Subscription: {
    postCreated: {
      subscribe: async function*(root, args, context) {
        // Returns an AsyncIterable — server yields each new Post
        for await (const post of pubsub.asyncIterator('POST_CREATED')) {
          yield { postCreated: post };
        }
      }
    },
    commentAdded: {
      subscribe: async function*(root, { postId }, context) {
        // Filter by postId — only yield when matching post gets a comment
        const iterable = pubsub.asyncIterator(`COMMENT_ADDED_${postId}`);
        for await (const comment of iterable) {
          yield { commentAdded: comment };
        }
      }
    }
  }
};

Transport Options

graphql-ws
Modern WebSocket protocol (2021). Uses CONNECT → SUBSCRIBE → complete messages. De facto standard.
subscriptions-transport-ws
Legacy protocol (2016). Deprecated in favor of graphql-ws but many servers still support it.
SSE
Server-Sent Events — one-way server→client over HTTP. Fallback when WebSocket unavailable (mobile, restricted networks).
Scaling subscriptions: WebSocket connections are stateful and long-lived. A single server can maintain O(100k) idle connections but becomes CPU-bound when many subscriptions have high event rates. Solutions: Redis pub/sub for horizontal scaling (multiple server instances share subscription events), or move high-frequency subscriptions to a dedicated streaming infrastructure (Apache Kafka, serverless functions).

Schema Stitching vs Federation

Large organizations split their graph across multiple services. Two patterns exist for composing them back into a single schema: schema stitching (build-time merge) and Apollo Federation (runtime composition with ownership boundaries).

Schema Stitching

Merge two or more schemas at build time. The gateway takes each subschema, transforms types to avoid conflicts, and stitches root fields together.

// Gateway stitches UserService + PostService
const gateway = new ApolloGateway({
  serviceList: [
    { name: 'users', url: 'http://localhost:4001' },
    { name: 'posts', url: 'http://localhost:4002' },
  ]
});

// Problems emerge when:
// - User and Post both define a "User" type
// - Both expose a "latest" Query field
// - Shared enum values differ between services
// Resolution: transform via typeMerging or manual renaming

Apollo Federation v2

Each service declares which parts of the schema it owns. The router composes at runtime. Services reference each other's types without duplicating them.

// posts service — owns the Post type, extends User
type Post @key(fields: "id") {
  id: ID!
  title: String!
  author: User!
}

extend type User @key(fields: "id") {
  id: ID! @external
  posts: [Post!]!
}

// users service — owns User, extends Post via reference
type User @key(fields: "id") {
  id: ID!
  name: String!
  email: String!
}

// Router query planning:
// Query.posts → posts service
// Post.author → join with users service (@requires)

Federation v1 vs v2

v1
Entities via @key. @requires to tell router which fields a resolver needs from another service. Implicit schema: services just list types, router infers composition.
v2
Explicit schema sharing: @shareable, @override, @tag. Entities must be resolved by the owning service. No more implicit cross-service field dependencies. Better tooling, submarine errors.

Performance — Query Complexity & Depth

GraphQL's flexibility is a double-edged sword: a single query can traverse thousands of schema nodes. Without guardrails, expensive queries spike CPU, memory, and database load.

Query Complexity Analysis

// Assign complexity scores to fields
const costTransform = new CreateCostTransform({
  // Every field defaults to cost 1
  estimatedBytes: ({ node, depth }) => {
    if (node.name === 'posts') return 1000;     // heavy relation
    if (node.name === 'comments') return 500;
    return 10;
  }
});

// Reject queries exceeding complexity threshold
const apolloConfig = {
  plugins: [
    {
      async didEncounterErrors({ errors, queryPlan }) {
        errors.forEach(err => {
          if (err.extensions?.code === 'QUERY_TOO_COMPLEX') {
            metrics.increment('graphql.query.too_complex');
          }
        });
      }
    }
  ]
};

Depth Limiting and Alias Abuse

// Query depth — limit nesting levels
const depthLimit = createDepthLimit({
  maxDepth: 10,
  depthHint: {
    'Query.user': 3   // allow deeper under specific fields
  }
});

// Alias abuse — same field queried many times with different aliases
// query { a:user b:user c:user ... z:user { name } }
// Resolves the same field 26 times → wasteful
const maxAliases = createAliasLimit({ maxAliases: 10 });

// Persisted Queries — lock queries to a known set
const apolloConfig = {
  persistedQueries: {
    cache: new RedisKeyValueCache({ client }),  // store query hashes
  }
};

// Client sends: { "extensions": { "persistedQuery": { "version": 1, "sha256Hash": "..." } } }
// Server looks up hash → returns pre-stored query → executes
// Unknown hash → 400 Bad Request. CDN can cache by hash.

Automatic Persisted Queries (APQ)

// APQ: client sends hash first, full query only on cache miss
// Step 1: client sends hash only
// { "query": "", "extensions": { "persistedQuery": { "version": 1, "sha256Hash": "abc123" } } }
// Step 2: server has "abc123"? Execute it.
// Step 3: server doesn't have it? Client sends full query → server stores it → executes

// Benefits: CDN can cache by hash, full query rarely travels wire, replay protection

Caching Strategies

HTTP caching doesn't work naturally with GraphQL POST requests. Several layered strategies address different cache needs.

Persisted Queries
CDN caches by query hash. Client sends hash instead of full query. Prevents cache poisoning, reduces payload size.
Normalized Cache
Apollo Client 3 stores entities by type+ID. Updates propagate automatically. @key directives declare entity boundaries.
@cacheControl
Apollo Server field-level directive sets HTTP Cache-Control headers. CDN respects max-age per field.

Apollo Client Normalized Cache

// Cache stores each entity once, identified by type + id
// Query: user(id: "123") { name posts { title author { name } } }
// Results in cache:
//   User:123 → { __typename: "User", name: "Ada", posts: [Post:1, Post:2] }
//   Post:1   → { __typename: "Post", title: "...", author: User:456 }
//   User:456 → { __typename: "User", name: "Alan" }

import { ApolloClient, InMemoryCache } from '@apollo/client';

const cache = new InMemoryCache({
  typePolicies: {
    User: {
      fields: {
        posts: {
          merge(existing = [], incoming) { return [...incoming]; }
        }
      }
    }
  }
});

const client = new ApolloClient({ cache, link: httpLink });

// Write to cache directly (for optimistic updates):
cache.writeQuery({
  query: USER_QUERY,
  data: { user: { ...newPost, __typename: 'User' } }
});

Security

GraphQL's flexibility creates attack surface that REST doesn't have. Introspection, query depth, and alias abuse are the primary vectors. Production deployments need layered defenses.

Introspection Exploitation

// Introspection query — enumerates entire schema
query {
  __schema {
    types { name fields { name type { name } } }
    queryType { name }
    mutations { name }
  }
}

// Attacker workflow: introspection → find sensitive fields → exploit
// Example: __type(name: "User") { fields { name type { name } } }
// → finds "isAdmin", "passwordHash" fields → queries them

// Production mitigations:
// 1. Disable introspection (Apollo Server: introspection: false)
// 2. Require authentication for introspection query
// 3. Strip sensitive fields from schema in production build
// 4. Use graphql-cost-analysis to rate-limit expensive introspection

const server = new ApolloServer({
  introspection: process.env.NODE_ENV === 'production' ? false : true,
});

Query Cost Analysis

// Rate-limit by computed query cost before execution
import { createCostLimitRule } from 'graphql-cost-analysis';

const rule = createCostLimitRule({
  maxCost: 10000,
  variablesCost: {
    "first": 1,    // each pagination item costs 1
    "limit": 1,
  },
  objectCost: {
    "Post": 5,   // resolving a Post costs 5
  }
});

// Reject if sum(cost) > maxCost → 429 or 400

Timeout Limits

// Timeout per query (prevent runaway resolvers)
const server = new ApolloServer({
  formatError: (formattedError) => {
    if (formattedError.extensions?.code === 'INTERNAL_SERVER_ERROR') {
      formattedError.message = 'An unexpected error occurred';
    }
    return formattedError;
  },
  formatResponse: (response) => {
    return response;
  }
});

// Timeout at HTTP layer:
// nginx: proxy_read_timeout 30s;
// Apollo Server plugins can also abort execution on timeout

// Resolver timeout pattern:
async function resolverWithTimeout(parent, args, context, info) {
  const timeout = new Promise((_, reject) =>
    setTimeout(() => reject(new Error('Resolver timeout')), 5000)
  );
  return Promise.race([slowOperation(args), timeout]);
}

Error Handling

GraphQL responses always return 200 OK (for valid queries) with an errors array alongside the data object. This is by design — it allows partial results: some fields can succeed while others fail.

// Partial success — some fields errored, some returned data
{
  "data": {
    "user": {
      "id": "123",
      "name": "Ada Lovelace"
    },
    "posts": null
  },
  "errors": [
    {
      "message": "Database connection failed",
      "locations": [{ "line": 3, "column": 5 }],
      "path": ["posts"],
      "extensions": {
        "code": "INTERNAL_SERVER_ERROR",
        "stacktrace": [...]    // only in development
      }
    }
  ]
}

// Error codes (Apollo Server conventions):
// GRAPHQL_PARSE_ERROR       — malformed query syntax
// GRAPHQL_VALIDATION_FAILED — query fails schema validation
// UNAUTHENTICATED           — missing or invalid auth token
// FORBIDDEN                 — authenticated but not authorized
// INTERNAL_SERVER_ERROR    — resolver threw or unexpected failure
// BAD_USER_INPUT            — args fail validation

Structured Error Extensions

// Attach structured data to errors for client-side handling
throw new UserInputError("Invalid email format", {
  extensions: {
    code: 'BAD_USER_INPUT',
    field: 'email',
    invalidValue: attemptedEmail
  }
});

// Client handles based on error structure:
const result = await client.mutate({ mutation: UPDATE_USER, variables: {...} });

if (result.errors?.[0]?.extensions?.code === 'BAD_USER_INPUT') {
  const field = result.errors[0].extensions.field;
  setFieldError(field, result.errors[0].message);
}

Code Generation — Types from Schema

GraphQL schemas are the single source of truth. Code generation tools (Codegen, GraphQL Code Generator) read your schema and generate TypeScript types, React hooks, and typed resolver signatures — eliminating the boilerplate and type drift that come from manually maintaining parallel type definitions.

// codegen.yml — run with: npx graphql-codegen
# schema: ./src/schema/schema.graphql
# generates: src/generated/graphql.ts

overrides:
  - generates:
      src/generated/:
        preset: client
        plugins:
          - typescript
          - typescript-operations
          - typed-document-node
    when:
      include: ./src/**
---

// Generated TypeScript from schema:

export type GetUserQuery = {
  user: {
    id: string;
    name: string;
    email: string | null;
    posts: Array<{
      title: string;
      author: { name: string };
    }>;
  } | null;
};

// Typed React hook (no manual type annotation needed):
const { data, loading, error } = useQuery(GetUserQuery, {
  variables: { id: "123", includeEmail: true }
});

Typed Resolver Signatures

// With graphql-codegen, resolvers get type-checked signatures
// Generated: src/generated/resolvers-types.ts

export type ResolverType<TypeName extends keyof ResolversTypes> =
  ResolversTypes[TypeName] extends GraphQLScalarType | string | number
    ? Resolver<ResolversTypes[TypeName]>
    : ResolversTypes[TypeName] extends Record<string, unknown>
      ? Partial<Record<keyof ResolversTypes[TypeName], Resolver>>
      : never;

// Resolver must match schema shape — TypeScript enforces field completeness
const resolvers: Resolvers = {
  Query: {
    user: (_: unknown, { id }: QueryUserArgs, ctx: Context) => {
      // id is typed as string, ctx is typed as Context
      return ctx.db.users.findById(id);
    }
  }
};

GraphQL vs REST — When Each Wins

GraphQL Wins

  • Multiple resource fetching in one round-trip — UI needs user + posts + comments. REST requires 3–4 requests; GraphQL needs 1.
  • Mobile clients optimizing for bandwidth — clients request only the fields they need. No over-fetching of unused data.
  • Rapidly evolving product UI — new fields added to UI without backend changes (if schema supports them). No versioned REST endpoints.
  • Analytics-heavy clients — every product surface needs different data shapes. GraphQL lets each screen declare exactly what it needs.
  • Schema as contract — frontend and backend teams agree on types, iterate independently. Schema is the source of truth.

REST Wins

  • Simple, well-defined resource models — CRUD on resources with predictable shapes. No need for a flexible query language.
  • HTTP caching infrastructure — GET requests cache at CDN, browser, and proxy layers automatically. GraphQL POST requests don't.
  • Bandwidth for simple clients — simple clients (IoT, legacy integrations) benefit from well-understood REST conventions.
  • Team familiarity and tooling maturity — REST has 20 years of tooling, patterns, documentation, and developer familiarity.
  • Load balancer-friendly — stateless REST requests distribute naturally. GraphQL stateful connections (for subscriptions) complicate scaling.

Hybrid Pattern

Many teams run both: REST for simple, highly-cacheable, public-facing operations; GraphQL for the client-heavy authenticated product surface. The two can coexist with a BFF (Backend for Frontend) pattern — GraphQL gateway in front of REST services.

Production Patterns

Query Batching

// Apollo Client batched link — combines multiple operations into one HTTP request
import { BatchHttpLink } from '@apollo/client';

const batchLink = new BatchHttpLink({
  uri: 'http://localhost:4000/graphql',
  batchMax: 5,           // max operations per batch
  batchInterval: 20       // wait up to 20ms to batch
});

// Three queries fired within 20ms are sent as one HTTP POST with:
// [{"query": "...", "variables": {...}}, {"query": "...", "variables": {...}}, ...]
// Server (Apollo Server): parses batch → executes each → returns array of results

// Use case: React renders multiple components in the same tick, each fires a query.
// Without batching: N HTTP requests. With batching: 1 HTTP request.

@defer — Streaming Partial Results

// @defer directive — server streams results, non-critical fields arrive later
// Useful when a field is expensive (audit log, related data) but not needed immediately

query {
  user(id: "123") {
    id
    name
    email      # arrive immediately
    posts @defer {  # stream in after initial payload
      title
    }
  }
}

// Response stream:
// {"data": {"user": {"id": "123", "name": "Ada", "email": "[email protected]" }}}   ← fast
// {"data": {"user": {"posts": [...] }}, "hasNext": false}                        ← slow

// Apollo Server 4+ supports @defer with incremental delivery (HTTP multipart/mixed)

@live — Polling Without Re-fetching

// @live directive — tells server to refresh results on a timer
// Client subscribes to live data without full query re-execution

query @live {
  onlineUsers {
    id
    name
    lastSeen
  }
}

// Server polls the resolver every 5s (configurable), pushes updated results
// Unlike subscriptions, @live uses simple polling under the hood

Tooling Ecosystem

Apollo Server
The reference GraphQL server for Node.js. Handles parsing, validation, execution, and plugin ecosystem.
Apollo Client
React/Angular/Vue GraphQL client with normalized cache, optimistic updates, and dev tools.
Relay
Facebook's GraphQL client built for performance. Connections, pagination, data masking, and compiler-driven optimization.
GraphiQL
In-browser IDE for exploring schemas and executing queries. Powers GraphQL Playground.
graphql-codegen
Generates TypeScript types, hooks, and resolvers from schema. Multi-language, extensible.
DataLoader
Batching and caching utility from Facebook. Solve N+1 for every production GraphQL server.
GraphQL Inspector
Compares schemas, detects breaking changes, validates operations against schema.
Apollo Sandbox
Visual schema explorer, operation explorer, and schema registry UI from Apollo.

Key Numbers

2012
Year GraphQL was created at Facebook (internal), open-sourced 2015
1M+
Max resolver chain depth in most production schemas
O(n)
Resolver complexity — each field is O(resolver)
200
Apollo recommended max query complexity score
10
Typical max query depth in production deployments
10
Typical max aliases (prevents alias abuse DoS)
32
Max DataLoader batch size in most implementations
~30
Max concurrent subscriptions per WebSocket connection

FAQ

Why does GraphQL have an N+1 problem but REST doesn't?

REST responses are bounded by the resource structure — a /users/123 response has one user. GraphQL lets the client ask for user.posts[0].comments[0].author, chaining across relationships in a single query. A naive resolver executes one DB query per node in the graph, so 100 posts with 10 comments each = 1 + 100 + 1000 = 1,101 database round-trips. REST avoids this because the server controls the response shape and can eagerly JOIN everything. DataLoader solves N+1 by batching requests: collect all requested IDs across a single event-loop tick, fire one batch query, then distribute results to individual resolvers.

Schema stitching vs Apollo Federation — which to use?

Schema stitching (merging two schemas into one at build time) works for small graphs but hits a wall: type conflicts, shared types, and ownership boundaries. Federation (Apollo v2) takes a different approach: each service owns its types and declares which parts of the schema it contributes. The gateway composes at runtime. Federation wins for team autonomy — a User service can evolve its own types without coordinating with an Order service. Schema stitching is simpler for a single team merging two legacy schemas. Don't stitch at the GraphQL layer what you can solve with your data layer (federation, distributed tracing, schema registry). Start simple, move to federation when team boundaries need it.

Can you cache GraphQL responses at the CDN layer?

Unlike REST GET requests which have a natural cache key (URL + query params), GraphQL typically uses POST with a JSON body — HTTP caching infrastructure (CDN, browser cache, transparent proxies) doesn't parse the JSON body to extract cache-relevant parts. Strategies: (1) Persisted Queries — client sends a query hash instead of full query, server has pre-registered maps, CDN can cache by hash. (2) Persisted queries with CDN — store the mapping at the CDN edge, so the full query never leaves the client. (3) @cache directive — Apollo Client 3's normalized cache stores query results by their entity IDs; Apollo Server's @cacheControl directive sets TTLs per field. (4) Full response caching — some setups cache the entire serialized response keyed by the query hash, but invalidation is tricky. The right answer depends on your data freshness requirements.

What's the real performance cost of GraphQL introspection?

Introspection is a recursive query over __Schema that fetches every type, field, directive, and enum value in your schema. On a schema with 500 types and 10,000 fields, an unqualified introspection query can be expensive — it traverses the full type graph. Attackers use introspection for reconnaissance (what's in your API?) before crafting attacks. Mitigations: disable introspection in production (Apollo Server's introspection: false — but tooling like Apollo Sandbox needs it), require an API key for introspection, or limit it to specific roles. For generated clients (Relay, urql), introspection runs once at build time to generate types, not at runtime.

Why do mutations return the full object?

It's a pattern, not a requirement, but it exists for a good reason: optimistic UI updates. When a client sends a mutation like `mutation { createPost(input:{title:"..."}) { id title } }`, it gets back the created object with the fields it asked for. Apollo Client can use that to update the cache immediately (optimistic update) without waiting for a server round-trip. The alternative — returning a boolean or just the ID — forces the client to refetch after mutation. The Payload type pattern (input + Payload) emerged from this: define exactly what fields the client gets back, keep mutation inputs and return types separate, and make it easy for clients to ask for precisely the data they need to update their local state.

DataLoader vs field-level caching — when to use each?

DataLoader solves N+1 by batching database reads within a single request. It has a per-request lifecycle — new DataLoader instance per request, cleared between requests. Field-level caching (Apollo Client normalized cache) solves a different problem: caching across requests, entity normalization by ID, automatic cache updates after mutations. They compose: DataLoader handles server-side batching to your DB, Apollo Client's cache handles client-side entity deduplication and optimistic updates. Using only DataLoader (no client cache) means every component re-fetches on mount. Using only client cache (no DataLoader) means N+1 hits your database 100 times per request. You need both.