GraphQL’s promise is efficient data fetching - clients request exactly what they need. The reality is that flexible queries create performance problems that REST APIs rarely encounter. These are the specific issues that appear when traffic scales, and the fixes that work.

The N+1 Problem Is Worse in GraphQL

In REST, a poor implementation causes N+1 queries. In GraphQL, N+1 is the default behavior without explicit mitigation.

query {
  posts {          # 1 query: SELECT * FROM posts LIMIT 20
    title
    author {       # 20 queries: SELECT * FROM users WHERE id = ?
      name
    }
  }
}

The resolver for author fires once per post. The schema makes this natural to write. The database sees 21 queries.

The fix is the DataLoader pattern - batch individual loads into a single query.

// Without DataLoader: 20 queries
const UserResolver = {
  Post: {
    author: async (post) => {
      return db.query('SELECT * FROM users WHERE id = $1', [post.authorId]);
    }
  }
};

// With DataLoader: 1 query for all authors
const userLoader = new DataLoader(async (userIds: string[]) => {
  const users = await db.query(
    'SELECT * FROM users WHERE id = ANY($1)', [userIds]
  );
  return userIds.map(id => users.find(u => u.id === id));
});

const UserResolver = {
  Post: {
    author: async (post) => userLoader.load(post.authorId)
  }
};

DataLoader batches all load calls made within the same tick into a single database query. One query for all authors instead of N.

DataLoader should be instantiated per-request, not per-server, to prevent data leaking between requests.

Unbounded Query Depth

A client can write an arbitrarily deep query:

{
  user {
    friends {
      friends {
        friends {
          friends {
            name
          }
        }
      }
    }
  }
}

Each level triggers nested database queries. A 10-level deep query on a social graph can generate thousands of database calls. Without depth limits, a single malicious or poorly written query can take down your server.

import depthLimit from 'graphql-depth-limit';

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

Set depth limits based on your schema structure. A maximum depth of 7-10 handles almost all legitimate use cases.

Query Complexity: Depth Is Not Enough

A wide query can be as expensive as a deep one:

{
  users(first: 1000) {
    posts(first: 1000) {
      comments(first: 1000) {
        body
      }
    }
  }
}

Three levels deep but requests 1 billion potential data points. Depth limits do not catch this.

Query complexity scoring assigns costs to fields and rejects queries above a threshold:

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

const server = new ApolloServer({
  validationRules: [
    createComplexityRule({
      maximumComplexity: 1000,
      variables: {},
      onComplete: (complexity) => {
        console.log('Query complexity:', complexity);
      },
      estimators: [
        fieldExtensionsEstimator(),
        simpleEstimator({ defaultComplexity: 1 })
      ]
    })
  ]
});

Assign higher complexity to fields that trigger expensive operations. List fields with first/limit arguments multiply complexity by the count.

No Caching Is the Norm (But Should Not Be)

REST APIs cache at the HTTP layer. GraphQL sends everything as POST requests to a single endpoint. HTTP caches cannot differentiate queries. Most GraphQL implementations have no caching by default.

Persisted queries solve this:

// Client sends operation ID instead of full query string
const response = await fetch('/graphql', {
  method: 'GET', // GET instead of POST - now cacheable
  url: `/graphql?operationId=getUser&variables={"id":"123"}`
});

// Server maps operation IDs to pre-registered queries
const persistedOperations = {
  getUser: `query GetUser($id: ID!) { user(id: $id) { name email } }`
};

Persisted queries have two benefits: GET requests with query parameters are cacheable by CDN, and only registered operations can execute (no arbitrary query injection).

For dynamic queries, field-level caching with DataLoader already provides per-field memoization within a request. Cross-request caching requires Redis or similar.

Over-fetching in Resolver Implementations

GraphQL prevents over-fetching at the API layer but not in resolver implementations.

// BAD: fetches all columns even if client only requested 'name'
const UserResolver = {
  Query: {
    user: async (_, { id }) => {
      return db.query('SELECT * FROM users WHERE id = $1', [id]);
    }
  }
};

// BETTER: use info to determine which fields were requested
const UserResolver = {
  Query: {
    user: async (_, { id }, context, info) => {
      const fields = getFieldNames(info); // Parse requested fields
      return db.query(
        `SELECT ${fields.join(', ')} FROM users WHERE id = $1`, [id]
      );
    }
  }
};

This is harder to implement correctly and adds complexity. For most cases, a SELECT * with proper indexes performs well enough. Optimize this only when you have a measured performance problem.

Introspection in Production

GraphQL’s introspection lets clients query the schema structure. It is essential for development tools and schema exploration. In production, it exposes your entire data model to anyone who can reach the endpoint.

const server = new ApolloServer({
  schema,
  introspection: process.env.NODE_ENV !== 'production'
});

Disable introspection in production. Legitimate clients use persisted queries. Schema exploration tools should run against staging.

The Monitoring Gap

REST APIs have a request URL that tells you what operation ran. GraphQL sends everything to /graphql. Without instrumentation, all your production queries look the same in monitoring.

const server = new ApolloServer({
  plugins: [
    {
      requestDidStart() {
        return {
          didResolveOperation({ request, document }) {
            metrics.increment('graphql.operation', {
              operation: request.operationName ?? 'anonymous'
            });
          }
        };
      }
    }
  ]
});

Track operation names in your metrics. Slow query analysis requires knowing which operation caused the slowness, not just that /graphql was slow.

Bottom Line

GraphQL performance problems at scale follow a predictable pattern: N+1 queries from missing DataLoaders, unbounded depth or complexity without limits, no caching because HTTP caching does not apply, and monitoring that treats all queries as identical. Fix N+1 with DataLoader, add depth and complexity limits, implement persisted queries for cacheable operations, and instrument operation names in your metrics. Do these before scale, not after.