Skip to main content
← All Articles Β· Β· 10 min read

GraphQL Best Practices: Performance, Security & Schema Design

GraphQL best practices 2026: N+1 problem with DataLoader, query depth limiting, persisted queries, schema design patterns, cursor pagination, and Apollo Server optimization.

GraphQLbest practicesperformanceDataLoaderN+1Apollo Serverschema design

GraphQL is powerful, but power without guardrails leads to slow queries, security vulnerabilities, and schemas that are painful to evolve. This guide covers the essential best practices for production GraphQL APIs β€” from the N+1 problem to schema design patterns that scale.

The N+1 Problem and DataLoader

The N+1 problem is GraphQL’s most notorious performance trap. It happens when resolving a list of items triggers one database query per item.

# This looks innocent
query {
  orders {          # 1 query to get N orders
    customer {      # N queries, one per order β€” the +1
      name
      email
    }
  }
}

Without DataLoader, a list of 100 orders fires 101 queries. With DataLoader, it fires 2.

Implementing DataLoader

DataLoader batches and caches requests within a single request’s event loop tick:

import DataLoader from 'dataloader';

// Create a DataLoader per request (not globally β€” request isolation matters)
function createLoaders() {
  return {
    customerLoader: new DataLoader(async (customerIds) => {
      // Receives array of IDs collected across all resolver calls this tick
      const customers = await db.customers.findMany({
        where: { id: { in: customerIds } }
      });

      // DataLoader requires results in the same order as input IDs
      const customerMap = Object.fromEntries(customers.map(c => [c.id, c]));
      return customerIds.map(id => customerMap[id] ?? new Error(`Customer ${id} not found`));
    }),

    orderItemsLoader: new DataLoader(async (orderIds) => {
      const items = await db.orderItems.findMany({
        where: { orderId: { in: orderIds } }
      });

      // Group items by orderId
      const itemsByOrder = orderIds.map(orderId =>
        items.filter(item => item.orderId === orderId)
      );
      return itemsByOrder;
    }, {
      // Cache within a single request
      cache: true,
      // Batch window in ms (default: 0 = end of current event loop tick)
      batchScheduleFn: (callback) => setTimeout(callback, 2)
    })
  };
}

// Pass loaders via context
const server = new ApolloServer({
  typeDefs,
  resolvers,
  context: ({ req }) => ({
    loaders: createLoaders(),
    user: req.user
  })
});

// Use in resolvers
const resolvers = {
  Order: {
    customer: (order, _, { loaders }) => {
      return loaders.customerLoader.load(order.customerId);
    },
    items: (order, _, { loaders }) => {
      return loaders.orderItemsLoader.load(order.id);
    }
  }
};

A single DataLoader instance per request ensures isolation between users while batching efficiently within a request.


Query Depth and Complexity Limiting

Without limits, clients can send arbitrarily deep or expensive queries:

# Malicious deep query
{
  user {
    friends {
      friends {
        friends {
          friends {
            # ... 100 levels deep
          }
        }
      }
    }
  }
}

Depth Limiting

import depthLimit from 'graphql-depth-limit';

const server = new ApolloServer({
  validationRules: [
    depthLimit(7)  // Reject queries deeper than 7 levels
  ]
});

Query Complexity Analysis

Assign a cost to each field and reject queries that exceed a budget:

import { createComplexityRule, simpleEstimator, fieldExtensionsEstimator } from 'graphql-query-complexity';

const server = new ApolloServer({
  plugins: [
    {
      requestDidStart: () => ({
        didResolveOperation({ request, document }) {
          const complexity = getComplexity({
            schema,
            operationName: request.operationName,
            query: document,
            variables: request.variables,
            estimators: [
              // Uses @complexity directive on field definitions
              fieldExtensionsEstimator(),
              // Fallback: 1 per scalar, multiplied by list arguments
              simpleEstimator({ defaultComplexity: 1 })
            ]
          });

          if (complexity > 500) {
            throw new GraphQLError(
              `Query complexity ${complexity} exceeds limit of 500`,
              { extensions: { code: 'QUERY_TOO_COMPLEX', complexity } }
            );
          }
        }
      })
    }
  ]
});

Mark expensive fields in your schema:

type Query {
  orders(first: Int): OrderConnection @complexity(value: 10, multipliers: ["first"])
  user(id: ID!): User @complexity(value: 2)
  fullTextSearch(query: String!): [SearchResult] @complexity(value: 50)
}

Persisted Queries

Persisted queries pre-register query strings on the server. Clients send only a hash instead of the full query string β€” improving performance and security.

// Apollo Server: Automatic Persisted Queries (APQ)
import { ApolloServer } from '@apollo/server';
import { createPersistedQueryLink } from '@apollo/client/link/persisted-queries';
import { sha256 } from 'crypto-hash';

// Client: send hash first, fall back to full query if not found
const persistedQueriesLink = createPersistedQueryLink({ sha256 });

// Server: caches queries in memory or Redis
const server = new ApolloServer({
  cache: new KeyvAdapter(new Keyv('redis://localhost:6379')),
  plugins: [ApolloServerPluginCacheControl({ defaultMaxAge: 5 })]
});

For maximum security in production, use trusted document mode: only allow pre-registered queries, reject all ad-hoc queries from clients.


Schema Design Patterns

The Connection Pattern (Cursor Pagination)

Use the Relay Connection spec for paginated lists:

type OrderConnection {
  edges: [OrderEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

type OrderEdge {
  node: Order!
  cursor: String!
}

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

type Query {
  orders(
    first: Int
    after: String
    last: Int
    before: String
    filter: OrderFilterInput
  ): OrderConnection!
}
// Resolver implementation
const resolvers = {
  Query: {
    orders: async (_, { first = 20, after, filter }) => {
      const cursor = after ? decodeCursor(after) : null;
      const items = await db.orders.findMany({
        take: first + 1, // Fetch one extra to determine hasNextPage
        cursor: cursor ? { id: cursor } : undefined,
        skip: cursor ? 1 : 0,
        where: buildFilter(filter),
        orderBy: { createdAt: 'desc' }
      });

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

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

Input Types for Mutations

Always use input types for mutation arguments β€” not inline arguments. This keeps mutations forward-compatible:

# Good: using input types
type Mutation {
  createOrder(input: CreateOrderInput!): CreateOrderPayload!
  updateOrder(id: ID!, input: UpdateOrderInput!): UpdateOrderPayload!
}

input CreateOrderInput {
  customerId: ID!
  items: [OrderItemInput!]!
  shippingAddressId: ID
  promoCode: String
}

# Mutation payload β€” always wrap responses
type CreateOrderPayload {
  order: Order
  errors: [UserError!]!
}

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

Nullable vs Non-Null Design

The choice between String and String! matters for schema evolution:

  • Use ! (non-null) only when the field is always present β€” missing it is a server error, not a business logic state
  • Fields that may not exist in all contexts should be nullable
  • Error fields in payloads should always be nullable
  • Prefer nullable by default; add ! deliberately
type User {
  id: ID!           # Always present
  email: String!    # Always present (required at registration)
  bio: String       # Optional β€” nullable is correct
  lastLoginAt: String  # May not have logged in yet
  premiumUntil: String # Null = not premium
}

Apollo Server Response Caching

Cache entire operation responses for public queries:

import responseCachePlugin from '@apollo/server-plugin-response-cache';

const server = new ApolloServer({
  plugins: [
    responseCachePlugin({
      // Determine if result can be cached for this user or globally
      sessionId: ({ contextValue }) => contextValue.user?.id ?? null
    })
  ]
});

Apply cache hints in your schema:

type Query {
  # Cache for 300 seconds, shared between all users
  products: [Product!]! @cacheControl(maxAge: 300, scope: PUBLIC)

  # Cache for 60 seconds, per-user
  currentUser: User @cacheControl(maxAge: 60, scope: PRIVATE)

  # Never cache
  cart: Cart @cacheControl(maxAge: 0)
}

Introspection in Production

Introspection allows clients to query your schema structure β€” useful during development, but a security risk in production (exposes your full API surface to attackers):

const server = new ApolloServer({
  introspection: process.env.NODE_ENV !== 'production',
  // Or: use IP allowlist for internal tooling
});

For internal GraphQL playgrounds in production, protect them behind authentication middleware.


Performance Checklist

  • DataLoader used for all relationships that appear in lists
  • Query depth limited (max 7-10 levels)
  • Query complexity analysis with per-operation budget
  • Persisted queries or trusted documents in production
  • Connection pattern used for all paginated fields
  • Response cache plugin configured for public queries
  • Introspection disabled in production
  • N+1 queries verified absent via query logging in development
  • Slow query logging enabled (flag queries over 200ms)
  • Schema has no deprecated fields older than 2 versions
Free Download

50 CLI Commands That Save Hours Every Week

The exact Git shortcuts, file search tricks, Docker commands, and productivity hacks that senior developers use daily. Compiled from 5+ years of shipping production code.

  • Git mastery (10 cmds)
  • File & search wizardry
  • Docker & DevOps tricks
  • Process & network tools
  • Productivity boosters
  • macOS & Linux install guide

No spam. 1 email/week max. Unsubscribe anytime.

Found this guide useful? Check out our free developer tools.