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:
- Split Response Objects: The client walks the JSON response tree and extracts every object containing a typename and an identifier (such as
idoruuid). - Generate Unique Identifiers: The client combines the object’s
__typenameand its identifier to generate a globally unique key, such asUser:u-42. - Flatten Nested References: Nested objects are replaced with reference pointers, such as
{"__ref": "Post:p-101"}. - 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 Metric | Urql Document Cache | Apollo Client InMemoryCache | Urql Graphcache Exchange |
|---|---|---|---|
| Library Bundle Weight (Minified) | 4.8 KB | 31.2 KB | 11.4 KB |
| Cache Lookup Latency (1k nodes) | 1.8ms | 9.4ms | 5.2ms |
| Average Memory Usage (1k records) | 0.8 MB | 6.8 MB | 3.1 MB |
| Automatic Cache Updating | No | Yes | Yes |
| Optimistic Updates API | No | Declarative | Procedural |
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 theidfield. 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
idoruuidfield 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 (emailandphone). 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.