Client Caching and Normalization: Apollo, urql and Relay

Advanced
13 min

Client Caching and Normalization: Apollo, urql and Relay

A GraphQL client's cache is what makes a screen update everywhere when a single object changes. After this lesson you will be able to explain how Apollo normalises results, update the cache precisely after mutations, and decide when urql or Relay is a better fit than Apollo.

How Normalization Works

InMemoryCache splits each result into flat records keyed by __typename and id, and stores the query as a set of references. A response containing { posts: [{ id: "1", title: "Hello", author: { id: "9", name: "Ada" } }] } becomes:

json
{ "ROOT_QUERY": { "posts({\"first\":10})": [{ "__ref": "Post:1" }] }, "Post:1": { "__typename": "Post", "id": "1", "title": "Hello", "author": { "__ref": "User:9" } }, "User:9": { "__typename": "User", "id": "9", "name": "Ada" } }

Because every object exists exactly once, a mutation returning { id: "9", name: "Ada L." } updates User:9, and every component that renders that user re-renders with the new name. No manual wiring is needed as long as operations select id (Apollo adds __typename automatically). Types without an id can be configured with typePolicies:

javascript
const cache = new InMemoryCache({ typePolicies: { Book: { keyFields: ["isbn"] }, Query: { fields: { posts: relayStylePagination() } }, }, });

relayStylePagination() from @apollo/client/utilities merges connection pages from fetchMore into one list.

Updating the Cache After Mutations

Field updates are automatic, but adding or removing list entries is not, because the cache cannot know which lists a new object belongs to. The update callback edits the cache directly:

javascript
const [addPost] = useMutation(ADD_POST, { update(cache, { data }) { cache.updateQuery({ query: GET_POSTS, variables: { first: 10 } }, (existing) => existing ? { posts: [data.addPost, ...existing.posts] } : existing ); }, }); const [deletePost] = useMutation(DELETE_POST, { update(cache, { data }) { cache.evict({ id: cache.identify({ __typename: "Post", id: data.deletePost.id }) }); cache.gc(); }, });

cache.updateQuery rewrites one stored query result, cache.modify edits fields of a record, cache.evict removes a record and cache.gc collects unreferenced ones. optimisticResponse applies the same update immediately with a predicted result, then replaces it when the server answers.

urql: Small Core, Opt-In Normalization

urql is a lighter client built around a pipeline of exchanges. By default it uses a document cache: results are cached per query, and any mutation that returns a __typename present in a cached query invalidates that query. That is often enough:

javascript
import { Client, Provider, cacheExchange, fetchExchange, useQuery } from "urql"; const client = new Client({ url: "http://localhost:4000/graphql", exchanges: [cacheExchange, fetchExchange] }); const [{ data, fetching, error }] = useQuery({ query: GET_POSTS, variables: { first: 10 } });

When you need Apollo-style normalization, swap cacheExchange for Graphcache from @urql/exchange-graphcache, which also supports offline persistence and optimistic updates.

Relay: Compiler-Driven and Fragment-First

Relay, Meta's client, is built for very large applications. Its compiler processes every operation at build time: each component declares a fragment for exactly the data it renders, and the parent query is assembled automatically. It requires schema conventions (globally unique id fields, a Node interface, Relay-style connections). The payoff is guaranteed data masking and minimal runtime work; the cost is a steeper setup.

| Client | Cache | Setup | Best for | |---|---|---|---| | Apollo Client | Normalized by default | Moderate | Most React apps, largest ecosystem | | urql | Document cache, Graphcache optional | Minimal | Smaller bundles, multiple frameworks | | Relay | Normalized, compiler-enforced | Heavy | Large teams with strict conventions |

Quick Quiz
Question 1 of 2

Why does a mutation returning `{ id: "9", name: "Ada L." }` update every component that shows user 9?

Key Takeaways

  • Apollo's InMemoryCache normalises results into records keyed by __typename and id; select id in every operation.
  • Field updates propagate automatically; use update with cache.updateQuery or cache.evict for list changes.
  • typePolicies handle custom keys and pagination merging such as relayStylePagination().
  • urql offers a small core with a document cache and optional Graphcache normalization.
  • Relay trades setup effort for compiler-enforced fragments and data masking.

Next lesson: TypeScript and GraphQL Code Generator — generate resolver and operation types from your schema so the compiler catches mismatches.

Client Caching and Normalization: Apollo, urql and Relay - GraphQL | CodeYourCraft | CodeYourCraft