Skip to content

GraphQL Performance — Query Optimization, Caching and Best Practices

DodaTech Updated 2026-06-28 6 min read

In this tutorial, you will learn about GraphQL Performance. We cover key concepts, practical examples, and best practices to help you master this topic.

GraphQL performance optimization requires strategies at every layer — resolver efficiency, response caching, CDN integration, query analysis, and database query optimization.

What You'll Learn

You will learn how to optimize GraphQL API performance with response caching, CDN integration, DataLoader patterns, persisted queries, query analysis, and monitoring.

Why Performance Matters

A slow GraphQL API undermines the developer experience and drives users away. Without optimization, complex dashboard queries take seconds instead of milliseconds. DodaTech's Durga Antivirus Pro dashboard was taking 3.2 seconds to load — after implementing response caching, DataLoader batching, and persisted queries, the same dashboard loads in 200ms.

flowchart LR
    A["Request"] --> B{"Cached?"}
    B -->|"Yes"| C["CDN Cache\n(5ms)"]
    B -->|"No"| D["Apollo Cache\n(10ms)"]
    D --> E{"Cache Hit?"}
    E -->|"Yes"| F["Return cached"]
    E -->|"No"| G["Execute Resolvers\n(optimized)"]
    G --> H["DataLoader Batching\n(1 DB query)"]
    H --> I["Response\n(50ms)"]
    style C fill:#bbf7d0,stroke:#16a34a
    style F fill:#bbf7d0,stroke:#16a34a
    style I fill:#bbf7d0,stroke:#16a34a
    style G fill:#fef3c7,stroke:#d97706
â„šī¸ Info

Prerequisites: Apollo Server, DataLoader, and caching concepts.

Response Caching with Cache Hints

# Schema cache hints
type Threat @cacheControl(maxAge: 60) {
  id: ID!
  name: String!
  severity: Severity!
  # Don't cache real-time status
  status: ThreatStatus @cacheControl(maxAge: 0)
}

type Device @cacheControl(maxAge: 30) {
  id: ID!
  name: String!
  os: String!
}

type Query {
  # Cache public data for 5 minutes
  threats: [Threat!]! @cacheControl(maxAge: 300)
  # Cache device catalog for 30 seconds
  devices: [Device!]! @cacheControl(maxAge: 30)
  # Never cache user-specific data
  myProfile: User! @cacheControl(maxAge: 0, scope: PRIVATE)
}
const { ApolloServer } = require('apollo-server');
const responseCachePlugin = require('apollo-server-plugin-response-cache');

const server = new ApolloServer({
  typeDefs,
  resolvers,
  cache: 'bounded',
  plugins: [
    responseCachePlugin({
      shouldReadFromCache: (requestContext) => {
        // Only cache GET requests (CDN-friendly)
        return requestContext.request.http?.method === 'GET';
      },
      sessionId: (requestContext) => {
        const user = requestContext.context.user;
        return user?.id || 'anonymous';
      },
    }),
  ],
});

CDN Caching with Persisted Queries

// Automatic Persisted Queries enable GET requests for caching
// CDN caches the response by URL
// URL includes the query hash, making it cacheable

// Server config
const server = new ApolloServer({
  typeDefs,
  resolvers,
  persistedQueries: true,
  cache: 'bounded',
  responseCachePlugin(),
});

// Client sends GET request for cached queries
// GET /graphql?query={queryHash}&variables={...}
// CDN caches this URL for 5 minutes
// Subsequent requests served from CDN edge

DataLoader Optimization

// Common DataLoader patterns for performance

// 1. Primary loader — by ID
const userLoader = new DataLoader(ids => 
  db.users.findByIds(ids).then(rows => 
    ids.map(id => rows.find(r => r.id === id) || null)
  ),
  { maxBatchSize: 100 }
);

// 2. Relationship loader — one-to-many
const threatsByDeviceLoader = new DataLoader(deviceIds =>
  db.threats.findByDeviceIds(deviceIds).then(rows =>
    deviceIds.map(id => rows.filter(r => r.deviceId === id))
  )
);

// 3. Computed field loader — aggregated data
const threatCountLoader = new DataLoader(deviceIds =>
  db.threats.countByDeviceIds(deviceIds).then(counts =>
    deviceIds.map(id => counts[id] || 0)
  )
);

// 4. Cross-reference loader
const devicesByUserLoader = new DataLoader(userIds =>
  db.devices.findByUserIds(userIds).then(rows =>
    userIds.map(id => rows.filter(r => r.userId === id))
  )
);

Query Performance Monitoring

const server = new ApolloServer({
  typeDefs,
  resolvers,
  plugins: [{
    requestDidStart({ request }) {
      const startTime = process.hrtime.bigint();
      const query = request.query?.substring(0, 200);
      
      return {
        willSendResponse({ context, errors }) {
          const duration = Number(process.hrtime.bigint() - startTime) / 1e6;
          
          // Log slow queries
          if (duration > 500) {
            logger.warn('Slow query', {
              operation: request.operationName,
              duration: `${duration.toFixed(0)}ms`,
              query,
              userId: context.user?.id,
            });
          }
          
          // Track metrics
          metrics.timing('graphql.query.duration', duration, {
            operation: request.operationName || 'anonymous',
            hasErrors: (errors?.length || 0) > 0,
          });
        },
      };
    },
  }],
});

N+1 Query Detection

// Log DataLoader batch efficiency
function createMonitoredLoader(name, batchFn, options = {}) {
  const loader = new DataLoader(async (keys) => {
    const startTime = Date.now();
    const results = await batchFn(keys);
    const duration = Date.now() - startTime;
    
    logger.debug(`DataLoader ${name}: ${keys.length} keys in ${duration}ms`);
    
    // Alert on single-key loads (possible N+1)
    if (keys.length === 1 && keys.length < 100) {
      logger.warn(`DataLoader ${name}: single-key load detected`);
    }
    
    return results;
  }, options);
  
  return loader;
}

// Usage
const context = {
  threatLoader: createMonitoredLoader('threats', batchThreats),
  userLoader: createMonitoredLoader('users', batchUsers),
};

Query Batching Strategies

// Merge multiple small queries into one
// Before: 10 separate requests
// After: 1 request with aliases

query DashboardData {
  criticalThreats: threats(severity: CRITICAL, limit: 5) {
    id name severity detectedAt
  }
  onlineDevices: devices(status: ONLINE) {
    id name os lastScan
  }
  userAlerts: myAlerts(unread: true) {
    id message createdAt
  }
  systemHealth: health {
    status uptime activeScans
  }
}

Common Mistakes

1. No Response Caching for Public Data

Threat lists, device catalogs, and severity enums rarely change. Without caching, every request hits resolvers and databases.

2. Not Using DataLoader for All Relationships

Every findByXId call in a resolver without DataLoader creates an N+1 problem. Use DataLoader for ALL relationship resolvers.

3. Ignoring Client-Side Caching

Apollo Client's InMemoryCache normalizes data by ID. Ensure every type has a unique id: ID! field for proper cache normalization.

4. Not Monitoring Query Performance

Without monitoring, you don't know which queries are slow. Log duration per operation and set up alerts for queries exceeding 500ms.

5. Overfetching in Parent Resolvers

Loading all related data in the root resolver defeats GraphQL's selection optimization. Let each level fetch its own data via DataLoader.

Practice Questions

  1. What is the most impactful GraphQL performance optimization?
  2. How do you cache GraphQL responses on the server?
  3. How do you optimize nested resolver queries?
  4. What is the role of CDN in GraphQL performance?
  5. How do you detect N+1 queries in production?

Answers:

  1. DataLoader batching — it reduces N+1 queries to 2 queries regardless of data size. Without it, performance degrades linearly with data volume.
  2. Use responseCachePlugin with @cacheControl directives on types and fields. Cache public data (minutes) and skip caching for user-specific data.
  3. Use DataLoader for ALL relationship resolvers. Batch loads within each request. Create loaders per request in the context Factory.
  4. CDN caches GraphQL GET requests (persisted queries) at edge locations. Subsequent requests from nearby users are served from the edge cache, not the origin server.
  5. Log DataLoader batch sizes — if a loader frequently receives single keys, it indicates N+1. Also log resolver execution counts for relationship fields.

Challenge: Optimize DodaTech's GraphQL API for performance. Implement response caching with appropriate TTLs (public data: 5min, device data: 30s, user data: 0). Set up DataLoader for all entity relationships with monitoring. Configure persisted queries for CDN caching. Implement query performance monitoring with slow-query alerts. Target: dashboard loads in under 200ms.

FAQ

How much faster is a cached GraphQL query?

Response caching can reduce latency by 10-100x. A DB query taking 100ms becomes a cache hit at 2-5ms. CDN caching reduces latency to <10ms regardless of user location.

Should I cache at the server or client?

Both — server cache (responseCachePlugin) prevents duplicate resolver execution. Client cache (Apollo InMemoryCache) prevents duplicate network requests. They work together.

How do I invalidate cache after mutations?

Use @cacheControl(maxAge: 0) on mutation return types, or implement Cache-Control headers that tell the client to refetch.

Does persisted query performance matter?

Yes — APQ reduces request size by 90%+ (hash instead of full query) and enables GET requests for CDN caching. This significantly improves page load times.

How many DataLoader instances should I create?

One per entity relationship: userLoader, deviceLoader, threatLoader, scanLoader, alertLoader. Each with appropriate batch function and maxBatchSize.

Mini Project

Optimize DodaTech's GraphQL API from first principles. Measure baseline performance, implement DataLoader for all relationships, add response caching with type-specific TTLs, configure persisted queries with CDN integration, implement query monitoring with Datadog/Prometheus, and benchmark the before/after performance improvement.

What's Next

Topic Description
Full-Stack Project Build a complete GraphQL application
Security Protecting your GraphQL API
Testing Testing GraphQL APIs
âŦ… Security
➡ GraphQL Project

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro