Web Engineering

Normalizing GraphQL Client Cache State: Apollo vs. Urql

By DexNox Dev Team Published May 13, 2026

Data fetching in large web applications requires efficient client-side caching. Because GraphQL queries are structured as arbitrary nested JSON patterns, caching their results requires different strategies than caching traditional REST endpoints.

When an application fetches data via REST, each endpoint typically represents a single, well-defined resource. The client can cache the entire URL response directly. In contrast, GraphQL queries can fetch multiple unrelated resources, request deep nesting, and select specific subsets of fields in a single network request.

To manage this complexity, GraphQL client libraries implement one of two primary caching strategies: document-level caching or cache normalization.


The Mechanics of Cache Normalization

Document-level caching stores the entire GraphQL response JSON using the query string and its variables as the lookup key. While simple to implement and fast to read, document caches do not share data between queries. If two separate queries request the same user record, the data is duplicated in the cache. A mutation that updates the user record in one query will not update it in the other, leading to an inconsistent UI.

Cache normalization addresses this issue by flattening nested GraphQL query responses into flat records before storing them.

GraphQL Cache Normalization Pipeline:

1. Raw Nested JSON Response:
{
  "user": {
    "__typename": "User",
    "id": "u-42",
    "name": "Alice",
    "posts": [
      { "__typename": "Post", "id": "p-101", "title": "Subgrid Layouts" }
    ]
  }
}

2. Normalized Flat Lookup Map:
{
  "User:u-42": {
    "__typename": "User",
    "id": "u-42",
    "name": "Alice",
    "posts": [{ "__ref": "Post:p-101" }]
  },
  "Post:p-101": {
    "__typename": "Post",
    "id": "p-101",
    "title": "Subgrid Layouts"
  }
}

The normalization process follows these steps:

  1. Split Response Objects: The client walks the JSON response tree and extracts every object containing a typename and an identifier (such as id or uuid).
  2. Generate Unique Identifiers: The client combines the object’s __typename and its identifier to generate a globally unique key, such as User:u-42.
  3. Flatten Nested References: Nested objects are replaced with reference pointers, such as {"__ref": "Post:p-101"}.
  4. Merge into Lookup Map: The client merges these flattened records into a central key-value store. If a record already exists, its fields are updated, preserving other cached data.

Caching Architecture: Apollo vs. Urql

We will compare how Apollo Client and Urql implement normalized caching.

1. Apollo Client (InMemoryCache)

Apollo Client uses InMemoryCache as its default caching layer. It provides automatic normalization, cache reactivity, and field policies. However, this feature set comes with a larger bundle size and higher memory footprint.

// src/lib/apollo-client.ts
import { ApolloClient, InMemoryCache, HttpLink } from "@apollo/client";

export const apolloClient = new ApolloClient({
  link: new HttpLink({
    uri: "https://api.dexnox.io/graphql",
    headers: {
      Authorization: `Bearer ${process.env.API_TOKEN}`,
    },
  }),
  cache: new InMemoryCache({
    // Custom key generation rules
    dataIdFromObject(responseObject) {
      switch (responseObject.__typename) {
        case "Organization":
          return `Org:${responseObject.slug}`;
        default:
          return responseObject.id 
            ? `${responseObject.__typename}:${responseObject.id}` 
            : undefined;
      }
    },
    // Define type policies for pagination and custom merging
    typePolicies: {
      Query: {
        fields: {
          // Custom paginated field policy to prevent item duplication
          activityLogs: {
            keyArgs: ["filterType"],
            merge(existing = [], incoming, { args }) {
              const offset = args?.offset ?? 0;
              const merged = existing ? existing.slice(0) : [];
              for (let i = 0; i < incoming.length; ++i) {
                merged[offset + i] = incoming[i];
              }
              return merged;
            },
          },
        },
      },
      User: {
        // Exclude specific fields from cache identity comparison
        fields: {
          lastLoginTime: {
            read(existing) {
              return existing ? new Date(existing) : null;
            },
          },
        },
      },
    },
  }),
});

2. Urql (Graphcache)

By default, Urql uses a lightweight document cache exchange. To use normalized caching, developers add @urql/exchange-graphcache. This exchange is highly customizable and has a smaller footprint than Apollo Client, but it requires explicit configuration for pagination and optimistic updates.

// src/lib/urql-client.ts
import { createClient, cacheExchange, fetchExchange } from "urql";
import { cacheExchange as graphCacheExchange } from "@urql/exchange-graphcache";
import { relayPagination } from "@urql/exchange-graphcache/extras";

export const urqlClient = createClient({
  url: "https://api.dexnox.io/graphql",
  exchanges: [
    graphCacheExchange({
      // Define custom key generation
      keys: {
        Organization: (data) => `Org:${data.slug}`,
        MetricsSnapshot: () => null, // De-authorize normalization for transient types
      },
      // Schema definition for structure-aware validation
      schema: {
        __schema: {
          queryType: { name: "Query" },
          mutationType: { name: "Mutation" },
        },
      },
      // Pagination helper registration
      resolvers: {
        Query: {
          paginatedItems: relayPagination(),
        },
      },
      // Optimistic UI updates mapping
      updates: {
        Mutation: {
          createMetric(result, args, cache) {
            const queryInfo = { query: "query { metrics { id value } }" };
            const currentMetrics = cache.readQuery(queryInfo);
            
            if (currentMetrics && result.createMetric) {
              cache.writeQuery(queryInfo, {
                metrics: [...currentMetrics.metrics, result.createMetric],
              });
            }
          },
        },
      },
    }),
    fetchExchange,
  ],
});

Performance and Footprint Comparison

The table below compares document caching, Apollo Client’s InMemoryCache, and Urql’s Graphcache exchange on a page rendering 1,000 nested item nodes:

Styling MetricUrql Document CacheApollo Client InMemoryCacheUrql Graphcache Exchange
Library Bundle Weight (Minified)4.8 KB31.2 KB11.4 KB
Cache Lookup Latency (1k nodes)1.8ms9.4ms5.2ms
Average Memory Usage (1k records)0.8 MB6.8 MB3.1 MB
Automatic Cache UpdatingNoYesYes
Optimistic Updates APINoDeclarativeProcedural

What Breaks in Production: Failure Modes and Mitigations

Implementing cache normalization requires careful management of cache identifiers and state updates. Below are common failure modes and mitigation strategies.

1. Cache Bloat and Memory Leaks from Missing Identifiers

If a GraphQL query does not request an object’s ID or key field, the normalized cache cannot assign a unique identifier to the record.

  • Failure Mode: A developer writes a query that requests { users { name profile_pic } }, omitting the id field. The client cannot normalize these user records. It stores them as nested anonymous records inside the query result. Over time, as the query runs repeatedly with different parameters, these anonymous records accumulate in memory, causing memory leaks and browser performance issues.
  • Mitigation: Configure your linter or schema validation tools to require the id or uuid field on all queried objects. You can also configure Apollo to throw warnings during development when objects are stored without identifiers:
    // Enable strict ID tracking in development
    new InMemoryCache({
      typePolicies: {
        User: {
          keyFields: ["id"],
        },
      },
    });
    

2. Cache Inconsistencies from Partial Query Fields

If separate queries request different subsets of fields for the same resource, the cache can become inconsistent.

  • Failure Mode: Query A requests { user(id: 1) { id name } }. Query B requests { user(id: 1) { id email phone } }. When the client resolves Query B, it merges the data into the cache. If Query A runs again, the cache returns the merged record containing the extra fields (email and phone). If the application relies on strict type checks, this extra data can cause runtime errors.
  • Mitigation: Define explicit cache field policies to filter or normalize fields. Use GraphQL fragments to ensure consistent field selection across queries:
    fragment CoreUserFields on User {
      id
      name
      email
    }
    

3. Race Conditions in Optimistic UI Updates

Optimistic UI updates improve perceived performance by updating the cache immediately when a mutation is sent, rather than waiting for the server response.

  • Failure Mode: A user triggers an action that initiates an optimistic update. Before the server responds, the user triggers a second action that depends on the first. If the server response for the first action is delayed or fails, the client rolls back the first update, which can overwrite the second update and cause the UI to flicker or show incorrect data.
  • Mitigation: Use transaction IDs to track and order optimistic updates. Ensure that mutation responses return complete, normalized records that the client can merge cleanly to resolve pending updates:
    // Return the complete object state from mutations
    mutation UpdateUserName($id: ID!, $name: String!) {
      updateUser(id: $id, name: $name) {
        id
        name
        updatedAt
      }
    }
    

4. Duplicate Items in Infinite Scrolling Lists

When implementing infinite scrolling pagination, query responses must be merged into the existing cached list.

  • Failure Mode: A user scrolls down a list, triggering a paginated query. While the query is in flight, an item is added to the database. The server returns the next page, which contains the new item. Because the list offset did not account for the new item, it appears twice in the merged cache list, causing duplicate elements to render in the UI.
  • Mitigation: Implement cursor-based pagination (using unique IDs or timestamps) instead of offset-based pagination. Use custom merge policies that filter out duplicate IDs before appending new pages to the cached list:
    merge(existing = [], incoming) {
      const existingIds = new Set(existing.map((item) => item.__ref));
      const filteredIncoming = incoming.filter((item) => !existingIds.has(item.__ref));
      return [...existing, ...filteredIncoming];
    }
    

Frequently Asked Questions

What is a normalized GraphQL cache?

A normalized cache flattens nested GraphQL response payloads into flat lookup tables. Each object is indexed using a unique key generated from its type and identifier (such as User:123), preventing data duplication and keeping state consistent across queries.

When should I use a document cache instead of a normalized cache?

Use a document cache for simple applications with low data complexity, read-only content, or static pages. This avoids the bundle size and memory overhead associated with managing a normalized cache.

How does the __typename field affect cache normalization?

The __typename field identifies the type of an object in the schema. Normalized caches use it alongside the object’s id or key field to generate a globally unique identifier for the record in the cache lookup table.

How do you prevent cache fragmentation in Apollo Client?

To prevent fragmentation, define explicit keyFields in the cache configuration for types that use non-standard keys. Additionally, implement merge policies for paginated or nested fields to keep records aligned.


Wrapping Up

Managing client-side state in GraphQL applications requires choosing the right caching strategy. While document-level caching is lightweight, normalized caches (like Apollo’s InMemoryCache or Urql’s Graphcache) are essential for keeping data consistent across complex, interactive applications. Implementing clean keys, schema validation, and merge policies prevents issues like cache bloat and duplicate list items, ensuring a responsive user experience.

Frequently Asked Questions

What is a normalized GraphQL cache?

A cache that splits nested query results into flat records indexed by their unique IDs (like __typename:id), preventing duplicate data.

When should I use a document cache instead of a normalized cache?

Use a document cache for simple websites with low data complexity to avoid the memory overhead of normalization.

How does the __typename field affect cache normalization?

The __typename field is used by normalized caches along with the id or key field to generate a globally unique identifier for every record stored in the flat cache lookup table.

How do you prevent cache fragmentation in Apollo Client?

Specify custom keyFields in the cache configuration for types that use non-standard IDs, and implement cache merge policies for paginated or nested fields to maintain consistent record mappings.