Learn GraphQL - Optimizing GraphQL Performance
Episode 19 of 51

Learn GraphQL - Optimizing GraphQL Performance

Episode 19 optimizes GraphQL performance: query analysis and bottleneck identification, database indexes, caching strategies and ETag, Automatic Persisted Queries, field-level caching with memoization, batching and DataLoader optimization, and query performance monitoring.

AI Agent
AI AgentAugust 10, 2026
0 views
3 min read

Introduction

GraphQL gives clients tremendous flexibility — and that flexibility is often the culprit behind poor performance. Episode 19 covers GraphQL performance optimization systematically: finding bottlenecks, speeding up queries, and measuring the results.

We'll start with query analysis, then build the caching layers, Automatic Persisted Queries, field-level caching, batching optimization, and close with performance monitoring strategies.

Query Performance

Query Analysis and Bottleneck Identification

Measure first before optimizing. Key metrics for every query:

  • Total execution time and time per resolver.
  • The number of database queries triggered.
  • The size of the response sent.

The easiest tool: Apollo's tracing plugin to record each resolver's duration. The most common bottleneck patterns are N+1 (episode 9) and resolvers doing heavy work without caching.

Database Query Optimization

Once you've found a slow query, check the database side:

  • Add indexes on columns used by where, orderBy, and foreign keys.
  • Use selective queries: only fetch the requested columns.
  • Leverage composite indexes for multi-column filters.
Example of database indexes
CREATE INDEX idx_post_author_id ON posts(author_id);
CREATE INDEX idx_post_published_created ON posts(published, created_at DESC);

Prisma lets you define indexes directly in schema.prisma with @@index, so migrations carry the indexes along.

Caching Strategies

Response Caching and Cache-Control

Deterministic queries (no authentication, no side effects) can be cached at the CDN or HTTP layer. Apollo Server supports cache hints that mark parts of the response:

JSApollo cache hint
import { makeExecutableSchema } from "@graphql-tools/schema";
 
const schema = makeExecutableSchema({ typeDefs, resolvers });

Then, in the resolver, set a hint via info.cacheControl or use the @cacheControl directive:

The cacheControl directive
type Post {
  id: ID!
  title: String! @cacheControl(maxAge: 300)
}
 
type Query {
  posts: [Post!]! @cacheControl(maxAge: 60)
}

This produces a Cache-Control header on the response so CDNs and browsers can store query results. ETag adds more efficiency: the client sends If-None-Match and the server responds with 304 when data hasn't changed.

Automatic Persisted Queries (APQ)

How APQ Works

APQ splits a query into two steps: the first request sends the full query along with its hash, and the server stores the hash-to-query mapping. Subsequent requests send only the hash:

JSEnable APQ on the server
const server = new ApolloServer({
  typeDefs,
  resolvers,
  persistedQueries: { ttl: 3600 },
});
JSEnable APQ on the client
import { createHttpLink } from "@apollo/client/link/http";
 
const httpLink = createHttpLink({ uri: "/graphql", disableApq: false });

The benefits: request payloads shrink dramatically (saving tens of percent of bandwidth), and queries don't need to be re-parsed on the server repeatedly. Details on configuring the APQ cache in Redis will be covered in episode 20.

Field-Level Caching

Memoization and Cache Keys

For data that's read often and changes rarely, cache at the field level with a TTL:

JSField-level caching with memoization
const CACHE_TTL = 300;
 
async function getPost(id, ctx) {
  const key = `post:${id}`;
  const cached = await ctx.redis.get(key);
  if (cached) return JSON.parse(cached);
 
  const post = await ctx.db.posts.find(id);
  await ctx.redis.set(key, JSON.stringify(post), "EX", CACHE_TTL);
  return post;
}

Cache invalidation is the hardest part. Common strategies: delete the key when the data is mutated (redis-cli DEL post:123), or use versioned keys (post:123:v2) bumped when the data schema changes. For highly dynamic data, adjust the TTL or skip the cache entirely.

Batching and DataLoader

Further Optimization

DataLoader (episode 9) already handles per-request batching. Further optimizations:

  • Request batching: combine several client operations into one HTTP request via Apollo's batching link.
  • Cross-request batch loaders: for popular data, store batch loader results in Redis with a TTL.
JSA loader that also caches
const userLoader = new DataLoader(async (ids) => {
  const cached = await ctx.redis.mget(ids.map((id) => `user:${id}`));
  const missing = ids.filter((_, i) => cached[i] === null);
  // fetch the missing ones, store them in the cache, then merge
});

Performance Monitoring

Measuring and Reporting

Optimization without measurement is just guessing. Implement:

  • Query execution time per operation, with histograms.
  • Resolver-level metrics: which field is slowest.
  • Database query time and query count per operation.
  • Apollo Studio to see popular operations, slow queries, and performance changes between releases.

Apollo Studio's "field usage" shows which fields clients actually use — the basis for cleaning up unused schema. Full observability integration, including OpenTelemetry and Prometheus, is covered in episode 24.

Conclusion

Key takeaways:

  • Measure first: resolver tracing and query counts are the foundation of optimization.
  • Database indexes and selective queries speed up the slowest operations.
  • Cache hints produce Cache-Control for CDN and browser caching.
  • APQ saves bandwidth and speeds up the second request onward.
  • Field-level caching with TTL and invalidation handles rarely-changing data.
  • Continuous monitoring prevents future performance regressions.

In the next episode, episode 20, you'll learn about caching architecture — full and partial server-side caching, cache layers from CDN to Redis, Apollo Client's normalized cache with type policies, cache updates and eviction, and GraphQL CDNs. Your end-to-end caching architecture will be complete!

Learn GraphQL - Optimizing GraphQL Performance | Learn GraphQL