Skip to main content
Why CacheeHow It Works
All Verticals5G TelecomAd TechAI InfrastructureFraud DetectionGamingTrading
PricingDocsBlogSchedule DemoLog InStart Free Trial
← Back to Blog

GraphQL Caching: Solving the N+1 Problem

December 21, 2025 • 7 min read • GraphQL Performance

GraphQL's flexibility creates a notorious performance trap: the N+1 query problem. A single GraphQL query can trigger hundreds or thousands of database queries, turning a 50ms request into a 5-second nightmare. This guide shows you how to eliminate N+1 queries using intelligent caching and batching strategies.

Understanding the N+1 Problem

The N+1 problem occurs when you fetch a list of items (1 query), then fetch related data for each item (N queries). Consider this GraphQL query:

query GetAuthors {
  authors {
    id
    name
    books {
      id
      title
    }
  }
}
Without optimization, this generates:
  • 1 query: SELECT * FROM authors (returns 100 authors)
  • 100 queries: SELECT * FROM books WHERE author_id = ? (one per author)
  • Total: 101 database queries for a single GraphQL request

With 100 authors and 10ms per query, your response time balloons to 1,010ms. Add more nested fields and you quickly reach thousands of queries per request.

Solution 1: DataLoader Pattern

DataLoader batches and caches requests within a single GraphQL operation:

const DataLoader = require('dataloader');

// Create a batching loader for books
const bookLoader = new DataLoader(async (authorIds) => {
    // Single query for all author IDs
    const books = await db.query(`
        SELECT * FROM books
        WHERE author_id IN (?)
        ORDER BY author_id
    `, [authorIds]);

    // Group books by author_id
    const booksByAuthor = authorIds.map(id =>
        books.filter(book => book.author_id === id)
    );

    return booksByAuthor;
});

// GraphQL resolver
const resolvers = {
    Author: {
        books: (author, args, context) => {
            // DataLoader automatically batches and caches
            return context.loaders.book.load(author.id);
        }
    }
};
With DataLoader:
  • 1 query: SELECT * FROM authors (100 authors)
  • 1 query: SELECT * FROM books WHERE author_id IN (1,2,3...100)
  • Total: 2 queries instead of 101
  • Performance: 20ms instead of 1,010ms (50x faster)

Solution 2: Field-Level Caching

Cache individual fields across requests using directives:

const { ApolloServer } = require('apollo-server');
const responseCachePlugin = require('apollo-server-plugin-response-cache');

const typeDefs = `
  type Query {
    author(id: ID!): Author @cacheControl(maxAge: 300)
  }

  type Author {
    id: ID!
    name: String! @cacheControl(maxAge: 3600)
    books: [Book!]! @cacheControl(maxAge: 300)
  }

  type Book {
    id: ID!
    title: String!
    rating: Float @cacheControl(maxAge: 60)
  }
`;

const server = new ApolloServer({
    typeDefs,
    resolvers,
    plugins: [
        responseCachePlugin({
            // Use Redis for distributed caching
            cache: new RedisCache({
                host: 'localhost',
                port: 6379
            })
        })
    ]
});

Automatic Cache Key Generation

// Apollo automatically generates cache keys like:
// "author:123:name" - cached for 1 hour
// "author:123:books" - cached for 5 minutes
// "book:456:rating" - cached for 1 minute

// Responses use the shortest TTL of any field
// Full query cached based on most volatile field

Solution 3: Persistent Query Caching

Cache entire query responses with smart invalidation:

class GraphQLCache {
    constructor(cache) {
        this.cache = cache;
    }

    async executeQuery(query, variables, context) {
        // Generate cache key from query + variables
        const cacheKey = this.generateKey(query, variables);

        // Try cache first
        const cached = await this.cache.get(cacheKey);
        if (cached) {
            return {
                data: cached,
                extensions: { cacheHit: true }
            };
        }

        // Execute query
        const result = await graphql({
            schema,
            source: query,
            variableValues: variables,
            contextValue: context
        });

        // Cache with TTL based on query complexity
        const ttl = this.calculateTTL(query);
        const tags = this.extractTypes(query);

        await this.cache.set(cacheKey, result.data, ttl, { tags });

        return result;
    }

    generateKey(query, variables) {
        // Normalize query and hash with variables
        const normalized = this.normalizeQuery(query);
        return crypto
            .createHash('sha256')
            .update(normalized + JSON.stringify(variables))
            .digest('hex');
    }

    calculateTTL(query) {
        // Extract @cacheControl directives
        const directives = this.parseDirectives(query);

        // Use minimum TTL from all fields
        const ttls = directives.map(d => d.maxAge);
        return Math.min(...ttls, 300); // Max 5 minutes
    }

    extractTypes(query) {
        // Parse query to find all accessed types
        // Used for cache invalidation
        const ast = parse(query);
        return this.findTypes(ast); // ['Author', 'Book']
    }
}

Solution 4: Intelligent Prefetching

Analyze query patterns to prefetch likely-needed data:

class PrefetchingDataLoader extends DataLoader {
    constructor(batchFn, options) {
        super(batchFn, options);
        this.accessPatterns = new Map();
    }

    async load(key) {
        // Track access patterns
        this.recordAccess(key);

        // Prefetch related keys based on history
        const relatedKeys = this.predictRelatedKeys(key);
        if (relatedKeys.length > 0) {
            // Non-blocking prefetch
            this.loadMany(relatedKeys).catch(err =>
                console.error('Prefetch failed:', err)
            );
        }

        return super.load(key);
    }

    predictRelatedKeys(key) {
        // ML-powered prediction or simple pattern matching
        const pattern = this.accessPatterns.get(key);
        if (!pattern) return [];

        // If author:123 is accessed, books for that author
        // are accessed 85% of the time - prefetch them
        if (pattern.booksProbability > 0.7) {
            return [`books:author:${key}`];
        }

        return [];
    }

    recordAccess(key) {
        // Update access patterns for ML training
        const pattern = this.accessPatterns.get(key) || {
            accessCount: 0,
            booksProbability: 0
        };

        pattern.accessCount++;
        this.accessPatterns.set(key, pattern);
    }
}

Solution 5: Automatic Persisted Queries (APQ)

Cache queries by hash to reduce payload size and enable aggressive caching:

// Client sends hash instead of full query
const client = new ApolloClient({
    link: createPersistedQueryLink().concat(httpLink),
    cache: new InMemoryCache()
});

// Server implementation
const server = new ApolloServer({
    typeDefs,
    resolvers,
    persistedQueries: {
        cache: new RedisCache({
            host: 'localhost',
            port: 6379
        }),
        ttl: 900 // 15 minutes
    }
});

// First request: Send hash + full query
// Subsequent requests: Send only hash
// Saves bandwidth and enables query-level caching

Complete Example: Optimized GraphQL Server

const { ApolloServer } = require('apollo-server');
const DataLoader = require('dataloader');
const Redis = require('ioredis');

const redis = new Redis();

// Context factory with loaders
function createContext() {
    return {
        loaders: {
            author: new DataLoader(async (ids) => {
                const authors = await db.query(
                    'SELECT * FROM authors WHERE id IN (?)',
                    [ids]
                );
                return ids.map(id =>
                    authors.find(a => a.id === id)
                );
            }),

            books: new DataLoader(async (authorIds) => {
                const books = await db.query(
                    'SELECT * FROM books WHERE author_id IN (?)',
                    [authorIds]
                );
                return authorIds.map(id =>
                    books.filter(b => b.author_id === id)
                );
            })
        },
        redis
    };
}

const resolvers = {
    Query: {
        author: async (_, { id }, { redis, loaders }) => {
            // Try cache first
            const cached = await redis.get(`author:${id}`);
            if (cached) return JSON.parse(cached);

            // Use DataLoader
            const author = await loaders.author.load(id);

            // Cache for 1 hour
            await redis.setex(
                `author:${id}`,
                3600,
                JSON.stringify(author)
            );

            return author;
        }
    },

    Author: {
        books: (author, _, { loaders }) => {
            // DataLoader batches and caches
            return loaders.books.load(author.id);
        }
    }
};

const server = new ApolloServer({
    typeDefs,
    resolvers,
    context: createContext,
    plugins: [
        responseCachePlugin(),
        {
            requestDidStart() {
                const start = Date.now();
                return {
                    willSendResponse({ metrics, response }) {
                        metrics.duration = Date.now() - start;
                        console.log('Query time:', metrics.duration);
                    }
                };
            }
        }
    ]
});

Measuring Performance Improvements

// Before optimization
{
    "duration": 1247,
    "queries": 101,
    "cacheHits": 0,
    "cacheHitRate": 0
}

// After DataLoader + field caching
{
    "duration": 23,
    "queries": 2,
    "cacheHits": 0,
    "cacheHitRate": 0
}

// After warming cache
{
    "duration": 4,
    "queries": 0,
    "cacheHits": 2,
    "cacheHitRate": 1.0
}

// Performance improvement: 311x faster (1247ms → 4ms)

Cache Invalidation Strategies

Invalidate cached GraphQL data when underlying data changes:

// Type-based invalidation
async function updateAuthor(id, data) {
    await db.update('authors', id, data);

    // Invalidate all cached queries involving Author type
    await cache.invalidateByTag('Author');

    // Or specific author
    await cache.del(`author:${id}`);
}

// Smart invalidation with dependency tracking
class SmartCache {
    async invalidateType(typeName) {
        // Find all cached queries that include this type
        const pattern = `query:*:${typeName}:*`;
        const keys = await redis.keys(pattern);

        if (keys.length > 0) {
            await redis.del(...keys);
        }

        console.log(`Invalidated ${keys.length} queries for ${typeName}`);
    }
}

Best Practices Summary

Conclusion

GraphQL's N+1 problem can cripple application performance, but the solution combines DataLoader batching, field-level caching, and intelligent query caching. These patterns reduce database queries by 98%+ and improve response times from seconds to milliseconds.

Start with DataLoader for all relationship resolvers, add field-level caching using @cacheControl directives, and implement full query caching for your most expensive operations. The result: fast, scalable GraphQL APIs that handle millions of requests with minimal infrastructure.

Automatic GraphQL Query Optimization

Cachee AI automatically detects and optimizes GraphQL N+1 patterns with ML-powered prefetching and intelligent field-level caching.

Start Free Trial

Related Reading

The Numbers That Matter

Cache performance discussions get philosophical fast. Here are the actual measured numbers from production deployments running on documented hardware, so you can compare against your own infrastructure instead of trusting marketing copy.

The compounding effect matters more than any single number. A 28-nanosecond L0 hit means your application spends almost zero time on cache lookups in the hot path, leaving the CPU free for the actual business logic that generates revenue.

When Caching Actually Helps

Caching isn't free. It introduces a consistency problem you didn't have before. Before adding any cache layer, the question to answer is whether your workload actually benefits from caching at all.

Caching helps when three conditions hold simultaneously. First, your reads dramatically outnumber your writes — typically a 10:1 ratio or higher. Second, the same keys get read repeatedly within a window where a cached value remains valid. Third, the cost of computing or fetching the underlying value is meaningfully higher than the cost of a cache lookup. Database queries that hit secondary indexes, RPC calls to slow upstream services, expensive computed aggregations, and rendered template fragments all qualify.

Caching hurts when those conditions don't hold. Write-heavy workloads suffer because every write invalidates a cache entry, multiplying your work. Workloads with poor key locality suffer because the cache wastes memory storing entries that never get reused. Workloads where the underlying fetch is already fast — well-indexed primary key lookups against a properly tuned database, for example — gain almost nothing from caching and inherit the consistency complexity for no reason.

The honest first step before any cache deployment is measuring your actual read/write ratio, key access distribution, and underlying fetch latency. If your read/write ratio is below 5:1 or your underlying database is already returning results in single-digit milliseconds, the engineering time is better spent elsewhere.

Memory Efficiency Is The Hidden Cost Lever

Throughput numbers get the headlines but memory efficiency determines your monthly bill. A cache that stores the same hot data in less RAM lets you run a smaller instance class — and on AWS that's the difference between profitable and breakeven for a lot of services.

Redis stores each key as a Simple Dynamic String with 16 bytes of header overhead, plus dictEntry pointers in the main hashtable, plus embedded TTL metadata. For 1KB values, per-entry overhead lands around 1100-1200 bytes once you account for hashtable load factor and slab fragmentation. At a million keys, that's roughly 1.2 GB of resident memory just for the data.

Cachee's L1 layer uses sharded DashMap entries with compact packing — a 64-bit key hash, value bytes, an 8-byte expiry timestamp, and a small frequency counter for the CacheeLFU admission filter. Per-entry overhead lands at roughly 40 bytes of structural data on top of the value itself. For the same million-key workload, that's about 13% smaller resident memory. On AWS ElastiCache pricing, that gap is the difference between needing a cache.r7g.large versus a cache.r7g.xlarge for borderline workloads.

What This Actually Costs

Concrete pricing math beats hypothetical. A typical SaaS workload with 1 billion cache operations per month, average 800-byte values, and a 5 GB hot working set currently runs on AWS ElastiCache cache.r7g.xlarge primary plus a read replica — roughly $480 per month for the two nodes, plus cross-AZ data transfer charges that quietly add another $50-150 per month depending on access patterns.

Migrating the hot path to an in-process L0/L1 cache and keeping ElastiCache as a cold L2 fallback drops the dedicated cache spend to $120-180 per month. For workloads where the hot working set fits inside the application's existing memory budget, you can eliminate the dedicated cache tier entirely. The cache becomes a library you link into your binary instead of a separate service to operate.

Compounded over twelve months, that's $3,600 to $4,500 per year on a single small workload. Multiply across a fleet of services and the savings start showing up in finance team conversations. The bigger savings usually come from eliminating cross-AZ data transfer charges, which Redis-as-a-service architectures incur on every read that crosses an availability zone.