Back to all blogs
Web DevelopmentAugust 1, 20269 min read

GraphQL Caching Strategies: How to Build a High-Performance Query Layer That Eliminates Redundant Fetches and Scales to Millions of Requests

GraphQL's flexible query model breaks traditional HTTP caching — here's the complete engineering playbook for building a multi-layer GraphQL caching strategy that cuts latency by 60% and eliminates redundant database hits at scale.

O
Oliver Grayson
Chief Executive Officer
GraphQL Caching Strategies: How to Build a High-Performance Query Layer That Eliminates Redundant Fetches and Scales to Millions of Requests
Quick Answer / TL;DR: GraphQL caching strategies require a fundamentally different approach than REST — you need to combine response-level caching (CDN + persisted queries), field-level caching via directives, DataLoader-based request deduplication, and Redis-backed resolver memoization to achieve sub-50ms query latency at scale. There is no single silver bullet; production systems need all four layers working in concert.

Why GraphQL Caching Strategies Are Harder Than REST — And Why That's a Solvable Problem

If you've ever migrated a high-traffic REST API to GraphQL and watched your database CPU spike to 90%, you already understand the problem. GraphQL caching strategies are fundamentally more complex than their REST counterparts because the entire value proposition of GraphQL — dynamic, client-driven queries — is the exact thing that makes naive HTTP caching useless. Every query can be structurally unique. POST requests don't cache at the CDN layer by default. And the N+1 query problem can silently destroy your database before you even realize what's happening.

At Apargo, we've architected GraphQL backends for SaaS platforms handling upwards of 4 million daily API calls. This article is the distilled playbook — covering every caching layer from the CDN edge down to the resolver level, with real code, real numbers, and the architectural decisions that actually matter in production.

The Four-Layer GraphQL Caching Model

Before diving into implementation, let's establish the mental model. Production-grade GraphQL caching strategies operate across four distinct layers, each targeting a different class of redundancy:

  • Layer 1 — CDN / Edge Cache: Cache full query responses at the network edge using persisted queries and GET requests.
  • Layer 2 — Application Response Cache: Cache entire operation results in Redis keyed by query hash + variables + user context.
  • Layer 3 — Field-Level Cache Directives: Annotate individual schema fields with TTL-based cache hints that drive partial response caching.
  • Layer 4 — DataLoader Deduplication: Batch and deduplicate resolver-level data fetches within a single request lifecycle to eliminate N+1 queries entirely.

Skipping any one of these layers leaves a significant performance gap. Let's build each one from the ground up.

Layer 1: CDN-Level Caching with Persisted Queries

The Problem with POST-Based GraphQL

Standard GraphQL clients send queries as HTTP POST requests with the query string in the body. CDNs like Cloudflare, Fastly, and AWS CloudFront do not cache POST requests by default — and for good reason, since POST semantics imply side effects. This means your CDN, which could be absorbing 70–80% of your traffic, is completely bypassed for every single GraphQL request.

The solution is Automatic Persisted Queries (APQ), a protocol supported natively by Apollo Client and Apollo Server. APQ works as follows:

  1. The client sends a lightweight GET request containing only the SHA-256 hash of the query.
  2. If the server recognizes the hash, it executes the cached query and returns the result.
  3. If not, the server returns a PersistedQueryNotFound error, prompting the client to re-send with the full query body, which the server then registers.
  4. Subsequent requests use the hash-based GET, making them fully CDN-cacheable.

Here's the Apollo Server configuration to enable APQ with Redis-backed storage:

// apollo-server-setup.ts
import { ApolloServer } from '@apollo/server';
import { ApolloServerPluginCacheControl } from '@apollo/server/plugin/cacheControl';
import { KeyvAdapter } from '@apollo/utils.keyvadapter';
import Keyv from 'keyv';
import KeyvRedis from '@keyv/redis';

// Redis-backed APQ store — persisted queries survive server restarts
const redisStore = new KeyvRedis('redis://localhost:6379');
const keyvInstance = new Keyv({ store: redisStore });

const server = new ApolloServer({
  typeDefs,
  resolvers,
  plugins: [
    // Enable cache control directives in schema
    ApolloServerPluginCacheControl({ defaultMaxAge: 0 }),
  ],
  // APQ store: queries are persisted here and served via GET
  cache: new KeyvAdapter(keyvInstance),
  persistedQueries: {
    ttl: 300, // 5-minute TTL for persisted query registry
  },
});

With APQ enabled and your CDN configured to cache GET requests, you can realistically absorb 40–60% of read traffic at the edge, never touching your origin server. For public or semi-public data (product catalogs, content feeds, pricing pages), this is the single highest-leverage optimization available.

Layer 2: Application-Level Response Caching with Redis

Keying Your Cache Correctly

For authenticated or personalized queries that can't be cached at the CDN, the next line of defense is an application-level response cache backed by Redis. The critical engineering challenge here is constructing a cache key that correctly captures all the dimensions of uniqueness for a given query result.

A naive implementation might key on just the query string — but this is dangerously wrong. Two users sending the same query should almost never receive each other's data. Your cache key must encode:

  • The normalized query document (or its SHA-256 hash)
  • The serialized variables object (deep-sorted to avoid key mismatches from property ordering)
  • The authenticated user's ID or tenant ID (for multi-tenant SaaS)
  • Any locale or currency context that affects the response
// cache-key-builder.ts
import crypto from 'crypto';

interface CacheKeyOptions {
  queryHash: string;
  variables: Record<string, unknown>;
  userId?: string;
  tenantId?: string;
  locale?: string;
}

/**
 * Builds a deterministic, collision-resistant Redis cache key
 * for a GraphQL operation result.
 */
export function buildGraphQLCacheKey(options: CacheKeyOptions): string {
  const { queryHash, variables, userId, tenantId, locale } = options;

  // Deep-sort variables to ensure key stability regardless of property order
  const sortedVars = JSON.stringify(variables, Object.keys(variables).sort());

  const keyPayload = [
    'gql',
    queryHash,
    sortedVars,
    userId ?? 'anon',
    tenantId ?? 'global',
    locale ?? 'en',
  ].join(':');

  // Hash the full payload to keep Redis key length manageable
  return crypto.createHash('sha256').update(keyPayload).digest('hex');
}

Integrating the Cache Into Your Resolver Pipeline

Rather than littering individual resolvers with cache logic, wrap your execution at the operation level using a plugin or middleware. Here's an Apollo Server plugin that intercepts execution and serves from Redis when a valid cache entry exists:

// response-cache-plugin.ts
import { ApolloServerPlugin } from '@apollo/server';
import { Redis } from 'ioredis';
import { buildGraphQLCacheKey } from './cache-key-builder';

const redis = new Redis({ host: 'localhost', port: 6379 });

export const responseCache = (): ApolloServerPlugin => ({
  async requestDidStart() {
    return {
      async executionDidStart(requestContext) {
        const { request, contextValue } = requestContext;
        const { query, variables, operationName } = request;

        // Only cache Query operations — never Mutations or Subscriptions
        if (!operationName || request.http?.method === 'POST') return;

        const cacheKey = buildGraphQLCacheKey({
          queryHash: operationName,
          variables: variables ?? {},
          userId: (contextValue as any).userId,
          tenantId: (contextValue as any).tenantId,
        });

        const cached = await redis.get(cacheKey);
        if (cached) {
          // Serve cached response — avg 2–5ms vs 80–300ms from DB
          return { result: JSON.parse(cached), cacheHit: true };
        }

        return {
          async willSendResponse(responseContext) {
            const { response } = responseContext;
            if (!response.body.singleResult.errors) {
              // Cache successful responses with a 60-second TTL
              await redis.setex(cacheKey, 60, JSON.stringify(response.body.singleResult));
            }
          },
        };
      },
    };
  },
});

In production benchmarks at Apargo, this pattern consistently reduces average query latency from 180–300ms down to 8–15ms for cache-hit paths, with Redis serving responses at sub-5ms P99 latency.

Layer 3: Field-Level Cache Directives

Granular TTL Control Per Schema Field

Not all fields in your schema have the same staleness tolerance. A user's profile picture might be safely cacheable for 24 hours, while their unread notification count must always be fresh. GraphQL caching strategies that treat the entire response as a single cacheable unit are too coarse — you need field-level control.

Apollo Server's @cacheControl directive gives you exactly this. Apply it directly in your schema definition:

# schema.graphql

# Enum for controlling cache scope
enum CacheControlScope {
  PUBLIC   # Cacheable by CDN and shared caches
  PRIVATE  # Only cacheable in user-specific (private) caches
}

directive @cacheControl(
  maxAge: Int
  scope: CacheControlScope
  inheritMaxAge: Boolean
) on FIELD_DEFINITION | OBJECT | INTERFACE | UNION

type Product @cacheControl(maxAge: 3600, scope: PUBLIC) {
  id: ID!
  name: String!
  description: String!
  price: Float! @cacheControl(maxAge: 300)  # Price changes more often
  inventory: Int! @cacheControl(maxAge: 30) # Inventory is highly volatile
  images: [ProductImage!]! @cacheControl(maxAge: 86400, scope: PUBLIC)
}

type User @cacheControl(maxAge: 0, scope: PRIVATE) {
  id: ID!
  name: String!
  email: String!
  unreadNotifications: Int! @cacheControl(maxAge: 0) # Always fresh
  avatar: String @cacheControl(maxAge: 86400, scope: PRIVATE)
}

Apollo Server computes the minimum maxAge across all fields touched by a query and uses that as the effective cache TTL for the response. This means a query that touches both Product.name (3600s) and Product.inventory (30s) will correctly be cached for only 30 seconds — the most volatile field wins.

Layer 4: DataLoader — Eliminating the N+1 Query Problem

Why N+1 Kills GraphQL Performance

The N+1 problem is the most common and most catastrophic performance issue in GraphQL backends. Consider a query that fetches 50 blog posts and the author of each. A naive resolver implementation will issue 1 query for posts and then 50 individual queries for authors — 51 total database round trips, each carrying 5–20ms of latency overhead. At scale, this translates to 250–1000ms of avoidable latency per request.

DataLoader, the open-source batching utility from the GraphQL Foundation, solves this by collecting all individual key lookups that occur within a single event loop tick and issuing a single batched query for all of them.

// dataloader-setup.ts
import DataLoader from 'dataloader';
import { db } from './database';

/**
 * Creates a per-request DataLoader for User entities.
 * IMPORTANT: DataLoader instances must be created per-request,
 * not shared globally, to prevent cross-request data leakage.
 */
export function createUserLoader() {
  return new DataLoader<string, User>(
    async (userIds: readonly string[]) => {
      // Single batched query replaces N individual queries
      const users = await db.query(
        `SELECT * FROM users WHERE id = ANY($1)`,
Share this article:
Web DevelopmentApargo Lab

Related Articles

Explore more insights from our engineering and product teams.

View all blogs
WebSocket vs Server-Sent Events: How to Choose the Right Real-Time Protocol for Your Production Application
June 20, 2026
Web Development

WebSocket vs Server-Sent Events: How to Choose the Right Real-Time Protocol for Your Production Application

Choosing between WebSocket vs Server-Sent Events can make or break your real-time feature's performance, scalability, and cost. This deep-dive breaks down the architecture, trade-offs, and exact use cases so your engineering team ships the right solution the first time.

How to Verify Documents Online and Detect Fake, Forged, or AI-Generated Files
April 28, 2026
Engineering

How to Verify Documents Online and Detect Fake, Forged, or AI-Generated Files

Learn how to verify documents online and detect fake, forged, edited, or AI-generated files instantly with VerifyDocs. Secure, fast, and AI-powered fraud detection.

Online Document Verification: Detect Fake, Edited & AI-Generated Files Instantly
May 1, 2026
Engineering

Online Document Verification: Detect Fake, Edited & AI-Generated Files Instantly

Learn how to verify documents online and detect fake, forged, edited, or AI-generated files instantly using VerifyDocs. Fast, secure, and AI-powered.