DevToolBoxGRATIS
Blog

GraphQL Complete Guide: Schema, Apollo, DataLoader, and Performance

13 min readdi DevToolBox
TL;DRGraphQL is a query language and runtime that lets clients request exactly the data they need from a single endpoint. Use Apollo Server for the backend (resolvers, DataLoader for N+1), Apollo Client for React (useQuery, useMutation, useSubscription), cursor-based pagination for scalability, JWT in context for auth, and Redis caching + persisted queries for performance.
Key Takeaways
  • GraphQL uses a single endpoint and SDL schema to give clients full control over response shape, solving over/under-fetching.
  • Apollo Server resolvers receive (parent, args, context, info) — put auth user, DataLoaders, and data sources in context.
  • DataLoader is mandatory for production GraphQL: it batches N+1 resolver calls into single database queries per request tick.
  • Apollo Client's InMemoryCache normalizes by __typename + id, auto-updating all components when mutations return known objects.
  • Use cursor-based pagination (Relay spec) for stable, scalable pagination that handles concurrent data changes.
  • JWT auth lives in the HTTP layer; the context function decodes it before resolvers run.
  • Persisted queries + GET requests enable CDN caching for GraphQL; Redis handles server-side resolver cache.
  • GraphQL errors always return HTTP 200 — check the errors array, not the status code.

What Is GraphQL?

GraphQL is a query language for APIs and a server-side runtime for executing those queries using a type system you define for your data. Developed at Facebook in 2012 and open-sourced in 2015, it solves fundamental problems with REST: over-fetching too much data, under-fetching too little (requiring multiple round trips), and rigid response shapes that do not adapt to diverse client needs.

At its core, GraphQL is schema-first. You define your entire data model in the Schema Definition Language (SDL) — types, queries, mutations, and subscriptions — and that schema becomes both documentation and a contract between your frontend and backend. Every GraphQL API exposes a single endpoint (typically /graphql) that accepts queries, mutations, or subscriptions as POST requests.

The key insight is that the client drives the response shape. Instead of a REST endpoint returning a fixed JSON structure, a GraphQL client sends a query describing exactly which fields it needs. The server resolves only those fields and returns precisely that shape — nothing more, nothing less.

Single Endpoint vs Multiple REST Endpoints

# REST: multiple endpoints, fixed shapes
GET /api/users/42           # Returns ALL user fields (over-fetching)
GET /api/users/42/posts     # Separate round trip (under-fetching)
GET /api/users/42/followers # Yet another round trip

# GraphQL: one endpoint, client-defined shape
POST /graphql
{
  "query": "query { user(id: \"42\") { name avatar posts(limit: 3) { title } followersCount } }"
}
# Returns exactly: name, avatar, 3 post titles, follower count -- nothing else

Schema Definition Language (SDL)

The Schema Definition Language is GraphQL's type system syntax. Every GraphQL API starts with a schema that defines all available types, queries, mutations, and subscriptions. The schema is the single source of truth for your API's capabilities.

SDL supports scalar types (Int, Float, String, Boolean, ID), object types, enum types, union types, interface types, and input types. Non-null fields are marked with !, and lists use [] notation. Understanding SDL thoroughly is the foundation of all GraphQL work.

Complete Schema Example with All SDL Features

# Custom scalars
scalar DateTime
scalar JSON

# Enum types
enum Role { ADMIN  EDITOR  VIEWER }
enum PostStatus { DRAFT  PUBLISHED  ARCHIVED }

# Interface
interface Node { id: ID! }

# Object types
type User implements Node {
  id: ID!
  name: String!              # Non-null: field always present
  email: String!
  avatar: String             # Nullable: may be null
  role: Role!
  posts(limit: Int = 10, offset: Int = 0): [Post!]!
  followersCount: Int!
  createdAt: DateTime!
}

type Post implements Node {
  id: ID!
  title: String!
  slug: String!
  body: String!
  status: PostStatus!
  author: User!              # Resolved field -- triggers resolver
  tags: [String!]!           # Non-null list of non-null strings
  comments: [Comment!]!
  publishedAt: DateTime      # Nullable DateTime
  updatedAt: DateTime!
}

type Comment implements Node {
  id: ID!
  body: String!
  author: User!
  post: Post!
  createdAt: DateTime!
}

# Input types (mutation arguments only)
input CreatePostInput {
  title: String!
  body: String!
  tags: [String!]
  status: PostStatus = DRAFT   # Default value
}

input UpdatePostInput {
  title: String    # All optional for partial update
  body: String
  tags: [String!]
  status: PostStatus
}

input PostFilter {
  status: PostStatus
  authorId: ID
  tag: String
  search: String
}

# Root operation types
type Query {
  user(id: ID!): User
  users(limit: Int = 20, offset: Int = 0): [User!]!
  post(id: ID, slug: String): Post
  posts(filter: PostFilter, limit: Int = 20, offset: Int = 0): [Post!]!
  postsConnection(filter: PostFilter, first: Int, after: String): PostConnection!
  me: User
}

type Mutation {
  createPost(input: CreatePostInput!): Post!
  updatePost(id: ID!, input: UpdatePostInput!): Post!
  deletePost(id: ID!): Boolean!
  publishPost(id: ID!): Post!
  addComment(postId: ID!, body: String!): Comment!
}

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

# Relay-style cursor pagination types
type PostConnection {
  edges: [PostEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}
type PostEdge {
  node: Post!
  cursor: String!
}
type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}

Apollo Server Setup & Resolvers

Apollo Server is the most popular GraphQL server for Node.js. It handles HTTP transport, query parsing and validation against your schema, resolver execution, error formatting, and introspection. Apollo Server 4 can be used as standalone or as middleware with Express, Fastify, or any Node HTTP framework.

Resolvers are functions that return the data for each field in your schema. They receive four arguments: parent (the resolved value from the parent field), args (the field's arguments from the query), context (shared request context — auth user, data sources, loaders), and info (field path, schema, directives). The N+1 problem — where fetching a list then individually fetching related records causes N+1 queries — is solved with DataLoader batching.

Apollo Server 4 with Express, Resolvers & DataLoader

// server.ts
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer';
import express from 'express';
import http from 'http';
import DataLoader from 'dataloader';
import { readFileSync } from 'fs';

const typeDefs = readFileSync('./schema.graphql', 'utf-8');

// DataLoader factory -- create fresh loaders per request
function createLoaders(db: Database) {
  return {
    // Batches N individual user.load(id) calls into:
    // SELECT * FROM users WHERE id IN ($1, $2, ...$N)
    userById: new DataLoader<string, User>(async (ids) => {
      const users = await db.users.findByIds([...ids]);
      const map = new Map(users.map((u: User) => [u.id, u]));
      // Return in SAME ORDER as input ids (DataLoader requirement)
      return ids.map(id => map.get(id) ?? new Error('User not found: ' + id));
    }),
    commentsByPostId: new DataLoader<string, Comment[]>(async (postIds) => {
      const comments = await db.comments.findByPostIds([...postIds]);
      const grouped = new Map<string, Comment[]>();
      comments.forEach((c: Comment) => {
        if (!grouped.has(c.postId)) grouped.set(c.postId, []);
        grouped.get(c.postId)!.push(c);
      });
      return postIds.map(id => grouped.get(id) ?? []);
    }),
  };
}

const resolvers = {
  Query: {
    user: (_: unknown, { id }: { id: string }, { db }: Context) =>
      db.users.findById(id),
    posts: (_: unknown, { filter, limit, offset }: any, { db }: Context) =>
      db.posts.findMany({ filter, limit, offset }),
    postsConnection: async (_: unknown, { filter, first = 10, after }: any, { db }: Context) => {
      const afterId = after ? Buffer.from(after, 'base64').toString() : null;
      const rows = await db.posts.findMany({
        where: { ...filter, ...(afterId ? { id: { gt: afterId } } : {}) },
        take: first + 1,
        orderBy: { createdAt: 'desc' },
      });
      const hasNextPage = rows.length > first;
      const nodes = rows.slice(0, first);
      const edges = nodes.map((node: any) => ({
        node,
        cursor: Buffer.from(node.id).toString('base64'),
      }));
      return {
        edges,
        totalCount: await db.posts.count(filter),
        pageInfo: {
          hasNextPage,
          hasPreviousPage: !!afterId,
          startCursor: edges[0]?.cursor ?? null,
          endCursor: edges[edges.length - 1]?.cursor ?? null,
        },
      };
    },
    me: (_: unknown, __: unknown, { user }: Context) => user ?? null,
  },

  Mutation: {
    createPost: (_: unknown, { input }: any, { user, db }: Context) => {
      if (!user) throw new AuthenticationError('Must be logged in');
      return db.posts.create({ ...input, authorId: user.id });
    },
    updatePost: async (_: unknown, { id, input }: any, { user, db }: Context) => {
      const post = await db.posts.findById(id);
      if (!post) throw new NotFoundError('Post');
      if (post.authorId !== user?.id) throw new ForbiddenError('Not your post');
      return db.posts.update(id, input);
    },
    deletePost: async (_: unknown, { id }: any, { user, db }: Context) => {
      const post = await db.posts.findById(id);
      if (post.authorId !== user?.id) throw new ForbiddenError('Not your post');
      await db.posts.delete(id);
      return true;
    },
  },

  // Field resolvers -- use loaders to prevent N+1
  Post: {
    author: (post: any, _: unknown, { loaders }: Context) =>
      loaders.userById.load(post.authorId),
    comments: (post: any, _: unknown, { loaders }: Context) =>
      loaders.commentsByPostId.load(post.id),
  },
  User: {
    posts: (user: any, { limit, offset }: any, { db }: Context) =>
      db.posts.findByAuthorId(user.id, { limit, offset }),
    followersCount: (user: any, _: unknown, { db }: Context) =>
      db.follows.countByFolloweeId(user.id),
  },
  Subscription: {
    commentAdded: {
      subscribe: (_: unknown, { postId }: any, { pubsub }: Context) =>
        pubsub.asyncIterator('COMMENT_ADDED_' + postId),
    },
    postPublished: {
      subscribe: (_: unknown, __: unknown, { pubsub }: Context) =>
        pubsub.asyncIterator('POST_PUBLISHED'),
    },
  },
};

// Bootstrap
const app = express();
const httpServer = http.createServer(app);
const server = new ApolloServer({
  typeDefs,
  resolvers,
  plugins: [ApolloServerPluginDrainHttpServer({ httpServer })],
  introspection: process.env.NODE_ENV !== 'production',
});
await server.start();
app.use('/graphql', express.json(), expressMiddleware(server, {
  context: async ({ req }) => {
    const token = req.headers.authorization?.replace('Bearer ', '');
    const user = token ? await verifyJWT(token) : null;
    return { user, db, loaders: createLoaders(db), pubsub, redis };
  },
}));
await new Promise<void>(r => httpServer.listen({ port: 4000 }, r));
console.log('GraphQL server ready at http://localhost:4000/graphql');

Apollo Client with React Hooks

Apollo Client is a comprehensive state management library for JavaScript that enables you to manage both local and remote data with GraphQL. For React, it provides hooks-based API: useQuery for fetching data, useMutation for sending mutations, and useSubscription for real-time updates. Apollo Client also includes a normalized in-memory cache (InMemoryCache) that automatically deduplicates and updates related data across components.

The InMemoryCache normalizes objects by __typename + id. When a mutation returns an updated object with the same ID as a cached object, every component displaying that data automatically re-renders — no manual state sync needed. Cache policies (cache-first, cache-and-network, network-only, no-cache) control when Apollo reads from cache vs. hits the network.

Apollo Client Setup with Auth, Error Handling & WebSocket

// lib/apollo-client.ts
import { ApolloClient, InMemoryCache, createHttpLink, from, split } from '@apollo/client';
import { setContext } from '@apollo/client/link/context';
import { onError } from '@apollo/client/link/error';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { createClient } from 'graphql-ws';
import { getMainDefinition } from '@apollo/client/utilities';

const httpLink = createHttpLink({
  uri: process.env.NEXT_PUBLIC_GRAPHQL_URL ?? '/graphql',
});

// Attach JWT from localStorage to every HTTP request
const authLink = setContext((_, { headers }) => {
  const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
  return {
    headers: { ...headers, ...(token ? { authorization: 'Bearer ' + token } : {}) },
  };
});

// Centralized error handling
const errorLink = onError(({ graphQLErrors, networkError, operation, forward }) => {
  if (graphQLErrors) {
    for (const err of graphQLErrors) {
      if (err.extensions?.code === 'UNAUTHENTICATED') {
        // Could refresh token here and retry with forward(operation)
        window.dispatchEvent(new CustomEvent('apollo:unauthenticated'));
      }
    }
  }
  if (networkError) console.error('[Network error]', networkError);
});

// WebSocket link for subscriptions (browser only)
const wsLink = typeof window !== 'undefined'
  ? new GraphQLWsLink(createClient({
      url: (process.env.NEXT_PUBLIC_GRAPHQL_URL ?? '/graphql').replace(/^http/, 'ws'),
      connectionParams: () => ({ authToken: localStorage.getItem('token') }),
      retryAttempts: 5,
    }))
  : null;

// Route subscriptions -> WS, everything else -> HTTP
const splitLink = wsLink
  ? split(
      ({ query }) => {
        const def = getMainDefinition(query);
        return def.kind === 'OperationDefinition' && def.operation === 'subscription';
      },
      wsLink,
      from([errorLink, authLink, httpLink])
    )
  : from([errorLink, authLink, httpLink]);

export const apolloClient = new ApolloClient({
  link: splitLink,
  cache: new InMemoryCache({
    typePolicies: {
      Post: { keyFields: ['slug'] },  // Cache Post by slug, not id
      Query: {
        fields: {
          posts: {
            keyArgs: ['filter'],       // Separate cache per filter combo
            merge(existing = [], incoming, { args }) {
              const merged = [...existing];
              const offset = args?.offset ?? 0;
              incoming.forEach((item: any, i: number) => {
                merged[offset + i] = item;
              });
              return merged;
            },
          },
        },
      },
    },
  }),
  defaultOptions: {
    watchQuery: { fetchPolicy: 'cache-and-network', errorPolicy: 'all' },
    query: { fetchPolicy: 'cache-first', errorPolicy: 'all' },
  },
});

useQuery, useMutation, useSubscription in React

'use client';
import { useQuery, useMutation, useSubscription, gql } from '@apollo/client';

const GET_POSTS = gql`
  query GetPosts($filter: PostFilter, $limit: Int!, $offset: Int!) {
    posts(filter: $filter, limit: $limit, offset: $offset) {
      id slug title
      author { id name avatar }
      tags publishedAt
    }
  }
`;

const CREATE_POST = gql`
  mutation CreatePost($input: CreatePostInput!) {
    createPost(input: $input) { id slug title status author { id name } }
  }
`;

const POST_PUBLISHED = gql`
  subscription OnPostPublished {
    postPublished { id slug title author { name } }
  }
`;

export function PostFeed() {
  // --- useQuery: fetch with cache + loading states ---
  const { data, loading, error, fetchMore, refetch } = useQuery(GET_POSTS, {
    variables: { filter: { status: 'PUBLISHED' }, limit: 10, offset: 0 },
    notifyOnNetworkStatusChange: true,
    pollInterval: 0, // Set to ms > 0 for polling
  });

  // --- useMutation: with optimistic update + cache write ---
  const [createPost, { loading: creating, error: mutError }] = useMutation(CREATE_POST, {
    optimisticResponse: ({ input }) => ({
      createPost: {
        __typename: 'Post',
        id: 'temp-' + Date.now(),
        slug: input.title.toLowerCase().replace(/\s+/g, '-'),
        title: input.title,
        status: 'DRAFT',
        author: { __typename: 'User', id: 'current', name: 'You' },
      },
    }),
    update(cache, { data: { createPost: newPost } }) {
      cache.modify({
        fields: {
          posts(existingRefs = []) {
            const ref = cache.writeFragment({
              data: newPost,
              fragment: gql`fragment NewPost on Post { id slug title status author { id name } }`,
            });
            return [ref, ...existingRefs];
          },
        },
      });
    },
  });

  // --- useSubscription: receive real-time published posts ---
  useSubscription(POST_PUBLISHED, {
    onData: ({ client, data }) => {
      const published = data.data?.postPublished;
      if (published) {
        client.cache.modify({
          fields: {
            posts(existing = []) {
              const ref = client.cache.writeFragment({
                data: published,
                fragment: gql`fragment PubPost on Post { id slug title author { name } }`,
              });
              return [ref, ...existing];
            },
          },
        });
      }
    },
  });

  if (error) return <div>Error: {error.message}</div>;
  if (loading && !data) return <div>Loading posts...</div>;

  const { posts = [] } = data ?? {};

  return (
    <div>
      <button
        disabled={creating}
        onClick={() => createPost({ variables: { input: { title: 'New Post', body: '...' } } })}
      >
        {creating ? 'Creating...' : 'New Post'}
      </button>
      {posts.map((post: any) => (
        <article key={post.slug}>
          <h2>{post.title}</h2>
          <small>by {post.author.name}</small>
        </article>
      ))}
      <button onClick={() => fetchMore({ variables: { offset: posts.length } })}>
        Load more
      </button>
    </div>
  );
}

Authentication and Authorization

Authentication in GraphQL is handled at the HTTP layer — typically via Authorization header with a JWT or session cookie — before any resolvers run. Apollo Server's context function runs on every request, decodes the token, and attaches the user to context, making it available to all resolvers.

Authorization (who can do what) is more nuanced in GraphQL because a single endpoint handles all operations. Common patterns include resolver-level guards (check context.user before resolving), schema directives (@auth, @hasRole), and middleware layers. The graphql-shield library provides a permission layer using rule composition.

JWT Context Function + graphql-shield Permission Rules

// auth/jwt.ts
import jwt from 'jsonwebtoken';

export async function verifyJWT(token: string): Promise<User | null> {
  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET!) as { userId: string; role: string };
    return await db.users.findById(payload.userId);
  } catch {
    return null;
  }
}

// Context function (runs on every request before any resolver)
export async function createContext({ req }: { req: Request }) {
  const auth = req.headers.get('authorization') ?? '';
  const token = auth.startsWith('Bearer ') ? auth.slice(7) : null;
  const user = token ? await verifyJWT(token) : null;
  return { user, db, loaders: createLoaders(db), pubsub, redis };
}

// auth/permissions.ts -- graphql-shield
import { shield, rule, and, or } from 'graphql-shield';

const isAuthenticated = rule({ cache: 'contextual' })(
  async (_, __, ctx: Context) => ctx.user !== null || 'Must be logged in'
);

const isAdmin = rule({ cache: 'contextual' })(
  async (_, __, ctx: Context) => ctx.user?.role === 'ADMIN' || 'Admin only'
);

const isPostOwner = rule({ cache: 'strict' })(
  async (_, { id }, ctx: Context) => {
    const post = await ctx.db.posts.findById(id);
    return post?.authorId === ctx.user?.id || 'Not your post';
  }
);

export const permissions = shield(
  {
    Query:    { me: isAuthenticated, users: isAdmin },
    Mutation: {
      createPost: isAuthenticated,
      updatePost: and(isAuthenticated, or(isPostOwner, isAdmin)),
      deletePost: and(isAuthenticated, or(isPostOwner, isAdmin)),
    },
  },
  { fallbackError: 'Not authorized', allowExternalErrors: true }
);

// Schema directive alternative (@auth in SDL)
// directive @auth on FIELD_DEFINITION
// type Mutation {
//   createPost(input: CreatePostInput!): Post! @auth
// }
//
// Transformer using @graphql-tools/schema:
// mapSchema(schema, {
//   [MapperKind.OBJECT_FIELD]: (fieldConfig) => {
//     if (!getDirective(schema, fieldConfig, 'auth')?.[0]) return fieldConfig;
//     const { resolve = defaultFieldResolver } = fieldConfig;
//     return { ...fieldConfig, resolve(src, args, ctx, info) {
//       if (!ctx.user) throw new AuthenticationError();
//       return resolve(src, args, ctx, info);
//     }};
//   },
// });

Pagination: Cursor-Based & Relay Spec

GraphQL supports two main pagination approaches: offset-based (skip/take or page/limit) and cursor-based. Offset pagination is simple but breaks under concurrent inserts or deletes. Cursor-based pagination uses an opaque cursor (typically a base64-encoded ID or timestamp) to point to a specific position in the dataset, making it stable under data mutations.

The Relay Connection Specification is the industry-standard cursor pagination contract. It defines a Connection type with edges (array of edge objects, each containing a node and cursor) and pageInfo (hasNextPage, hasPreviousPage, startCursor, endCursor). Most GraphQL clients including Apollo Client natively understand the Relay spec.

Relay Cursor Pagination — Server & Client

// Server: postsConnection resolver
async function postsConnection(
  _: unknown,
  { filter, first = 10, after }: { filter?: any; first?: number; after?: string },
  { db }: Context
) {
  // Decode the opaque cursor (base64 of the row ID)
  const afterId = after
    ? Buffer.from(after, 'base64').toString('utf-8')
    : null;

  // Fetch one extra row to determine if there is a next page
  const rows = await db.posts.findMany({
    where: { ...filter, ...(afterId ? { id: { gt: afterId } } : {}) },
    orderBy: { createdAt: 'desc' },
    take: first + 1,
  });

  const hasNextPage = rows.length > first;
  const nodes = rows.slice(0, first);
  const edges = nodes.map((node: any) => ({
    node,
    cursor: Buffer.from(String(node.id)).toString('base64'),
  }));

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

// Client: useQuery with fetchMore (infinite scroll)
const POSTS_CONNECTION = gql`
  query PostsConnection($filter: PostFilter, $first: Int!, $after: String) {
    postsConnection(filter: $filter, first: $first, after: $after) {
      edges { cursor  node { id slug title author { name } } }
      pageInfo { hasNextPage endCursor }
      totalCount
    }
  }
`;

function PostsPaginated() {
  const { data, fetchMore } = useQuery(POSTS_CONNECTION, {
    variables: { first: 10 },
  });

  const conn = data?.postsConnection;

  return (
    <div>
      {conn?.edges.map(({ node }: any) => (
        <article key={node.id}><h3>{node.title}</h3></article>
      ))}
      {conn?.pageInfo.hasNextPage && (
        <button
          onClick={() =>
            fetchMore({
              variables: { after: conn.pageInfo.endCursor },
              updateQuery(prev, { fetchMoreResult: next }) {
                if (!next) return prev;
                return {
                  postsConnection: {
                    ...next.postsConnection,
                    edges: [
                      ...prev.postsConnection.edges,
                      ...next.postsConnection.edges,
                    ],
                  },
                };
              },
            })
          }
        >
          Load more ({conn.totalCount - conn.edges.length} remaining)
        </button>
      )}
    </div>
  );
}

Error Handling in GraphQL

GraphQL has a unique error model: every response returns HTTP 200, even when errors occur. Errors appear in a top-level errors array alongside any partial data. This differs fundamentally from REST where HTTP status codes communicate error categories. The errors array contains objects with message, locations, path (which field failed), and extensions (custom error codes).

Best practice is to always return HTTP 200 for GraphQL errors and use the errors array, but return non-200 status only for transport-level failures (malformed JSON, missing query). In Apollo Server, throwing ApolloError (or its subclasses: AuthenticationError, ForbiddenError, UserInputError, NotFoundError) automatically populates the extensions.code field for clients to handle programmatically.

Custom Error Classes + Response Shape + Client Handling

// errors.ts -- Custom error types with error codes
import { GraphQLError } from 'graphql';

export class AuthenticationError extends GraphQLError {
  constructor(msg = 'Authentication required') {
    super(msg, { extensions: { code: 'UNAUTHENTICATED', http: { status: 401 } } });
  }
}
export class ForbiddenError extends GraphQLError {
  constructor(msg = 'Forbidden') {
    super(msg, { extensions: { code: 'FORBIDDEN', http: { status: 403 } } });
  }
}
export class NotFoundError extends GraphQLError {
  constructor(resource: string) {
    super(resource + ' not found', { extensions: { code: 'NOT_FOUND' } });
  }
}
export class ValidationError extends GraphQLError {
  constructor(msg: string, fields?: Record<string, string>) {
    super(msg, { extensions: { code: 'VALIDATION_ERROR', fields } });
  }
}

// GraphQL response with errors (HTTP 200 always):
// {
//   "data": { "createPost": null },
//   "errors": [{
//     "message": "Authentication required",
//     "locations": [{ "line": 2, "column": 3 }],
//     "path": ["createPost"],
//     "extensions": { "code": "UNAUTHENTICATED" }
//   }]
// }

// Partial data with errors (errorPolicy: "all"):
// {
//   "data": { "me": { "name": "Alice" }, "adminStats": null },
//   "errors": [{ "path": ["adminStats"], "extensions": { "code": "FORBIDDEN" } }]
// }

// Client: programmatic error handling by code
function useTypedMutation(mutation: any, options?: any) {
  const [mutate, result] = useMutation(mutation, {
    errorPolicy: 'all',
    ...options,
  });

  function getErrorByCode(code: string) {
    return result.error?.graphQLErrors.find(
      (e) => e.extensions?.code === code
    );
  }

  return [
    mutate,
    {
      ...result,
      isUnauthenticated: !!getErrorByCode('UNAUTHENTICATED'),
      isForbidden:       !!getErrorByCode('FORBIDDEN'),
      isNotFound:        !!getErrorByCode('NOT_FOUND'),
      validationFields:  getErrorByCode('VALIDATION_ERROR')?.extensions?.fields,
      isNetworkError:    !!result.error?.networkError,
    },
  ] as const;
}

Performance: DataLoader, Caching & Persisted Queries

The N+1 problem is the most common GraphQL performance pitfall. When resolving a list of posts and each post resolver fetches its author separately, you get N+1 database queries (1 for the list + N for authors). DataLoader solves this by batching all individual load() calls within a single event loop tick into a single batch request, and memoizing results within the same request.

Response caching can be implemented at multiple levels: Apollo Server's built-in @cacheControl directives set HTTP cache headers, Redis caching at the resolver level caches expensive computations, and Apollo Client's InMemoryCache handles client-side caching. Persisted queries replace full query strings with a hash, reducing request size and enabling GET-based CDN caching for queries.

DataLoader Batching + Redis Resolver Cache + APQ

// DataLoader: the N+1 solution
// Without DataLoader:
//   Resolving 20 posts -> 20 x "SELECT * FROM users WHERE id = $1" = 21 queries
// With DataLoader:
//   Resolving 20 posts -> 1 x "SELECT * FROM users WHERE id IN (...)" = 2 queries

import DataLoader from 'dataloader';

export function createUserLoader(db: any) {
  return new DataLoader<string, any>(
    async (ids) => {
      const rows = await db.query(
        'SELECT * FROM users WHERE id = ANY($1::uuid[])',
        [[...ids]]
      );
      const map = new Map(rows.map((r: any) => [r.id, r]));
      return ids.map(id => map.get(id) ?? new Error('User ' + id + ' not found'));
    },
    { maxBatchSize: 500, cache: true }
  );
}

// Redis caching at resolver level
import type { Redis } from 'ioredis';

async function cachedResolver<T>(
  redis: Redis,
  key: string,
  ttlSeconds: number,
  fetchFn: () => Promise<T>
): Promise<T> {
  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached) as T;
  const result = await fetchFn();
  await redis.setex(key, ttlSeconds, JSON.stringify(result));
  return result;
}

// Usage in resolver:
Query: {
  posts: async (_, args, { db, redis }) =>
    cachedResolver(
      redis,
      'posts:' + JSON.stringify(args),
      60, // 60 second TTL
      () => db.posts.findMany(args)
    ),
},

// Automatic Persisted Queries (APQ)
// -- cuts request body from ~500B to ~50B
// -- enables GET requests for CDN caching
import { createPersistedQueryLink } from '@apollo/client/link/persisted-queries';
import { sha256 } from 'crypto-hash';

const apqLink = createPersistedQueryLink({
  sha256,
  useGETForHashedQueries: true, // Enables CDN GET caching
});

// Client flow:
// 1. Send: POST /graphql { extensions: { persistedQuery: { sha256Hash: "abc" } } }
// 2. Server miss: returns PersistedQueryNotFound
// 3. Client retries: POST /graphql { query: "...", extensions: { persistedQuery: { sha256Hash: "abc" } } }
// 4. Server stores hash, executes, responds
// 5. Next time: GET /graphql?extensions=%7B%22persistedQuery%22%3A%7B%22sha256Hash%22%3A%22abc%22%7D%7D
// 6. CDN can cache this GET response!

// @cacheControl directive (HTTP cache headers)
// type Query {
//   publicPosts: [Post!]! @cacheControl(maxAge: 300, scope: PUBLIC)
//   me: User @cacheControl(maxAge: 0, scope: PRIVATE)
// }

GraphQL vs REST vs gRPC vs tRPC

Choosing the right API paradigm depends on your use case, team expertise, and infrastructure. Here is a comprehensive comparison of the four major API approaches in 2026:

FeatureGraphQLRESTgRPCtRPC
TransportHTTP + WebSocketHTTP/1.1 or HTTP/2HTTP/2 (binary)HTTP (JSON)
EndpointSingle /graphqlOne per resourceService methodsType-safe procedures
ContractSDL (.graphql file)OpenAPI (optional)Protocol BuffersTypeScript types
Type Safetycodegen requiredOpenAPI + codegenBuilt-in (proto gen)Built-in (inferred)
Over-fetchingSolved by designCommon problemSolved (streaming)Controlled per procedure
HTTP CachingHard (needs APQ)Simple GET cachingN/ANeeds setup
Real-timeSubscriptions (WS)SSE or WebSocketBidirectional streamingSubscriptions
File UploadMultipart specNative multipartNot standardSeparate REST endpoint
BrowserFull supportFull supportgrpc-web proxy onlyFull support
Learning CurveMedium-HighLowHighLow (TypeScript devs)
N+1 ProblemDataLoader requiredManaged per endpointStreaming mitigatesCo-location avoids it
VersioningSchema evolutionURL /v1 /v2Proto backward compatTypeScript refactor
EcosystemVery large (Apollo)Largest (universal)Large (Google)Growing (Vercel)
Best ForMulti-client flexible APIsPublic/simple APIsInternal microservicesFull-stack TS monorepo

Decision Guide: When to Use Each Approach

GraphQL
  • Multiple client types (web/mobile/IoT)
  • Complex nested data requirements
  • Rapid frontend iteration
  • Real-time subscriptions needed
  • Developer-facing flexible API
REST
  • Simple CRUD, URLs map to resources
  • Public API (universally understood)
  • Heavy HTTP GET caching
  • File upload/download workflows
  • Webhook / 3rd-party integrations
gRPC
  • High-performance internal services
  • Polyglot microservices (multi-language)
  • Bidirectional streaming required
  • Low-latency critical path
  • Contract-first with .proto files
tRPC
  • Full-stack TypeScript monorepo
  • Next.js with shared type definitions
  • Small team, no separate API layer
  • Zero codegen, end-to-end types
  • Avoid REST/GraphQL boilerplate

Frequently Asked Questions

Does GraphQL solve the over-fetching and under-fetching problem?

Yes, this is GraphQL's primary design goal. Clients specify exactly which fields they need in their query, so the server returns precisely that data — no more, no less. This eliminates over-fetching (REST endpoints returning unused fields) and under-fetching (needing multiple REST round trips to assemble a complete page's data). For a complex dashboard page that needs user info, recent orders, and notifications, GraphQL does it in one request; REST would require three.

How do GraphQL subscriptions work and when should I use them?

Subscriptions are a GraphQL operation type (alongside Query and Mutation) that enables real-time data push from server to client via WebSocket connections. The server publishes events using a PubSub mechanism, and subscribed clients receive updates automatically. Use subscriptions for chat messages, live notifications, collaborative editing, stock price feeds, and any feature where clients need to react to server-side state changes in real time. For less frequent updates, polling with useQuery's pollInterval option is simpler to implement and scale.

What is the difference between code-first and schema-first GraphQL?

Schema-first (SDL-first) means you write the .graphql schema file manually, then write resolvers to match it. Code-first means you define your types in code (TypeScript decorators with TypeGraphQL, or objects with Pothos), and the SDL is generated automatically. Schema-first gives you the schema as a contract visible to all teams upfront; code-first avoids schema/resolver drift since types are co-located. Most large teams prefer schema-first for the explicit contract, while code-first is popular in TypeScript projects for end-to-end type safety without codegen.

What is GraphQL Federation and when should I use it?

GraphQL Federation (Apollo Federation) is a specification for composing multiple GraphQL subgraph services into a single unified supergraph API. Each team owns their subgraph schema (e.g., Users, Orders, Products), and a Router/Gateway stitches them together transparently. Use Federation when you have multiple backend services that each own part of the data graph and need to expose a unified API to clients — the microservices pattern for GraphQL. For monoliths or small teams, a single Apollo Server instance is simpler.

How does GraphQL handle file uploads?

GraphQL's default transport (JSON over HTTP) does not natively support binary file uploads. The community-standard solution is the GraphQL Multipart Request Specification (graphql-upload package for Node.js), which extends the GraphQL HTTP protocol to accept multipart/form-data with file streams mapped to query variables. Alternatively, many teams use a separate REST endpoint for file uploads and reference the returned URL in GraphQL mutations, keeping GraphQL for structured data only.

Is GraphQL harder to cache than REST?

Yes, HTTP-level caching is harder with GraphQL because all requests go to a single POST /graphql endpoint, so HTTP caches cannot differentiate responses by URL. Workarounds include: persisted queries (assign a hash ID to queries, use GET requests for cacheable queries — Apollo Client and Apollo Server both support this), Apollo Client's normalized InMemoryCache for client-side caching, CDN caching with Automatic Persisted Queries (APQ), and server-side response caching with Redis keyed on query + variables hash.

What tools exist for GraphQL development and testing?

Key tools: GraphiQL and Apollo Sandbox (in-browser IDE with schema explorer and query builder), Apollo Studio (cloud-based schema registry, operation metrics, alerts), GraphQL Inspector (schema diffing, breaking change detection), graphql-codegen (generate TypeScript types and React hooks from schema + operations), Rover CLI (Apollo Federation schema management), Postman and Insomnia (support GraphQL operations), and Apollo DevTools browser extension (inspect cache, active queries, mutations). For testing: Jest with @apollo/client MockedProvider for React components, and graphql-faker for generating mock data from schema.

When should I NOT use GraphQL?

Avoid GraphQL for: simple CRUD APIs where REST endpoints map naturally to resources; public APIs where consumers expect REST (most third-party developers know REST); file upload/download heavy workflows; services that primarily use HTTP caching (REST GET caching is much simpler); very small teams where GraphQL's setup overhead is not justified by benefits; and APIs consumed primarily by server-to-server calls where the flexibility of client-defined queries adds no value. tRPC is an excellent alternative for TypeScript monorepos where frontend and backend share the same codebase.

𝕏 Twitterin LinkedIn
È stato utile?

Resta aggiornato

Ricevi consigli dev e nuovi strumenti ogni settimana.

Niente spam. Cancella quando vuoi.

Prova questi strumenti correlati

{ }JSON Formatter🔓CORS TesterJWTJWT Decoder

Articoli correlati

API Testing: Complete Guide with cURL, Supertest, and k6

Master API testing with this complete guide. Covers HTTP methods, cURL, fetch/axios, Postman/Newman, supertest, Python httpx, mock servers, contract testing, k6 load testing, and OpenAPI documentation.

Autenticazione JWT: Guida completa all'implementazione

Implementa l'autenticazione JWT da zero. Struttura token, access e refresh token, implementazione Node.js, gestione lato client, best practice di sicurezza e middleware Next.js.

WebSocket Complete Guide: Real-Time Communication with ws and Socket.io

Master WebSocket real-time communication. Complete guide with Browser API, Node.js ws, Socket.io, React hooks, Python websockets, Go gorilla/websocket, authentication, scaling, and error handling.