07 · Clients: fetch, Apollo Client & the Normalized Cache¶
Everything so far has been server-side. This lesson switches seats. A GraphQL client can be as
simple as one fetch call; the reason teams adopt a client library is the normalized
cache, which stores each object once by identity and keeps every query that shows it
consistent. Understanding how that cache keys objects explains several server-side
conventions — always exposing id, returning changed objects from mutations — that otherwise
look like style preferences. All examples run in Node against a small Apollo Server, using
Apollo Client 4.3.3 without any UI framework.
The server¶
import { ApolloServer } from "@apollo/server";
import { startStandaloneServer } from "@apollo/server/standalone";
const books = [
{ id: "1", title: "Dune", stock: 3, authorId: "a1" },
{ id: "2", title: "Dune Messiah", stock: 0, authorId: "a1" },
];
const authors = [{ id: "a1", name: "Frank Herbert" }];
export const stats = { requests: 0 };
const server = new ApolloServer({
typeDefs: /* GraphQL */ `
type Query { books: [Book!]! book(id: ID!): Book }
type Mutation { restock(id: ID!, amount: Int!): Book }
type Book { id: ID! title: String! stock: Int! author: Author! }
type Author { id: ID! name: String! }
`,
resolvers: {
Query: { books: () => books, book: (_, { id }) => books.find((b) => b.id === id) },
Mutation: { restock: (_, { id, amount }) => { const b = books.find((x) => x.id === id); b.stock += amount; return b; } },
Book: { author: (b) => authors.find((a) => a.id === b.authorId) },
},
plugins: [{ async requestDidStart() { stats.requests++; } }],
});
export const { url } = await startStandaloneServer(server, { listen: { port: 4313 } });
export const stop = () => server.stop();
The plugin counts HTTP requests that reach the server, so we can see when the client used its cache instead.
A plain fetch is a complete client¶
import { ApolloClient, InMemoryCache, HttpLink, gql } from "@apollo/client";
import { url, stats, stop } from "./api07.mjs";
// --- 1. Plain fetch: no library needed
const res = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ query: "query One($id: ID!) { book(id: $id) { title } }", variables: { id: "1" } }),
});
console.log("fetch:", JSON.stringify(await res.json()));
// --- 2. Apollo Client with a normalized cache
const client = new ApolloClient({ link: new HttpLink({ uri: url }), cache: new InMemoryCache() });
stats.requests = 0;
const LIST = gql`query List { books { id title stock author { id name } } }`;
const ONE = gql`query One($id: ID!) { book(id: $id) { id title stock } }`;
await client.query({ query: LIST });
console.log("\nafter List, requests:", stats.requests);
console.log("cache keys:", Object.keys(client.cache.extract()).join(", "));
console.log("Book:1 entry:", JSON.stringify(client.cache.extract()["Book:1"]));
// Same query again: served from cache
await client.query({ query: LIST });
console.log("List again, requests:", stats.requests);
// A *different* query for data already cached: still a network request by default...
const one = await client.query({ query: ONE, variables: { id: "1" } });
console.log("One(1) (default cache-first):", JSON.stringify(one.data.book), "requests:", stats.requests);
// --- 3. Mutation results update every query that shows the same object
await client.mutate({ mutation: gql`mutation { restock(id: "2", amount: 5) { id stock } }` });
const fromCache = client.readQuery({ query: LIST });
console.log("\nafter restock (no refetch), List from cache shows Messiah stock:",
fromCache.books.find((b) => b.id === "2").stock, "| requests:", stats.requests);
// --- 4. Without an id, the mutation result can't be matched to the cached object
await client.mutate({ mutation: gql`mutation { restock(id: "1", amount: 1) { stock } }` });
console.log("restock without id -> cached Dune stock:", client.readQuery({ query: LIST }).books[0].stock,
"| server says:", (await client.query({ query: ONE, variables: { id: "1" }, fetchPolicy: "network-only" })).data.book.stock);
// --- 5. Reading a fragment straight from the cache
const author = client.readFragment({ id: "Author:a1", fragment: gql`fragment A on Author { name }` });
console.log("\nreadFragment Author:a1:", JSON.stringify(author));
// --- 6. A cache redirect: teach the cache that book(id:) means Book:<id>
const client2 = new ApolloClient({
link: new HttpLink({ uri: url }),
cache: new InMemoryCache({
typePolicies: {
Query: {
fields: {
book: { read: (_, { args, toReference }) => toReference({ __typename: "Book", id: args.id }) },
},
},
},
}),
});
await client2.query({ query: LIST });
const before = stats.requests;
const one2 = await client2.query({ query: ONE, variables: { id: "2" } });
console.log("\nwith cache redirect, One(2):", JSON.stringify(one2.data.book), "| new requests:", stats.requests - before);
await stop();
The first block needs nothing but the platform:
For scripts, server-to-server calls and simple pages, this is often all you need. Add a typed wrapper with code generation (lesson 08) and it's production-grade. What it doesn't give you is memory: every call goes to the network, and two parts of a UI showing the same book can disagree after one of them updates it.
Apollo Client's normalized cache¶
The output from the remaining sections, in order:
after List, requests: 1
cache keys: Author:a1, Book:1, Book:2, ROOT_QUERY
Book:1 entry: {"__typename":"Book","id":"1","title":"Dune","stock":3,"author":{"__ref":"Author:a1"}}
List again, requests: 1
One(1) (default cache-first): {"__typename":"Book","id":"1","title":"Dune","stock":3} requests: 2
after restock (no refetch), List from cache shows Messiah stock: 5 | requests: 3
restock without id -> cached Dune stock: 3 | server says: 4
readFragment Author:a1: {"__typename":"Author","name":"Frank Herbert"}
with cache redirect, One(2): {"__typename":"Book","id":"2","title":"Dune Messiah","stock":5} | new requests: 0
Normalization¶
After one List query, the cache doesn't hold "the result of List". It holds four entries:
Book:1, Book:2, Author:a1, and ROOT_QUERY, which records that the root field books
points at [Book:1, Book:2]. Each object is stored once, keyed by __typename:id, and
references to other objects are stored as {"__ref": "Author:a1"}. Apollo Client adds
__typename to every selection set automatically to build these keys — another reason
__typename matters (Level 2 · 02).
Cache hits are per field, not per object¶
Running List again cost no request: every field it needs is in the cache.
Running One(id: "1") did cost a request, even though Book:1 with id, title and stock
was already cached. The cache had no record of what the root field book(id: "1") returns —
only books had been queried. Section 6 fixes that with a cache redirect: a read
function on Query.book that tells the cache "book(id: X) is Book:X". With it, One(2) was
answered entirely from cache — zero new requests.
Mutations update every view¶
restock(id: "2") returned { id stock }. Because the result included __typename and id,
the cache merged the new stock into the existing Book:2 entry — and the cached List result,
which references Book:2, now shows 5 without being refetched. This is why mutation payloads
should return the changed objects with their ids (Level 2 · 08).
The next mutation omitted id. The server updated Dune's stock to 4, but the cache couldn't tell
which object the result belonged to, so the cached List still said 3 — a stale UI. Lists that
gain or lose items are similar: a cache can't know that a new review belongs in some list, so
mutations that add or remove items need refetchQueries or a manual cache.modify.
Reading fragments¶
readFragment({ id: "Author:a1", … }) reads one normalized object directly. UI components that
declare their data needs as fragments use this to render from cache.
Fetch policies¶
client.query defaults to cache-first: use the cache when it can satisfy every field, otherwise
go to the network. Others you'll use:
| Policy | Behaviour |
|---|---|
cache-first |
cache if complete, else network (default for query) |
network-only |
always network, then write to cache (used above to check the server's value) |
cache-and-network |
return cache immediately, then update from network (watched queries) |
no-cache |
network, don't write to cache |
cache-only |
never network |
Choosing a client¶
fetch/graphql-request-style thin clients — scripts, servers, simple pages.- Apollo Client — normalized cache, React integration, widely used. Version 4 reorganised the
package (React hooks live in
@apollo/client/react;rxjsis a peer dependency). - urql — smaller, document cache by default with an optional normalized cache ("Graphcache").
- Relay — compiler-driven, fragment-colocation-first, and strict about server conventions
(global
Nodeids, connections). Very efficient for large apps that follow its rules.
All of them reward the same server design: stable ids, __typename, connections for lists,
and mutations that return what they change.
How It Actually Works¶
When a result arrives, InMemoryCache walks it alongside the query document. For each object
it computes an id with dataIdFromObject — by default ${__typename}:${id} (or _id), or a
keyFields policy you configure per type. Objects with an id are written to a flat map under
that key and replaced in their parent by a reference; objects without one are stored inline in
their parent. Writes merge fields into existing entries rather than replacing them, which is
how a mutation returning only { id stock } updated one field of Book:2.
Reads walk the query document again, following references. If any requested field is missing,
the read is incomplete and a cache-first query goes to the network. Root fields with arguments
are stored under keys that include the arguments (book({"id":"1"})), which is why the cache
couldn't connect book(id: "1") to Book:1 until the read function supplied that mapping.
Watched queries (used by UI hooks) subscribe to the entries they read and re-render when a write
touches any of them.
Common mistakes¶
- Omitting
idfrom selections — objects can't be normalized and mutations can't update them. - Ids that aren't unique per type (or are reused across types without
__typename), so different objects overwrite each other. - Expecting list membership to update automatically after create/delete mutations.
- Leaving the default
cache-firston data that must be fresh (balances, stock). - Disabling
__typenameto "save bytes", which breaks normalization and union handling.
Exercise¶
- Add
restock's result to a list query in another client instance and observe that separateApolloClientinstances don't share a cache. - Add
addBook(title: String!): Book!and updateListin the cache after the mutation withcache.modify, without refetching. - Give
AuthorakeyFields: ["name"]policy and explain what changes incache.extract(). - Use
client.watchQueryonList, subscribe to it, run the restock mutation, and log each emission.