MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

GraphQL with Apollo Server and Client

Build full-stack GraphQL with Apollo: schema, resolvers, caching, and React integration.

GraphQLApolloReactNode.js

By MinhVo

Introduction

Apollo has established itself as the de facto standard for building production GraphQL applications. From Apollo Server on the backend to Apollo Client on the frontend, the Apollo ecosystem provides a comprehensive toolkit that handles everything from schema definition to intelligent caching, making GraphQL accessible and production-ready for teams of all sizes.

The Apollo platform addresses the fundamental challenges that arise when adopting GraphQL: How do you define a schema that scales across teams? How do you prevent N+1 database queries? How do you cache GraphQL responses when every query is potentially unique? How do you integrate GraphQL into React applications without drowning in boilerplate?

This guide walks through building a full-stack GraphQL application with Apollo Server and Apollo Client. We'll cover schema design best practices, resolver architecture with DataLoader for N+1 prevention, Apollo Client's normalized cache, React integration with hooks, and production deployment patterns. By the end, you'll have a complete mental model for building and deploying Apollo GraphQL applications.

Full-Stack GraphQL Architecture

Understanding Apollo Server: The GraphQL Backend

Apollo Server is a spec-compliant GraphQL server that integrates with any GraphQL schema. It handles query parsing, validation, execution, and response formatting while providing extension points for authentication, caching, and observability.

Schema-First Design

Apollo Server encourages a schema-first approach where you define your API contract before writing any resolver code. This contract becomes the single source of truth that both frontend and backend teams reference.

const typeDefs = `#graphql
  """
  A user account in the system
  """
  type User {
    id: ID!
    name: String!
    email: String!
    avatar: String
    posts: [Post!]!
    followers: [User!]!
    following: [User!]!
    followerCount: Int!
    createdAt: DateTime!
  }
 
  """
  A blog post authored by a User
  """
  type Post {
    id: ID!
    title: String!
    content: String!
    status: PostStatus!
    author: User!
    tags: [String!]!
    comments: [Comment!]!
    likeCount: Int!
    createdAt: DateTime!
    updatedAt: DateTime!
  }
 
  enum PostStatus {
    DRAFT
    PUBLISHED
    ARCHIVED
  }
 
  type Comment {
    id: ID!
    content: String!
    author: User!
    post: Post!
    createdAt: DateTime!
  }
 
  type Query {
    """
    Fetch a user by their unique identifier
    """
    user(id: ID!): User
 
    """
    List posts with optional filtering and pagination
    """
    posts(
      filter: PostFilter
      first: Int = 10
      after: String
    ): PostConnection!
 
    """
    Search posts by title or content
    """
    searchPosts(term: String!, limit: Int = 10): [Post!]!
  }
 
  input PostFilter {
    authorId: ID
    status: PostStatus
    tags: [String!]
  }
 
  type PostConnection {
    edges: [PostEdge!]!
    pageInfo: PageInfo!
    totalCount: Int!
  }
 
  type PostEdge {
    node: Post!
    cursor: String!
  }
 
  type PageInfo {
    hasNextPage: Boolean!
    hasPreviousPage: Boolean!
    startCursor: String
    endCursor: String
  }
 
  type Mutation {
    createPost(input: CreatePostInput!): Post!
    updatePost(id: ID!, input: UpdatePostInput!): Post!
    deletePost(id: ID!): Boolean!
    likePost(postId: ID!): Post!
    addComment(postId: ID!, content: String!): Comment!
  }
 
  type Subscription {
    postCreated: Post!
    commentAdded(postId: ID!): Comment!
    postLiked(postId: ID!): Post!
  }
`;

This schema definition includes documentation strings, custom scalars, input types, and Relay-compatible pagination. The GraphQL SDL serves as both documentation and API specification, enabling frontend teams to begin development immediately using tools like GraphQL Code Generator.

Resolver Architecture

Resolvers are the functions that fulfill GraphQL queries. Apollo Server executes them in a tree structure that mirrors the query, with each field resolver receiving the parent object (root), arguments, context, and schema information.

import { GraphQLError } from 'graphql';
import DataLoader from 'dataloader';
 
// Context factory - runs per request
const context = async ({ req }) => {
  const token = req.headers.authorization?.replace('Bearer ', '');
  const user = token ? await verifyToken(token) : null;
 
  return {
    user,
    loaders: {
      user: new DataLoader(async (ids) => {
        const users = await db.users.findByIds(ids);
        return ids.map(id => users.find(u => u.id === id) || null);
      }),
      postsByAuthor: new DataLoader(async (authorIds) => {
        const posts = await db.posts.findByAuthorIds(authorIds);
        return authorIds.map(id => posts.filter(p => p.authorId === id));
      }),
      commentCount: new DataLoader(async (postIds) => {
        const counts = await db.comments.countByPostIds(postIds);
        return postIds.map(id => counts.find(c => c.postId === id)?.count || 0);
      }),
      likeCount: new DataLoader(async (postIds) => {
        const counts = await db.likes.countByPostIds(postIds);
        return postIds.map(id => counts.find(c => c.postId === id)?.count || 0);
      }),
    },
  };
};
 
const resolvers = {
  Query: {
    user: async (_, { id }, { loaders }) => {
      return loaders.user.load(id);
    },
 
    posts: async (_, { filter, first, after }, { user }) => {
      const where = {};
 
      if (filter?.authorId) where.authorId = filter.authorId;
      if (filter?.status) where.status = filter.status;
      if (filter?.tags) where.tags = { hasSome: filter.tags };
 
      if (after) {
        const cursor = decodeCursor(after);
        where.createdAt = { lt: cursor };
      }
 
      const [posts, totalCount] = await Promise.all([
        db.posts.findMany({
          where,
          take: first + 1,
          orderBy: { createdAt: 'desc' },
        }),
        db.posts.count({ where }),
      ]);
 
      const hasNextPage = posts.length > first;
      const edges = posts.slice(0, first).map(post => ({
        node: post,
        cursor: encodeCursor(post.createdAt),
      }));
 
      return {
        edges,
        totalCount,
        pageInfo: {
          hasNextPage,
          hasPreviousPage: !!after,
          startCursor: edges[0]?.cursor,
          endCursor: edges[edges.length - 1]?.cursor,
        },
      };
    },
 
    searchPosts: async (_, { term, limit }) => {
      return db.posts.search(term, { take: limit });
    },
  },
 
  Mutation: {
    createPost: async (_, { input }, { user, pubsub }) => {
      if (!user) throw new GraphQLError('Authentication required', {
        extensions: { code: 'UNAUTHENTICATED' },
      });
 
      const post = await db.posts.create({
        ...input,
        authorId: user.id,
        status: 'DRAFT',
      });
 
      await pubsub.publish('POST_CREATED', { postCreated: post });
      return post;
    },
 
    updatePost: async (_, { id, input }, { user }) => {
      const post = await db.posts.findUnique({ where: { id } });
      if (!post) throw new GraphQLError('Post not found', {
        extensions: { code: 'NOT_FOUND' },
      });
      if (post.authorId !== user.id) throw new GraphQLError('Not authorized', {
        extensions: { code: 'FORBIDDEN' },
      });
 
      return db.posts.update({ where: { id }, data: input });
    },
 
    likePost: async (_, { postId }, { user, pubsub }) => {
      if (!user) throw new GraphQLError('Authentication required');
 
      await db.likes.upsert({
        where: { userId_postId: { userId: user.id, postId } },
        create: { userId: user.id, postId },
        update: {},
      });
 
      const post = await db.posts.findUnique({ where: { id: postId } });
      await pubsub.publish(`POST_LIKED_${postId}`, { postLiked: post });
      return post;
    },
 
    addComment: async (_, { postId, content }, { user, pubsub }) => {
      if (!user) throw new GraphQLError('Authentication required');
 
      const comment = await db.comments.create({
        data: { content, postId, authorId: user.id },
      });
 
      await pubsub.publish(`COMMENT_ADDED_${postId}`, { commentAdded: comment });
      return comment;
    },
  },
 
  User: {
    posts: (user, _, { loaders }) => loaders.postsByAuthor.load(user.id),
    followerCount: (user) => db.follows.count({ where: { followingId: user.id } }),
  },
 
  Post: {
    author: (post, _, { loaders }) => loaders.user.load(post.authorId),
    comments: (post) => db.comments.findByPostId(post.id),
    likeCount: (post, _, { loaders }) => loaders.likeCount.load(post.id),
  },
 
  Comment: {
    author: (comment, _, { loaders }) => loaders.user.load(comment.authorId),
    post: (comment) => db.posts.findUnique({ where: { id: comment.postId } }),
  },
 
  Subscription: {
    postCreated: {
      subscribe: (_, __, { pubsub }) => pubsub.asyncIterator(['POST_CREATED']),
    },
    commentAdded: {
      subscribe: (_, { postId }, { pubsub }) =>
        pubsub.asyncIterator([`COMMENT_ADDED_${postId}`]),
    },
  },
};

Understanding Apollo Client: The GraphQL Frontend

Apollo Client is a comprehensive state management library for JavaScript that handles GraphQL data fetching, caching, and UI updates. Its normalized cache intelligently merges query results, ensuring that the same entity referenced in multiple queries is represented only once in the cache.

Cache Normalization

Apollo Client's InMemoryCache normalizes query results by extracting objects with __typename and id fields, storing them in a flat lookup table. When a query returns data referencing the same object, Apollo serves it from cache rather than making another network request.

import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client';
 
const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        posts: {
          keyArgs: ['filter'],
          merge(existing, incoming, { args }) {
            if (!args?.after) return incoming;
 
            return {
              ...incoming,
              edges: [...(existing?.edges || []), ...incoming.edges],
            };
          },
        },
      },
    },
    Post: {
      fields: {
        comments: {
          merge(existing = [], incoming) {
            return incoming;
          },
        },
      },
    },
  },
});
 
const client = new ApolloClient({
  link: new HttpLink({ uri: '/graphql' }),
  cache,
  defaultOptions: {
    watchQuery: {
      fetchPolicy: 'cache-and-network',
      nextFetchPolicy: 'cache-first',
    },
  },
});

React Integration with Hooks

Apollo Client provides React hooks that integrate GraphQL operations directly into your components. The useQuery hook manages loading states, errors, and cache updates automatically.

import { useQuery, useMutation, useSubscription, gql } from '@apollo/client';
import { useState } from 'react';
 
const GET_POSTS = gql`
  query GetPosts($first: Int, $after: String, $filter: PostFilter) {
    posts(first: $first, after: $after, filter: $filter) {
      edges {
        node {
          id
          title
          content
          author { id name avatar }
          likeCount
          createdAt
        }
        cursor
      }
      pageInfo {
        hasNextPage
        endCursor
      }
      totalCount
    }
  }
`;
 
const CREATE_POST = gql`
  mutation CreatePost($input: CreatePostInput!) {
    createPost(input: $input) {
      id
      title
      content
      status
      author { id name }
    }
  }
`;
 
const POST_CREATED_SUBSCRIPTION = gql`
  subscription OnPostCreated {
    postCreated {
      id
      title
      content
      author { id name avatar }
      createdAt
    }
  }
`;
 
function PostFeed({ filter }) {
  const [newPosts, setNewPosts] = useState([]);
 
  const { data, loading, error, fetchMore } = useQuery(GET_POSTS, {
    variables: { first: 10, filter },
    notifyOnNetworkStatusChange: true,
  });
 
  // Subscribe to new posts
  useSubscription(POST_CREATED_SUBSCRIPTION, {
    onData: ({ data: subscriptionData }) => {
      setNewPosts(prev => [subscriptionData.data.postCreated, ...prev]);
    },
  });
 
  if (loading && !data) return <PostFeedSkeleton />;
  if (error) return <ErrorState error={error} />;
 
  const { edges, pageInfo, totalCount } = data.posts;
 
  return (
    <div className="post-feed">
      <header>
        <h2>Posts ({totalCount})</h2>
        {newPosts.length > 0 && (
          <button onClick={() => setNewPosts([])}>
            {newPosts.length} new posts — click to refresh
          </button>
        )}
      </header>
 
      {newPosts.map(post => (
        <PostCard key={post.id} post={post} isNew />
      ))}
 
      {edges.map(({ node }) => (
        <PostCard key={node.id} post={node} />
      ))}
 
      {pageInfo.hasNextPage && (
        <LoadMoreButton
          loading={loading}
          onClick={() =>
            fetchMore({
              variables: { after: pageInfo.endCursor },
            })
          }
        />
      )}
    </div>
  );
}
 
function CreatePostForm() {
  const [createPost, { loading, error }] = useMutation(CREATE_POST, {
    update(cache, { data: { createPost } }) {
      cache.modify({
        fields: {
          posts(existing) {
            const newPostRef = cache.writeFragment({
              data: createPost,
              fragment: gql`
                fragment NewPost on Post {
                  id
                  title
                  content
                  author { id name }
                }
              `,
            });
            return { ...existing, edges: [{ node: newPostRef, cursor: '' }, ...existing.edges] };
          },
        },
      });
    },
  });
 
  const handleSubmit = (e) => {
    e.preventDefault();
    const formData = new FormData(e.target);
    createPost({
      variables: {
        input: {
          title: formData.get('title'),
          content: formData.get('content'),
        },
      },
    });
  };
 
  return (
    <form onSubmit={handleSubmit}>
      <input name="title" placeholder="Post title" required />
      <textarea name="content" placeholder="Write your post..." required />
      <button type="submit" disabled={loading}>
        {loading ? 'Creating...' : 'Create Post'}
      </button>
      {error && <p className="error">{error.message}</p>}
    </form>
  );
}

Apollo Client React Integration

Architecture and Design Patterns

Context and Authentication

Apollo Server's context function runs per-request and is the ideal place for authentication, authorization, and DataLoader instantiation. The context object is passed to every resolver, providing access to the authenticated user and batched data loaders.

import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import express from 'express';
import cors from 'cors';
import jwt from 'jsonwebtoken';
 
const app = express();
 
const server = new ApolloServer({
  typeDefs,
  resolvers,
  plugins: [
    // Logging plugin
    {
      async requestDidStart() {
        const start = Date.now();
        return {
          async willSendResponse({ response }) {
            const duration = Date.now() - start;
            console.log(`Operation completed in ${duration}ms`);
          },
          async didEncounterErrors({ errors }) {
            errors.forEach(err => {
              console.error('GraphQL Error:', err.message, err.extensions);
            });
          },
        };
      },
    },
    // Usage reporting plugin (for Apollo Studio)
    ApolloServerPluginUsageReporting({
      sendVariableValues: { none: true },
    }),
  ],
});
 
await server.start();
 
app.use(
  '/graphql',
  cors<cors.CorsRequest>(),
  express.json(),
  expressMiddleware(server, {
    context: async ({ req }) => {
      const token = req.headers.authorization?.replace('Bearer ', '');
      let user = null;
 
      if (token) {
        try {
          const payload = jwt.verify(token, process.env.JWT_SECRET);
          user = await db.users.findUnique({ where: { id: payload.userId } });
        } catch (err) {
          // Invalid token - proceed as unauthenticated
        }
      }
 
      return {
        user,
        loaders: createLoaders(),
      };
    },
  })
);

Error Handling and Classification

Apollo Server uses the GraphQLError class with extensions to classify errors, enabling clients to handle different error types appropriately.

import { GraphQLError } from 'graphql';
 
// Authentication errors
throw new GraphQLError('You must be logged in', {
  extensions: { code: 'UNAUTHENTICATED' },
});
 
// Authorization errors
throw new GraphQLError('You lack permission for this action', {
  extensions: { code: 'FORBIDDEN', requiredRole: 'ADMIN' },
});
 
// Validation errors
throw new GraphQLError('Invalid input', {
  extensions: {
    code: 'VALIDATION_ERROR',
    fields: {
      title: 'Title must be between 1 and 200 characters',
      email: 'Invalid email format',
    },
  },
});
 
// Not found errors
throw new GraphQLError('Post not found', {
  extensions: { code: 'NOT_FOUND', resourceType: 'Post', id },
});
 
// Rate limiting
throw new GraphQLError('Too many requests', {
  extensions: { code: 'RATE_LIMITED', retryAfter: 60 },
});

Client-side error handling can then branch based on the error code:

import { ApolloError } from '@apollo/client';
 
function handleApolloError(error: ApolloError) {
  const code = error.graphQLErrors?.[0]?.extensions?.code;
 
  switch (code) {
    case 'UNAUTHENTICATED':
      return redirectToLogin();
    case 'FORBIDDEN':
      return showPermissionDenied();
    case 'VALIDATION_ERROR':
      return showValidationErrors(error.graphQLErrors[0].extensions.fields);
    case 'NOT_FOUND':
      return showNotFound();
    case 'RATE_LIMITED':
      return scheduleRetry(error.graphQLErrors[0].extensions.retryAfter);
    default:
      return showGenericError(error.message);
  }
}

File Uploads

GraphQL's specification doesn't natively support file uploads, but Apollo Server provides a solution through the graphql-upload package.

import { graphqlUploadExpress } from 'graphql-upload';
 
app.use(graphqlUploadExpress({ maxFileSize: 10_000_000, maxFiles: 5 }));
 
const typeDefs = `#graphql
  scalar Upload
 
  type File {
    filename: String!
    mimetype: String!
    encoding: String!
    url: String!
  }
 
  type Mutation {
    uploadAvatar(file: Upload!): File!
    uploadPostImages(files: [Upload!]!): [File!]!
  }
`;
 
const resolvers = {
  Mutation: {
    uploadAvatar: async (_, { file }, { user }) => {
      const { createReadStream, filename, mimetype } = await file;
      const stream = createReadStream();
 
      const url = await uploadToS3(stream, {
        key: `avatars/${user.id}/${filename}`,
        contentType: mimetype,
      });
 
      await db.users.update({
        where: { id: user.id },
        data: { avatar: url },
      });
 
      return { filename, mimetype, encoding: 'binary', url };
    },
  },
};

Step-by-Step Implementation

Setting Up Apollo Server with Express

npm install @apollo/server express cors graphql dataloader
npm install -D @types/express @types/cors typescript
// src/server.ts
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import express from 'express';
import cors from 'cors';
import { typeDefs } from './schema';
import { resolvers } from './resolvers';
import { createContext } from './context';
 
async function startServer() {
  const app = express();
  const port = process.env.PORT || 4000;
 
  const server = new ApolloServer({
    typeDefs,
    resolvers,
    formatError: (formattedError, error) => {
      // Don't expose internal errors in production
      if (process.env.NODE_ENV === 'production' &&
          !formattedError.extensions?.code?.startsWith('CUSTOM_')) {
        return { message: 'Internal server error', extensions: { code: 'INTERNAL_ERROR' } };
      }
      return formattedError;
    },
  });
 
  await server.start();
 
  app.use(
    '/graphql',
    cors<cors.CorsRequest>({
      origin: process.env.CORS_ORIGINS?.split(',') || ['http://localhost:3000'],
      credentials: true,
    }),
    express.json({ limit: '10mb' }),
    expressMiddleware(server, { context: createContext })
  );
 
  // Health check endpoint
  app.get('/health', (_, res) => res.json({ status: 'ok' }));
 
  app.listen(port, () => {
    console.log(`🚀 Server ready at http://localhost:${port}/graphql`);
  });
}
 
startServer().catch(console.error);

Setting Up Apollo Client with React

npm install @apollo/client graphql
// src/lib/apollo.ts
import { ApolloClient, InMemoryCache, HttpLink, ApolloLink } from '@apollo/client';
import { setContext } from '@apollo/client/link/context';
import { onError } from '@apollo/client/link/error';
 
const httpLink = new HttpLink({ uri: '/graphql' });
 
const authLink = setContext((_, { headers }) => {
  const token = localStorage.getItem('auth_token');
  return {
    headers: {
      ...headers,
      authorization: token ? `Bearer ${token}` : '',
    },
  };
});
 
const errorLink = onError(({ graphQLErrors, networkError }) => {
  if (graphQLErrors) {
    graphQLErrors.forEach(({ extensions }) => {
      if (extensions?.code === 'UNAUTHENTICATED') {
        localStorage.removeItem('auth_token');
        window.location.href = '/login';
      }
    });
  }
  if (networkError) {
    console.error('Network error:', networkError);
  }
});
 
export const client = new ApolloClient({
  link: ApolloLink.from([errorLink, authLink, httpLink]),
  cache: new InMemoryCache(),
  defaultOptions: {
    watchQuery: { fetchPolicy: 'cache-and-network' },
    query: { fetchPolicy: 'network-only' },
    mutate: { errorPolicy: 'all' },
  },
});
// src/App.tsx
import { ApolloProvider } from '@apollo/client';
import { client } from './lib/apollo';
import { Router } from './Router';
 
export default function App() {
  return (
    <ApolloProvider client={client}>
      <Router />
    </ApolloProvider>
  );
}

Real-World Use Cases

Use Case 1: Social Media Feed

A social media application with infinite scroll, real-time updates, and optimistic interactions demonstrates Apollo's full capabilities. The normalized cache ensures that when a user likes a post from the feed, the like count updates everywhere that post appears—in the feed, on the post detail page, and in search results.

Use Case 2: E-Commerce Product Catalog

An e-commerce platform with complex product relationships, filtering, and inventory tracking benefits from GraphQL's ability to fetch exactly the data needed for each view. Product pages load reviews, related products, and pricing data in a single request.

Use Case 3: Dashboard with Real-Time Metrics

An admin dashboard displaying live metrics, user activity, and system health leverages Apollo's subscription support for real-time updates while using the normalized cache to keep multiple views consistent without redundant network requests.

Best Practices for Production

  1. Use DataLoader for every nested resolver: DataLoader batches individual loads within a single request, preventing N+1 queries. Create new DataLoader instances per request to avoid caching stale data across requests.

  2. Implement persisted queries in production: Automatic Persisted Queries (APQ) reduce bandwidth by sending query hashes instead of full query strings. Combined with CDN caching, this dramatically reduces server load.

  3. Design cache policies per query: Different queries have different freshness requirements. Use cache-first for stable data, network-only for real-time requirements, and cache-and-network for views that need instant display with background updates.

  4. Validate input with Zod or Joi: Use schema validation libraries in your resolvers to validate mutation inputs. Return structured validation errors that forms can map to specific fields.

  5. Disable introspection in production: Introspection exposes your entire schema to potential attackers. Disable it in production and use schema registry tools like Apollo Studio for documentation.

  6. Monitor resolver performance: Track resolver execution times, identify slow queries, and monitor DataLoader batch sizes. Apollo Studio provides this out of the box, or you can implement custom plugins.

  7. Implement field-level authorization: Don't just check authentication at the resolver level—implement field-level authorization for sensitive data like email addresses, financial information, or admin-only fields.

  8. Use fragments for reusable field selections: Define GraphQL fragments for common field selections to ensure consistency across queries and reduce duplication.

Common Pitfalls and Solutions

PitfallImpactSolution
Missing DataLoaderN+1 queries crash the databaseCreate DataLoaders for every relationship field
Stale DataLoader cacheData inconsistency within a requestCreate new DataLoader instances per request context
Overly complex queriesServer timeout, high memory usageImplement query depth limiting and complexity analysis
Cache inconsistenciesUI shows stale data after mutationsUse cache.modify or refetchQueries after mutations
Unhandled loading statesUI flickers or shows incorrect dataHandle loading and error states explicitly in components
Missing error extensionsClients can't distinguish error typesAlways include error codes in GraphQLError extensions
Large schema performanceSlow startup and validationUse schema splitting and lazy loading for large schemas

Performance Optimization

Query Batching

Apollo Client supports query batching, combining multiple queries into a single HTTP request. This reduces network overhead for pages with multiple independent data requirements.

import { BatchHttpLink } from '@apollo/client/link/batch-http';
 
const client = new ApolloClient({
  link: new BatchHttpLink({ uri: '/graphql', batchMax: 10, batchInterval: 20 }),
  cache: new InMemoryCache(),
});

Cache Persistence

For mobile applications or frequently visited pages, persisting the Apollo cache to localStorage or AsyncStorage provides instant load times on subsequent visits.

import { persistCache } from 'apollo3-cache-persist';
 
const cache = new InMemoryCache();
 
await persistCache({
  cache,
  storage: localStorage,
  maxSize: 1024 * 1024 * 5, // 5MB
});
 
const client = new ApolloClient({ link, cache });

Query Complexity Analysis

Prevent expensive queries from overwhelming your server by analyzing query complexity before execution.

import { getComplexity, simpleEstimator, fieldExtensionsEstimator } from 'graphql-query-complexity';
 
const server = new ApolloServer({
  typeDefs,
  resolvers,
  plugins: [{
    async requestDidStart() {
      return {
        async didResolveOperation({ request, document }) {
          const complexity = getComplexity({
            schema: server.schema,
            operationName: request.operationName,
            query: document,
            variables: request.variables,
            estimators: [
              fieldExtensionsEstimator(),
              simpleEstimator({ defaultComplexity: 1 }),
            ],
          });
 
          if (complexity > 1000) {
            throw new GraphQLError('Query too complex', {
              extensions: { code: 'QUERY_TOO_COMPLEX', complexity, maxComplexity: 1000 },
            });
          }
        },
      };
    },
  }],
});

Comparison with Alternatives

FeatureApollourqlRelaygraphql-request
CacheNormalized InMemoryDocument-basedNormalized (mandatory)None
Bundle Size~33KB~7KB~40KB~5KB
Learning CurveModerateLowSteepMinimal
DevToolsExcellentGoodExcellentNone
React HooksFull supportFull supportFull supportManual
SubscriptionsBuilt-inVia exchangesBuilt-inManual
File UploadsVia graphql-uploadManualVia mutationManual
Code GenerationGraphQL CodegenGraphQL CodegenRelay CompilerGraphQL Codegen
Best ForFull-featured appsLightweight appsFacebook-scale appsSimple scripts

Advanced Patterns

Custom Scalars

Define custom scalar types for domain-specific data like dates, URLs, and money.

import { GraphQLScalarType } from 'graphql';
import { DateTimeResolver } from 'graphql-scalars';
 
const resolvers = {
  DateTime: DateTimeResolver,
  Money: new GraphQLScalarType({
    name: 'Money',
    description: 'A monetary value in cents',
    serialize(value) {
      return { amount: value / 100, currency: 'USD' };
    },
    parseValue(value) {
      return Math.round(value.amount * 100);
    },
  }),
};

Schema Stitching and Federation

For large applications, Apollo Federation allows multiple services to contribute to a unified graph while maintaining independent deployment.

// Users subgraph
const typeDefs = `#graphql
  extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key"])
 
  type User @key(fields: "id") {
    id: ID!
    name: String!
    email: String!
  }
`;
 
// Posts subgraph
const typeDefs = `#graphql
  extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@external"])
 
  type Post @key(fields: "id") {
    id: ID!
    title: String!
    author: User!
  }
 
  type User @key(fields: "id") {
    id: ID! @external
    posts: [Post!]!
  }
`;

Testing Strategies

Server-Side Testing

import { describe, it, expect } from 'vitest';
import { createTestServer } from './test-utils';
 
describe('Post resolvers', () => {
  it('creates a post when authenticated', async () => {
    const server = createTestServer({ user: { id: '123' } });
 
    const result = await server.executeOperation({
      query: `
        mutation CreatePost($input: CreatePostInput!) {
          createPost(input: $input) { id title author { id } }
        }
      `,
      variables: { input: { title: 'Test Post', content: 'Content' } },
    });
 
    expect(result.body.singleResult.data.createPost.title).toBe('Test Post');
    expect(result.body.singleResult.data.createPost.author.id).toBe('123');
  });
 
  it('rejects unauthenticated post creation', async () => {
    const server = createTestServer({ user: null });
 
    const result = await server.executeOperation({
      query: `mutation { createPost(input: { title: "Test", content: "Content" }) { id } }`,
    });
 
    expect(result.body.singleResult.errors[0].extensions.code).toBe('UNAUTHENTICATED');
  });
});

Client-Side Testing

import { MockedProvider } from '@apollo/client/testing';
import { render, screen, waitFor } from '@testing-library/react';
import { GET_POSTS } from './PostFeed';
 
const mocks = [
  {
    request: { query: GET_POSTS, variables: { first: 10 } },
    result: {
      data: {
        posts: {
          edges: [
            { node: { id: '1', title: 'Test Post', author: { id: '1', name: 'Author' } }, cursor: 'abc' },
          ],
          pageInfo: { hasNextPage: false, endCursor: 'abc' },
          totalCount: 1,
        },
      },
    },
  },
];
 
describe('PostFeed', () => {
  it('renders posts from GraphQL query', async () => {
    render(
      <MockedProvider mocks={mocks}>
        <PostFeed />
      </MockedProvider>
    );
 
    await waitFor(() => {
      expect(screen.getByText('Test Post')).toBeInTheDocument();
    });
  });
});

Future Outlook

The Apollo ecosystem continues evolving with Apollo Server v4's improved TypeScript support, Apollo Client 3.5's hook-first API, and Apollo Federation v2's enhanced composition model. The GraphQL specification itself is progressing with @defer and @stream directives for progressive data delivery.

The trend toward GraphQL-native databases like Dgraph and Hasura's instant GraphQL APIs democratizes GraphQL adoption by eliminating the need to write resolvers for simple CRUD operations. Combined with tools like GraphQL Code Generator for type-safe operations, the barrier to entry continues to drop.

Conclusion

Apollo Server and Apollo Client provide a mature, production-proven platform for building full-stack GraphQL applications. The schema-first approach establishes clear contracts between teams, DataLoader prevents performance pitfalls, the normalized cache eliminates redundant state management, and React hooks integrate GraphQL seamlessly into component-driven architectures.

The key to success with Apollo is investing in proper DataLoader implementation from day one, designing cache policies that match your data's freshness requirements, and implementing comprehensive error handling that distinguishes between authentication, validation, and system errors. Start with a simple schema, measure performance as you add complexity, and leverage Apollo's extension points for monitoring and optimization.

For teams committed to GraphQL, Apollo's ecosystem offers the most comprehensive toolkit available. The combination of Apollo Server's extensibility, Apollo Client's intelligent caching, and Apollo Studio's observability creates a development experience that scales from prototype to production serving millions of requests.