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 Caching: Normalized Caches and Persistence

Implement GraphQL caching: Apollo cache, Relay store, and cache persistence.

GraphQLCachingApolloPerformance

By MinhVo

Introduction

Caching is one of the most powerful performance optimizations available to GraphQL applications. Unlike REST APIs where caching is often handled at the HTTP layer with CDNs and browser caches, GraphQL's single-endpoint design requires a more sophisticated approach. Normalized caching—where objects are stored by their unique identifiers rather than by query—enables automatic cache updates, reduces redundant data fetching, and dramatically improves perceived performance.

In this comprehensive guide, we'll explore how normalized caches work under the hood, implement caching strategies with Apollo Client and Relay, and learn how to persist caches for offline-first experiences. Whether you're building a real-time dashboard or a content-heavy application, mastering GraphQL caching will transform how your app handles data.

GraphQL caching architecture diagram

Understanding Normalized Caches: Core Concepts

A normalized cache stores each GraphQL object exactly once, keyed by a unique identifier. When you query a User with id: "1" in multiple different queries, the cache holds only one copy of that user. This is fundamentally different from document caching (where entire query responses are cached) and provides several critical advantages.

Why Normalization Matters

Consider a social media app where the same user appears in a feed post, a comment, and a friend list. Without normalization, each query response would store a separate copy of that user object. If the user updates their name, you'd need to manually update every copy across every query result. With normalization, there's a single source of truth—the user object stored at User:1—and all queries referencing that user automatically reflect the update.

The normalization process works in three steps. First, the cache extracts objects from a query response by traversing the result tree. Second, each object is assigned a cache key using its __typename and id (or a custom keyFields function). Third, the original query response is replaced with references to the normalized store, creating a graph-like data structure where objects can point to each other.

Cache Key Generation

By default, Apollo Client generates cache keys using the __typename:id pattern. For a User with id: "42", the cache key becomes User:42. Relay uses a similar approach but with opaque record identifiers. Custom key generation allows you to handle entities without traditional IDs:

const cache = new InMemoryCache({
  typePolicies: {
    Product: {
      keyFields: ["sku", "warehouse"],
    },
    User: {
      keyFields: ["id"],
    },
  },
});

This ensures that products are uniquely identified by their SKU and warehouse combination, even if they don't have a traditional id field.

Cache Normalization Internals

When Apollo's InMemoryCache processes a query response, it performs a deep traversal of the result object. For each object with a __typename field, it computes a cache key and stores the object in its internal EntityMap. Scalar fields are stored directly, while object fields are replaced with Reference objects pointing to the normalized entity. This creates a directed graph where entities can reference each other without data duplication.

Normalized cache data flow diagram

Architecture and Design Patterns

The Entity Store Pattern

The entity store is the heart of a normalized cache. It's a flat map of entities, where each entity contains its scalar fields and references to other entities. This architecture enables O(1) lookups by cache key and ensures that updating an entity in one place automatically propagates to all queries that reference it.

// Simplified entity store structure
interface EntityStore {
  [cacheKey: string]: {
    [fieldName: string]: any;
    __typename: string;
  };
}
 
// Example state
const store: EntityStore = {
  "User:1": {
    __typename: "User",
    id: "1",
    name: "Alice",
    email: "alice@example.com",
    posts: [{ __ref: "Post:101" }, { __ref: "Post:102" }],
  },
  "Post:101": {
    __typename: "Post",
    id: "101",
    title: "GraphQL Caching",
    author: { __ref: "User:1" },
  },
};

Cache Policies and Eviction

Cache policies determine how the cache behaves when data is requested. Apollo Client supports four fetch policies: cache-first (default), cache-only, network-only, and cache-and-network. Understanding when to use each policy is crucial for building responsive applications.

For data that rarely changes (user profiles, settings), cache-first minimizes network requests. For real-time data (stock prices, chat messages), cache-and-network ensures fresh data while still providing instant cached responses. For offline scenarios, cache-only serves data exclusively from the cache.

Normalized vs. Document Caching

Document caching stores entire query responses as serialized documents. While simpler to implement, it suffers from data duplication and stale data issues. Normalized caching trades higher memory usage and implementation complexity for superior data consistency and automatic updates.

FeatureDocument CacheNormalized Cache
Data duplicationHighNone
Cross-query updatesManualAutomatic
Memory usageLowerHigher
Implementation complexitySimpleComplex
Real-time supportLimitedExcellent

Step-by-Step Implementation

Setting Up Apollo Client Cache

Apollo Client ships with InMemoryCache as its default cache implementation. Setting it up requires configuring normalization policies and type definitions:

import { ApolloClient, InMemoryCache, ApolloLink } from "@apollo/client";
import { persistCache, LocalStorageWrapper } from "apollo3-cache-persist";
 
const cache = new InMemoryCache({
  possibleTypes: {
    Node: ["User", "Post", "Comment"],
  },
  typePolicies: {
    Query: {
      fields: {
        user: {
          read(existing, { args, toReference }) {
            return (
              existing ||
              toReference({
                __typename: "User",
                id: args?.id,
              })
            );
          },
        },
        posts: {
          keyArgs: ["category"],
          merge(existing, incoming, { args }) {
            const merged = existing ? existing.edges.slice(0) : [];
            if (args?.after) {
              for (const edge of incoming.edges) {
                if (!merged.some((e) => e.cursor === edge.cursor)) {
                  merged.push(edge);
                }
              }
            } else {
              return incoming;
            }
            return { ...incoming, edges: merged };
          },
        },
      },
    },
    User: {
      keyFields: ["id"],
      fields: {
        posts: {
          merge(existing = [], incoming) {
            return [...existing, ...incoming];
          },
        },
      },
    },
  },
});

Implementing Cache Reads with Field Policies

Field policies allow you to intercept cache reads and writes, enabling computed fields, cache redirections, and pagination merging. The read function is called whenever a field is accessed, giving you complete control over what data is returned:

const typePolicies = {
  Query: {
    fields: {
      // Redirect to normalized entity without a query
      currentUser: {
        read(_, { toReference }) {
          const userId = localStorage.getItem("currentUserId");
          return toReference({ __typename: "User", id: userId });
        },
      },
      // Merge paginated results
      notifications: {
        keyArgs: ["type"],
        merge(existing, incoming, { args }) {
          if (!args?.offset) return incoming;
          return {
            ...incoming,
            items: [...(existing?.items || []), ...incoming.items],
          };
        },
      },
    },
  },
};

Persisting the Cache for Offline Support

Cache persistence stores the normalized cache to a persistent storage medium (localStorage, IndexedDB, or AsyncStorage for React Native). This enables instant app startup with previously fetched data:

import { persistCache } from "apollo3-cache-persist";
import { LocalStorageWrapper } from "apollo3-cache-persist";
 
async function initializeApollo() {
  const cache = new InMemoryCache({ /* config */ });
 
  await persistCache({
    cache,
    storage: new LocalStorageWrapper(window.localStorage),
    maxSize: 1024 * 1024 * 5, // 5MB limit
    debug: process.env.NODE_ENV === "development",
  });
 
  const client = new ApolloClient({
    cache,
    link: ApolloLink.from([/* your links */]),
  });
 
  return client;
}

Cache persistence architecture

Real-World Use Cases

E-Commerce Product Catalog

In an e-commerce application, the same product might appear in search results, category pages, wishlists, and order history. With normalized caching, when a product's price changes, every view automatically reflects the update without refetching data:

const GET_PRODUCT = gql`
  query GetProduct($id: ID!) {
    product(id: $id) {
      id
      name
      price
      inventory
      reviews {
        id
        rating
        text
        author { id name }
      }
    }
  }
`;
 
// When the price updates via mutation, all queries referencing
// Product:123 automatically show the new price

Real-Time Chat Application

Chat applications benefit enormously from normalized caching. When a new message arrives via subscription, the cache can be updated to include the message in all relevant conversations. Users appearing in multiple conversations maintain consistent profile data across the entire application.

Dashboard with Multiple Data Sources

Financial dashboards often display the same entity (a stock, account, or transaction) in multiple widgets. Normalized caching ensures that when a stock price updates in the header, the portfolio widget, watchlist, and transaction history all reflect the same price simultaneously.

Best Practices for Production

  1. Always define typePolicies: Without explicit policies, the cache falls back to heuristic-based normalization which can miss entities or create duplicates.

  2. Use keyArgs for filtered queries: When a field accepts filter arguments (like posts(category: "tech")), specify which arguments create distinct cache entries to prevent cross-contamination.

  3. Implement read functions for computed fields: Derive values from cached data rather than storing redundant copies. This keeps the cache DRY and automatically updates when dependencies change.

  4. Set cache size limits: Unbounded caches consume increasing memory. Use apollo3-cache-persist with size limits or implement custom eviction strategies.

  5. Handle cache garbage collection: Remove entities that are no longer referenced by any active query to prevent memory leaks in long-running applications.

  6. Use cache.evict() strategically: When deleting entities, evict them from the cache and run garbage collection to clean up dangling references:

const [deleteUser] = useMutation(DELETE_USER, {
  update(cache, { data }) {
    cache.evict({ id: cache.identify(data.deleteUser) });
    cache.gc();
  },
});
  1. Monitor cache performance: Use Apollo Client DevTools to inspect cache contents, track hit rates, and identify normalization issues during development.

  2. Implement cache warming: Pre-populate the cache with known data (from server-side rendering or initial page load) to eliminate loading states for critical content.

Common Pitfalls and Solutions

PitfallImpactSolution
Missing id fields in queriesObjects not normalized, duplicates appearAlways include id and __typename in queries
Over-normalization of transient dataCache pollution with temporary objectsUse noCache directive or separate cache for transient data
Stale pagination cachesUsers see outdated list dataImplement proper merge functions with cursor-based pagination
Cache persistence race conditionsApp crashes on startup with corrupt cacheWrap persistCache in try-catch, reset cache on failure
Memory leaks from orphaned entitiesApp slows down over timeCall cache.gc() after entity deletion

Performance Optimization

Normalized caching already provides significant performance benefits, but there are additional optimizations you can apply. Batch multiple cache reads into single operations, use cache-only fetch policy for data you know is cached, and implement possibleTypes to help the cache identify union and interface types correctly.

// Batch cache reads for better performance
function getRelatedEntities(cache, entityType, ids) {
  return ids.map((id) =>
    cache.readFragment({
      id: `${entityType}:${id}`,
      fragment: gql`
        fragment EntityFields on ${entityType} {
          id
          name
          status
        }
      `,
    })
  );
}

Comparison with Alternatives

FeatureApollo CacheRelay Storeurql Graphcache
NormalizationFullFullFull
Config APItypePoliciesCompiler-drivenresolvers
PersistencePluginManualPlugin
Bundle size~30KB~40KB~8KB
Learning curveModerateSteepGentle
Schema awarenessOptionalRequiredOptional

Advanced Patterns

Optimistic Mutations with Cache Updates

Optimistic updates immediately reflect changes in the UI before the server confirms them. Combined with normalized caching, you can update any entity in the cache regardless of which query originally fetched it:

const [updateUser] = useMutation(UPDATE_USER, {
  optimisticResponse: {
    __typename: "Mutation",
    updateUser: {
      __typename: "User",
      id: userId,
      name: newName,
    },
  },
  update(cache, { data: { updateUser } }) {
    cache.modify({
      id: cache.identify(updateUser),
      fields: {
        name: () => newName,
      },
    });
  },
});

Cache Redirection

For entities you know exist in the cache, you can skip network requests entirely by redirecting field reads to the normalized store:

const typePolicies = {
  Query: {
    fields: {
      post: {
        read(_, { args, toReference }) {
          return toReference({
            __typename: "Post",
            id: args?.id,
          });
        },
      },
    },
  },
};

Testing Strategies

Test your cache configuration by writing unit tests that verify normalization behavior, cache reads, and merge functions:

describe("InMemoryCache normalization", () => {
  it("normalizes entities across queries", () => {
    const cache = new InMemoryCache({ typePolicies });
    cache.writeQuery({
      query: GET_USER,
      data: { user: { __typename: "User", id: "1", name: "Alice" } },
    });
    const user = cache.readFragment({
      id: "User:1",
      fragment: gql`fragment U on User { name }`,
    });
    expect(user.name).toBe("Alice");
  });
});

Future Outlook

The GraphQL caching ecosystem continues to evolve with innovations like incremental delivery, which allows partial cache updates for large queries, and schema-aware caching that can automatically generate optimal normalization policies. Edge computing and CDN-level GraphQL caching are also emerging patterns that push normalized caching closer to the user.

Community Resources and Further Learning

The technology landscape evolves rapidly, making continuous learning essential for maintaining expertise. Building a systematic approach to staying current with developments in your technology stack ensures you can leverage new features and avoid deprecated patterns.

Curated Learning Pathways

Rather than consuming content randomly, create structured learning pathways aligned with your current projects and career goals. Start with official documentation and specification documents, which provide the most accurate and comprehensive information. Follow this with hands-on tutorials and workshops that reinforce concepts through practical application.

Technical blogs from framework maintainers and core team members often provide deeper insights into design decisions and upcoming features. Subscribe to the official blogs of your primary frameworks and libraries to stay ahead of breaking changes and deprecation timelines.

Contributing to Open Source

Contributing to open-source projects in your technology stack provides unparalleled learning opportunities. Start with documentation improvements and bug reports, then progress to fixing small issues tagged as "good first issue" in your favorite projects. This direct engagement with maintainers and the codebase accelerates your understanding far beyond what passive learning can achieve.

# Setting up for contribution
git clone https://github.com/project/repository.git
cd repository
git checkout -b fix/issue-description
 
# Run the project's contribution setup
npm run setup:dev
npm run test  # Ensure tests pass before making changes
 
# Make your changes, then run the full test suite
npm run test:full
npm run lint
npm run build
 
# Submit your contribution
git add -A
git commit -m "fix: description of the fix
 
Closes #1234"
git push origin fix/issue-description

Building a Technical Knowledge Base

Maintain a personal knowledge base that captures insights, solutions, and patterns you discover during your work. Tools like Obsidian, Notion, or even a simple Markdown repository can serve as an external memory that grows more valuable over time.

Organize your notes by topic rather than chronologically, and include code examples, links to relevant documentation, and explanations of why certain approaches work better than others. When you encounter a particularly insightful article or conference talk, write a summary that captures the key takeaways and how they apply to your current projects.

Follow key conferences and their published talks to stay informed about emerging patterns and best practices. Many conferences publish recorded talks on YouTube within weeks of the event, making world-class technical content freely accessible.

Join relevant Discord servers, Slack communities, and forums where practitioners discuss real-world challenges and solutions. These communities provide early warning about emerging issues and access to collective wisdom that isn't available through formal documentation.

Mentorship and Knowledge Sharing

Teaching others is one of the most effective ways to deepen your own understanding. Consider writing technical blog posts, giving talks at local meetups, or mentoring junior developers. The process of explaining concepts to others forces you to organize your knowledge and identify gaps in your understanding.

Pair programming sessions with colleagues of different experience levels create mutual learning opportunities. Senior developers gain fresh perspectives on problems they've solved the same way for years, while junior developers benefit from exposure to production-grade thinking and decision-making processes.

Conclusion

Normalized caching is the backbone of performant GraphQL applications. By storing entities once and referencing them across queries, you eliminate data duplication, enable automatic UI updates, and support offline-first experiences. The key takeaways are:

  1. Always configure typePolicies with proper keyFields for your entities
  2. Implement merge functions for paginated and list data
  3. Use read functions to create computed fields and cache redirections
  4. Persist your cache for instant startup and offline support
  5. Monitor cache performance with DevTools and implement garbage collection

Start by adding normalization policies to your most frequently accessed entities, then gradually expand to cover your entire schema. With proper caching, your GraphQL application will feel faster, use less bandwidth, and provide a better user experience overall.