← Tech Guides
API Query Language

GraphQL

Ask for exactly what you need, get predictable results.

The complete GraphQL reference. Schema design, resolvers, queries, mutations, subscriptions, authentication, pagination, client libraries, and the tooling ecosystem.

Schema-First Type-Safe Introspectable Single Endpoint
01

Quick Reference

The three root operation types at a glance. Every GraphQL API exposes a single endpoint that handles queries, mutations, and subscriptions.

Query — Read Data

query GetUser($id: ID!) {
  user(id: $id) {
    name
    email
    posts(first: 5) {
      title
      createdAt
    }
  }
}

Mutation — Write Data

mutation CreatePost($input: CreatePostInput!) {
  createPost(input: $input) {
    id
    title
    author {
      name
    }
  }
}

Subscription — Real-time Stream

subscription OnNewMessage($channelId: ID!) {
  messageAdded(channelId: $channelId) {
    id
    text
    sender {
      name
      avatar
    }
  }
}

REST vs GraphQL

AspectRESTGraphQL
EndpointsMultiple (/users, /posts)Single (/graphql)
Data shapeServer decidesClient decides
Over-fetchingCommonEliminated
Under-fetchingRequires multiple requestsNested in one query
Versioning/v1/, /v2/Schema evolution (deprecate fields)
Type systemOpenAPI / ad hocBuilt-in, introspectable
CachingHTTP caching (GET)Normalized client cache
02

Schema Definition

The schema is the contract between client and server. Written in SDL (Schema Definition Language), it describes every type, field, and relationship in your API.

Object Types

type User {
  id: ID!             # Non-nullable unique identifier
  name: String!       # Non-nullable string
  email: String       # Nullable string
  age: Int            # Nullable integer
  score: Float        # Floating point
  active: Boolean!    # Non-nullable boolean
  posts: [Post!]!     # Non-nullable list of non-nullable Posts
  role: Role!         # Enum value
}
Nullability. In GraphQL, fields are nullable by default. Add ! to make them non-nullable. [Post!]! means the list itself and every item in it are guaranteed non-null.

Scalar Types

ScalarDescriptionExample
Int32-bit signed integer42
FloatDouble-precision float3.14
StringUTF-8 text"hello"
BooleanTrue or falsetrue
IDUnique identifier (serialized as String)"abc123"
# Custom scalar types
scalar DateTime    # ISO 8601 date-time string
scalar JSON        # Arbitrary JSON blob
scalar URL         # Validated URL string

Enums

enum Role {
  ADMIN
  EDITOR
  VIEWER
}

enum SortOrder {
  ASC
  DESC
}

Interfaces

interface Node {
  id: ID!
}

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

type User implements Node & Timestamped {
  id: ID!
  createdAt: DateTime!
  updatedAt: DateTime!
  name: String!
}

Union Types

union SearchResult = User | Post | Comment

type Query {
  search(term: String!): [SearchResult!]!
}

# Querying a union requires inline fragments:
query {
  search(term: "graphql") {
    ... on User { name, email }
    ... on Post { title, body }
    ... on Comment { text }
  }
}

Input Types

input CreateUserInput {
  name: String!
  email: String!
  role: Role = VIEWER   # Default value
}

input UpdateUserInput {
  name: String          # All fields optional for partial updates
  email: String
  role: Role
}

type Mutation {
  createUser(input: CreateUserInput!): User!
  updateUser(id: ID!, input: UpdateUserInput!): User!
}
Input vs Object types. Input types cannot have fields that return other object types or use interfaces/unions. They are strictly for passing data into mutations and queries.

Directives

# Built-in directives
query GetUser($withPosts: Boolean!, $skipEmail: Boolean!) {
  user(id: "1") {
    name
    email @skip(if: $skipEmail)
    posts @include(if: $withPosts) {
      title
    }
  }
}

# Schema directives (custom)
directive @auth(requires: Role!) on FIELD_DEFINITION

type Query {
  users: [User!]! @auth(requires: ADMIN)
}
03

Resolvers

Resolvers are functions that populate each field in your schema. They receive four arguments: parent, args, context, and info.

Resolver Signature

// fieldName(parent, args, context, info) => data

const resolvers = {
  Query: {
    // parent is the root value (usually empty)
    user: (parent, { id }, context) => {
      return context.db.users.findById(id);
    },

    users: (parent, { limit, offset }, context) => {
      return context.db.users.findAll({ limit, offset });
    },
  },

  User: {
    // parent is the User object from the parent resolver
    posts: (user, args, context) => {
      return context.db.posts.findByAuthor(user.id);
    },

    // Computed field (no DB column)
    fullName: (user) => {
      return `${user.firstName} ${user.lastName}`;
    },
  },
};

The Four Arguments

ArgumentAlso CalledDescription
parentroot, objReturn value of the parent resolver (the object this field belongs to)
argsArguments passed to the field in the query
contextctxShared per-request object (auth, DB connections, loaders)
infoAST metadata about the query (field selection, path, etc.)

The N+1 Problem & DataLoader

Without batching, fetching a list of users and their posts triggers N+1 queries: 1 for the user list, then 1 per user for their posts.

Problem: N+1 Queries

// Query: { users { posts { title } } }
// SQL executed:
SELECT * FROM users         -- 1 query
SELECT * FROM posts WHERE author_id = 1  -- N queries
SELECT * FROM posts WHERE author_id = 2
SELECT * FROM posts WHERE author_id = 3
// ... one per user

Solution: DataLoader Batching

// With DataLoader, all IDs are collected
// and batched into a single query:
SELECT * FROM users         -- 1 query
SELECT * FROM posts
  WHERE author_id IN (1, 2, 3)  -- 1 query
// Total: 2 queries regardless of N
import DataLoader from 'dataloader';

// Create loader per-request (in context factory)
const createLoaders = (db) => ({
  postsByAuthor: new DataLoader(async (authorIds) => {
    const posts = await db.posts.findAll({
      where: { authorId: authorIds }
    });
    // Must return array in same order as input IDs
    return authorIds.map(id =>
      posts.filter(p => p.authorId === id)
    );
  }),
});

// Resolver uses the loader
User: {
  posts: (user, args, context) => {
    return context.loaders.postsByAuthor.load(user.id);
  },
}
Per-request instances. Always create a new DataLoader per request. DataLoader caches results within a single request, and sharing across requests would leak data between users.

Context Factory

const server = new ApolloServer({
  typeDefs,
  resolvers,
  context: ({ req }) => ({
    // Authentication
    user: getUserFromToken(req.headers.authorization),
    // Database
    db: prisma,
    // DataLoaders (fresh per request)
    loaders: createLoaders(prisma),
  }),
});
04

Queries

Queries are read operations. GraphQL provides powerful features for composing exactly the data you need in a single request.

Arguments

# Schema
type Query {
  user(id: ID!): User
  users(
    role: Role
    limit: Int = 10
    offset: Int = 0
    sortBy: String = "createdAt"
    order: SortOrder = DESC
  ): [User!]!
}

# Query with arguments
query {
  users(role: ADMIN, limit: 5) {
    name
    email
  }
}

Variables

Variables decouple dynamic values from the query string. They are passed as a separate JSON object alongside the query.

# Query with variable declarations
query GetUsers(
  $role: Role!
  $limit: Int = 10
) {
  users(role: $role, limit: $limit) {
    name
    email
  }
}

# Variables JSON (sent alongside query):
{
  "role": "ADMIN",
  "limit": 20
}

Aliases

Aliases let you query the same field multiple times with different arguments in a single request.

query {
  admins: users(role: ADMIN) {
    name
    email
  }
  editors: users(role: EDITOR) {
    name
    email
  }
}

# Response:
{
  "admins": [{ "name": "Alice", ... }],
  "editors": [{ "name": "Bob", ... }]
}

Fragments

Fragments define reusable sets of fields. They reduce duplication when the same fields are needed across multiple queries or inline selections.

# Define a fragment
fragment UserFields on User {
  id
  name
  email
  avatar
}

# Use in multiple places
query {
  me {
    ...UserFields
    settings { theme }
  }
  user(id: "42") {
    ...UserFields
    posts { title }
  }
}

# Inline fragments (for unions/interfaces)
query {
  search(term: "hello") {
    __typename
    ... on User { name }
    ... on Post { title, body }
  }
}

Operation Names & Multiple Operations

# Named operations (required when sending multiple in one document)
query GetDashboard {
  me { name }
  notifications(unread: true) { count }
  recentPosts(limit: 5) { title, createdAt }
}

# HTTP request specifies which operation to execute:
{
  "query": "query GetDashboard { ... } query GetProfile { ... }",
  "operationName": "GetDashboard"
}
05

Mutations

Mutations are write operations. They modify server-side data and return the updated state. Unlike queries, mutation fields execute sequentially.

Input Type Pattern

The single input argument pattern keeps mutation signatures clean and makes schema evolution easier.

input CreatePostInput {
  title: String!
  body: String!
  tags: [String!]
  published: Boolean = false
}

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

Payload Types

Return a dedicated payload type instead of the raw entity. This gives room for metadata, errors, and future fields without breaking changes.

type CreatePostPayload {
  post: Post                   # Nullable (null on failure)
  errors: [UserError!]!        # Business logic errors
}

type UserError {
  field: String                # Which input field caused the error
  message: String!             # Human-readable message
  code: ErrorCode!             # Machine-readable code
}

enum ErrorCode {
  NOT_FOUND
  VALIDATION_ERROR
  PERMISSION_DENIED
  ALREADY_EXISTS
}

CRUD Mutations

type Mutation {
  # Create
  createUser(input: CreateUserInput!): CreateUserPayload!

  # Update (partial)
  updateUser(id: ID!, input: UpdateUserInput!): UpdateUserPayload!

  # Delete
  deleteUser(id: ID!): DeleteUserPayload!

  # Bulk operations
  deleteUsers(ids: [ID!]!): DeleteUsersPayload!
}

# Executing a mutation
mutation {
  createUser(input: {
    name: "Alice"
    email: "alice@example.com"
    role: EDITOR
  }) {
    post {
      id
      name
    }
    errors {
      field
      message
    }
  }
}

Optimistic Updates

Return enough data from mutations to update the client cache without an extra query.

# Bad: only returning the ID
mutation { updatePost(id: "1", input: { title: "New" }) { id } }

# Good: return all fields the UI needs
mutation {
  updatePost(id: "1", input: { title: "New Title" }) {
    post {
      id
      title
      updatedAt
      author { name }
    }
    errors { message }
  }
}
06

Subscriptions

Subscriptions push real-time data to clients over persistent connections. The most common transport is WebSocket via the graphql-ws protocol.

Schema Definition

type Subscription {
  messageAdded(channelId: ID!): Message!
  userStatusChanged: UserStatus!
  postPublished: Post!
}

type Message {
  id: ID!
  text: String!
  sender: User!
  createdAt: DateTime!
}

type UserStatus {
  user: User!
  online: Boolean!
  lastSeen: DateTime
}

Server Implementation (Apollo)

import { PubSub } from 'graphql-subscriptions';

const pubsub = new PubSub();

const resolvers = {
  Mutation: {
    sendMessage: async (_, { channelId, text }, ctx) => {
      const message = await ctx.db.messages.create({
        channelId, text, senderId: ctx.user.id
      });
      // Publish event to all subscribers
      pubsub.publish(`MESSAGE_ADDED_${channelId}`, {
        messageAdded: message,
      });
      return message;
    },
  },

  Subscription: {
    messageAdded: {
      subscribe: (_, { channelId }) =>
        pubsub.asyncIterator(`MESSAGE_ADDED_${channelId}`),
    },
  },
};

Client Usage

// Apollo Client subscription
import { useSubscription, gql } from '@apollo/client';

const NEW_MESSAGES = gql`
  subscription OnNewMessage($channelId: ID!) {
    messageAdded(channelId: $channelId) {
      id
      text
      sender { name avatar }
    }
  }
`;

function ChatRoom({ channelId }) {
  const { data, loading } = useSubscription(NEW_MESSAGES, {
    variables: { channelId },
  });

  if (data) {
    // New message received
    console.log(data.messageAdded);
  }
}

Transport Protocols

ProtocolLibraryStatus
graphql-wsgraphql-wsRecommended
subscriptions-transport-wssubscriptions-transport-wsDeprecated
Server-Sent Eventsgraphql-sseAlternative
Production PubSub. The in-memory PubSub only works for single-server deployments. For production, use Redis-backed pubsub (graphql-redis-subscriptions) or a message broker like Kafka.
07

Authentication & Authorization

Authentication verifies who the user is. Authorization decides what they can access. GraphQL handles both through context and resolver-level checks.

Context-Based Authentication

// Extract user from JWT in the context factory
const server = new ApolloServer({
  typeDefs,
  resolvers,
  context: async ({ req }) => {
    const token = req.headers.authorization?.replace('Bearer ', '');
    let user = null;

    if (token) {
      try {
        const decoded = jwt.verify(token, process.env.JWT_SECRET);
        user = await db.users.findById(decoded.userId);
      } catch (e) {
        // Token invalid or expired — user stays null
      }
    }

    return { user, db, loaders: createLoaders(db) };
  },
});

Resolver-Level Authorization

const resolvers = {
  Query: {
    // Public — no auth check
    publicPosts: (_, args, ctx) => {
      return ctx.db.posts.findPublic();
    },

    // Authenticated — any logged-in user
    me: (_, args, ctx) => {
      if (!ctx.user) throw new AuthenticationError('Not authenticated');
      return ctx.user;
    },

    // Role-based — admin only
    allUsers: (_, args, ctx) => {
      if (!ctx.user) throw new AuthenticationError('Not authenticated');
      if (ctx.user.role !== 'ADMIN') throw new ForbiddenError('Admin only');
      return ctx.db.users.findAll();
    },
  },
};

Schema Directives for Auth

# Schema
directive @auth(requires: Role = VIEWER) on FIELD_DEFINITION | OBJECT

type Query {
  me: User @auth
  users: [User!]! @auth(requires: ADMIN)
  analytics: Analytics! @auth(requires: ADMIN)
  posts: [Post!]!  # No directive = public
}
// Directive implementation (graphql-tools)
import { mapSchema, getDirective, MapperKind } from '@graphql-tools/utils';

function authDirective(schema) {
  return mapSchema(schema, {
    [MapperKind.OBJECT_FIELD]: (fieldConfig) => {
      const directive = getDirective(schema, fieldConfig, 'auth')?.[0];
      if (!directive) return;

      const { resolve } = fieldConfig;
      const requiredRole = directive.requires || 'VIEWER';

      fieldConfig.resolve = (parent, args, ctx, info) => {
        if (!ctx.user) throw new Error('Not authenticated');
        if (!hasRole(ctx.user, requiredRole)) {
          throw new Error(`Requires ${requiredRole} role`);
        }
        return resolve(parent, args, ctx, info);
      };
    },
  });
}

Field-Level Permissions

User: {
  // Only the user themselves or admins can see email
  email: (user, args, ctx) => {
    if (ctx.user?.id === user.id || ctx.user?.role === 'ADMIN') {
      return user.email;
    }
    return null;
  },

  // Mask sensitive data for non-owners
  phone: (user, args, ctx) => {
    if (ctx.user?.id !== user.id) return '***-***-' + user.phone.slice(-4);
    return user.phone;
  },
}
08

Pagination

GraphQL supports multiple pagination patterns. Cursor-based pagination (Relay spec) is the most robust approach for dynamic datasets.

Offset-Based (Simple)

type Query {
  posts(limit: Int = 10, offset: Int = 0): [Post!]!
}

# Usage: posts(limit: 10, offset: 20)  → items 21-30
Offset pitfalls. Offset pagination breaks when items are inserted or deleted between page fetches, causing duplicates or skipped items. Use cursor-based pagination for real-time data.

Cursor-Based (Relay Specification)

The Relay Connection specification defines a standard structure for paginated data with cursors, page info, and edges.

# Connection types (Relay spec)
type PostConnection {
  edges: [PostEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

type PostEdge {
  node: Post!            # The actual item
  cursor: String!        # Opaque cursor string
}

type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}

type Query {
  posts(
    first: Int           # Forward pagination count
    after: String        # Cursor: fetch items after this
    last: Int            # Backward pagination count
    before: String       # Cursor: fetch items before this
  ): PostConnection!
}

Querying Connections

# First page
query {
  posts(first: 10) {
    edges {
      node { id, title }
      cursor
    }
    pageInfo {
      hasNextPage
      endCursor
    }
    totalCount
  }
}

# Next page (use endCursor from previous response)
query {
  posts(first: 10, after: "Y3Vyc29yOjEw") {
    edges { node { id, title } }
    pageInfo { hasNextPage, endCursor }
  }
}

Resolver Implementation

const resolvers = {
  Query: {
    posts: async (_, { first = 10, after }, ctx) => {
      // Decode opaque cursor to get the offset/ID
      const afterId = after ? decodeCursor(after) : null;

      // Fetch one extra to determine hasNextPage
      const posts = await ctx.db.posts.findMany({
        where: afterId ? { id: { gt: afterId } } : {},
        take: first + 1,
        orderBy: { id: 'asc' },
      });

      const hasNextPage = posts.length > first;
      const edges = posts.slice(0, first).map(post => ({
        node: post,
        cursor: encodeCursor(post.id),
      }));

      return {
        edges,
        pageInfo: {
          hasNextPage,
          hasPreviousPage: !!after,
          startCursor: edges[0]?.cursor,
          endCursor: edges[edges.length - 1]?.cursor,
        },
        totalCount: await ctx.db.posts.count(),
      };
    },
  },
};

// Cursor encoding (base64 is a common convention)
const encodeCursor = (id) => Buffer.from(`cursor:${id}`).toString('base64');
const decodeCursor = (cursor) => Buffer.from(cursor, 'base64').toString().split(':')[1];
09

Client Libraries

GraphQL clients handle query execution, caching, and state management. Choose based on your needs: full-featured (Apollo), lightweight (urql), or minimal (graphql-request).

Library Comparison

FeatureApollo Clienturqlgraphql-request
Bundle size~40 KB~12 KB~5 KB
Normalized cacheBuilt-inPlugin (Graphcache)None
SubscriptionsBuilt-inPluginNone
SSR supportBuilt-inBuilt-inManual
DevtoolsChrome extensionChrome extensionNone
Framework bindingsReact, Vue, Angular, SvelteReact, Vue, SvelteFramework-agnostic
Learning curveHigherModerateMinimal

Apollo Client

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

// Setup
const client = new ApolloClient({
  uri: 'https://api.example.com/graphql',
  cache: new InMemoryCache(),
  headers: {
    authorization: `Bearer ${token}`,
  },
});

// React hook
const GET_POSTS = gql`
  query GetPosts($first: Int!) {
    posts(first: $first) {
      edges { node { id title } }
      pageInfo { hasNextPage endCursor }
    }
  }
`;

function PostList() {
  const { loading, error, data, fetchMore } = useQuery(GET_POSTS, {
    variables: { first: 10 },
  });

  if (loading) return 'Loading...';
  if (error) return `Error: ${error.message}`;

  return (
    <div>
      {data.posts.edges.map(({ node }) => (
        <PostCard key={node.id} post={node} />
      ))}
      {data.posts.pageInfo.hasNextPage && (
        <button onClick={() => fetchMore({
          variables: { after: data.posts.pageInfo.endCursor }
        })}>Load More</button>
      )}
    </div>
  );
}

urql

import { createClient, Provider, useQuery } from 'urql';

const client = createClient({
  url: 'https://api.example.com/graphql',
  fetchOptions: () => ({
    headers: { authorization: `Bearer ${getToken()}` },
  }),
});

// React component
function UserProfile({ id }) {
  const [result] = useQuery({
    query: `query ($id: ID!) { user(id: $id) { name email } }`,
    variables: { id },
  });

  const { data, fetching, error } = result;
  if (fetching) return 'Loading...';
  return <div>{data.user.name}</div>;
}

graphql-request (Minimal)

import { GraphQLClient, gql } from 'graphql-request';

const client = new GraphQLClient('https://api.example.com/graphql', {
  headers: { authorization: `Bearer ${token}` },
});

// Simple query
const data = await client.request(gql`
  query {
    users { id name email }
  }
`);

// With variables
const user = await client.request(
  gql`query GetUser($id: ID!) { user(id: $id) { name } }`,
  { id: '42' }
);

Code Generation

GraphQL Code Generator creates TypeScript types and typed hooks from your schema and operations.

# Install
npm install -D @graphql-codegen/cli \
  @graphql-codegen/typescript \
  @graphql-codegen/typescript-operations \
  @graphql-codegen/typescript-react-apollo

# codegen.yml
schema: "https://api.example.com/graphql"
documents: "src/**/*.graphql"
generates:
  src/generated/graphql.ts:
    plugins:
      - typescript
      - typescript-operations
      - typescript-react-apollo

# Run
npx graphql-codegen

// Result: fully typed hooks
import { useGetPostsQuery } from './generated/graphql';
// data.posts is fully typed with no manual type annotations
10

Introspection & Tooling

GraphQL is self-documenting through its introspection system. This enables powerful developer tools for exploring, testing, and composing schemas.

Introspection Queries

# Get all types in the schema
query {
  __schema {
    types {
      name
      kind
      description
    }
  }
}

# Get fields for a specific type
query {
  __type(name: "User") {
    name
    fields {
      name
      type {
        name
        kind
        ofType { name }
      }
      args {
        name
        type { name }
        defaultValue
      }
    }
  }
}

# The __typename meta-field (available on every type)
query {
  search(term: "hello") {
    __typename
    ... on User { name }
    ... on Post { title }
  }
}
Disable in production. Introspection exposes your entire schema. Disable it in production environments to prevent schema leakage: introspection: false in your server config.

IDE & Explorer Tools

ToolTypeDescription
GraphiQLIn-browser IDEOfficial GraphQL Foundation explorer. Auto-complete, docs panel, history.
Apollo SandboxCloud IDEApollo Studio’s hosted explorer. Schema registry, operation collections, mocking.
AltairDesktop / BrowserFeature-rich client. File upload, subscriptions, environments, plugins.
InsomniaDesktopREST + GraphQL client. Environment management, code generation.
PostmanDesktop / CloudGraphQL support with auto-schema fetching and variable management.

Schema Federation & Stitching

For large organizations, a single monolithic schema becomes unwieldy. Federation and stitching compose multiple subgraphs into a unified API.

Apollo Federation

# Users subgraph
type User @key(fields: "id") {
  id: ID!
  name: String!
}

# Posts subgraph
type User @key(fields: "id") {
  id: ID!
  posts: [Post!]!
}

type Post {
  id: ID!
  title: String!
  author: User!
}

Schema Stitching

import { stitchSchemas }
  from '@graphql-tools/stitch';

const gateway = stitchSchemas({
  subschemas: [
    {
      schema: usersSchema,
      executor: usersExecutor,
    },
    {
      schema: postsSchema,
      executor: postsExecutor,
    },
  ],
});
ApproachBest ForGateway
FederationMicroservices, team autonomyApollo Router / Gateway
StitchingComposing third-party APIsCustom Node.js server
MonolithSmall teams, simple appsSingle server

Schema Linting & Validation

# graphql-eslint — lint GraphQL schemas and operations
npm install -D @graphql-eslint/eslint-plugin

# .eslintrc.js
{
  overrides: [{
    files: ['*.graphql'],
    parser: '@graphql-eslint/eslint-plugin',
    plugins: ['@graphql-eslint'],
    rules: {
      '@graphql-eslint/naming-convention': ['error', {
        types: 'PascalCase',
        FieldDefinition: 'camelCase',
        EnumValueDefinition: 'UPPER_CASE',
      }],
      '@graphql-eslint/no-unreachable-types': 'error',
      '@graphql-eslint/require-description': ['warn', {
        types: true,
        FieldDefinition: true,
      }],
    },
  }],
}

# graphql-inspector — diff schemas, find breaking changes
npx graphql-inspector diff old-schema.graphql new-schema.graphql

# Output:
✖ Field 'User.email' was removed              # BREAKING
✔ Field 'User.avatar' was added               # SAFE
⚠ Field 'User.name' description changed       # WARNING

Performance & Security Best Practices

Query Depth Limiting

import depthLimit from 'graphql-depth-limit';

new ApolloServer({
  validationRules: [depthLimit(7)],
});

Prevents deeply nested queries from overloading resolvers.

Query Complexity

import { createComplexityRule }
  from 'graphql-query-complexity';

new ApolloServer({
  validationRules: [
    createComplexityRule({
      maximumComplexity: 1000,
    }),
  ],
});

Assigns cost to fields; rejects queries exceeding budget.

Persisted Queries

// Client sends hash instead of
// full query string
{
  "extensions": {
    "persistedQuery": {
      "sha256Hash": "abc..."
    }
  }
}

Reduces bandwidth; prevents arbitrary query execution.

Rate Limiting

// Per-field or per-operation
// rate limiting
const limiter = rateLimit({
  max: 100,
  window: '15m',
  keyGenerator: (ctx) =>
    ctx.user?.id || ctx.ip,
});

Prevents abuse by limiting request frequency per user or IP.