Evicting Apollo Cache Entries When CMS Content Changes
This guide extends Apollo Client GraphQL Caching with the missing half of client-side invalidation: getting a CMS publish event from the webhook into every open tab, and evicting exactly the entries that changed instead of resetting the whole store.
Server caches are easy to invalidate because the server receives the webhook. The browser does not. An editor fixes a pricing typo, the ISR page and the CDN are purged within seconds, and a customer who opened the page ten minutes ago keeps seeing the old price until they reload, because their Apollo cache has no reason to refetch.
The Problem
The obvious fixes are all expensive. pollInterval on every query multiplies CMS API usage by the number of open tabs. client.resetStore() on any change refetches every active query in every tab simultaneously, which is a self-inflicted traffic spike exactly when editors are busiest. Short cache-and-network refetches on focus only help readers who switch tabs.
What you want is targeted eviction: the server tells clients which entries changed, and each client drops those entries and refetches only the queries that referenced them. Apollo supports this directly through cache.evict and cache.gc. The missing pieces are a trustworthy event source and a transport.
How Targeted Eviction Works
cache.evict({ id }) removes one normalized entity. Any active query that referenced it now has a missing field, so Apollo marks it incomplete and refetches it according to its fetch policy. cache.gc() then removes anything that is no longer reachable from a root query, such as the old author reference of a deleted post. Evicting by id requires that you can compute the cache id from the webhook payload, which is why the identity rules in the typePolicies and keyFields guide matter here: the server must broadcast every field that appears in keyFields, including locale.
List fields need separate handling. A newly published entry does not exist in the cache yet, so evicting by id does nothing for the list that should now include it. For creates and deletes, also evict the list field itself (cache.evict({ id: "ROOT_QUERY", fieldName: "posts" })) so list queries refetch.
Implementation
The server half verifies the webhook with a timing-safe HMAC comparison, collapses bursts into one broadcast per window, and fans out over Server-Sent Events. The client half subscribes and evicts. Both halves are shown in one file for reading; in a Next.js app they live in a route handler and a client component.
// ---------- server: app/api/cms-events/route.ts (Node runtime) ----------
import { createHmac, timingSafeEqual } from "node:crypto";
interface ChangedEntry {
typename: string; // GraphQL type name, e.g. "BlogPost"
id: string; // CMS entry id
locale: string; // part of keyFields
op: "publish" | "unpublish" | "delete";
}
const clients = new Set<ReadableStreamDefaultController<Uint8Array>>();
let pending: ChangedEntry[] = [];
let timer: NodeJS.Timeout | null = null;
const enc = new TextEncoder();
function verify(raw: string, signature: string | null): boolean {
if (!signature) return false;
const expected = createHmac("sha256", process.env.CMS_WEBHOOK_SECRET ?? "").update(raw).digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(signature, "hex");
return a.length === b.length && timingSafeEqual(a, b);
}
function flush(): void {
const batch = pending;
pending = [];
timer = null;
const frame = enc.encode(`event: invalidate\ndata: ${JSON.stringify(batch)}\n\n`);
for (const c of clients) c.enqueue(frame);
}
export async function POST(req: Request): Promise<Response> {
const raw = await req.text();
if (!verify(raw, req.headers.get("x-cms-signature"))) return new Response("invalid signature", { status: 401 });
const body = JSON.parse(raw) as { type: string; id: string; locale: string; event: ChangedEntry["op"] };
pending.push({ typename: body.type, id: body.id, locale: body.locale, op: body.event });
// Collapse bursts (bulk publish, scheduled releases) into one broadcast.
if (!timer) timer = setTimeout(flush, 500);
return new Response(null, { status: 204 });
}
export async function GET(): Promise<Response> {
let self: ReadableStreamDefaultController<Uint8Array>;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
self = controller;
clients.add(controller);
controller.enqueue(enc.encode(": connected\n\n"));
},
cancel() {
clients.delete(self);
},
});
return new Response(stream, {
headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache, no-transform", Connection: "keep-alive" },
});
}
// ---------- client: components/CmsInvalidation.tsx ----------
// "use client";
// import { useApolloClient } from "@apollo/client";
// import { useEffect } from "react";
//
// export function CmsInvalidation(): null {
// const client = useApolloClient();
// useEffect(() => {
// const source = new EventSource("/api/cms-events");
// source.addEventListener("invalidate", (ev) => {
// const batch = JSON.parse((ev as MessageEvent<string>).data) as ChangedEntry[];
// for (const e of batch) {
// const id = client.cache.identify({ __typename: e.typename, sys: { id: e.id, locale: e.locale } });
// if (id) client.cache.evict({ id });
// if (e.op !== "publish") client.cache.evict({ id: "ROOT_QUERY", fieldName: "blogPostCollection" });
// }
// client.cache.gc();
// });
// return () => source.close();
// }, [client]);
// return null;
// }
The client component is kept as comments only because a server route and a "use client" module cannot share a file. Copy it into its own module unchanged. cache.identify builds the id from the same keyFields the cache uses, so the client never has to hard-code the id format.
Recovering Missed Events
SSE connections drop: laptops sleep, mobile networks switch, deploys restart the server. EventSource reconnects automatically and sends the Last-Event-ID header with the id of the last message it received, so the server can replay what the client missed. Give every broadcast an incrementing id: line and keep the last few minutes of batches in memory or in Redis. On reconnect, replay everything after the client’s id. If the id is too old to replay, send a single event: reset that tells the client to call client.refetchQueries({ include: "active" }). That is a bounded fallback, because only one tab pays for it, and only after a long disconnect.
Tabs that were in the background need no special handling. Browsers keep the EventSource open while a tab is hidden, and the eviction happens immediately. The refetch triggered by a hidden tab still costs a request, though, so for very high-traffic pages consider deferring refetches until document.visibilityState returns to visible: evict immediately, and let the query refetch when the reader comes back.
Configuration Reference
# Server
CMS_WEBHOOK_SECRET=whsec_32_random_bytes_hex # shared with the CMS webhook config
CMS_EVENTS_BATCH_MS=500 # broadcast window; 250 to 1000 is typical
# CMS webhook settings
# URL: https://www.example.com/api/cms-events
# Events: Entry publish, Entry unpublish, Entry delete
# Header: x-cms-signature = HMAC-SHA256(body, secret), hex encoded
| Parameter | Effect |
|---|---|
| Batch window | Longer windows mean fewer broadcasts and slower freshness; 500 ms is imperceptible to readers. |
| Event filter | Subscribe to publish, unpublish and delete only. Draft saves should never reach public clients. |
| Keep-alive | Send a : ping comment every 25 seconds so proxies do not close idle SSE connections. |
| Runtime | SSE fan-out from memory needs one long-lived process; on serverless, publish to a pub/sub service and let each edge instance subscribe. |
Gotchas & Edge Cases
- Missing key fields in the payload. Contentful’s webhook body contains
sys.idand per-locale fields, but not a singlelocale. Emit oneChangedEntryper locale present infields, or evicting will miss the localized entities. - Serverless fan-out. An in-memory
Setof controllers only reaches clients connected to the same instance. On Vercel or Lambda, publish batches to Redis, Ably or a similar channel and have the SSE route subscribe to it. - Replayed webhooks. CMS platforms retry on timeouts, so the same event may arrive twice. Eviction is idempotent, so duplicates only cost a refetch, but include the delivery id in the batch and dedupe server-side if the CMS provides one.
- Draft data in broadcasts. Never include field values in the broadcast, only ids. The public SSE stream is readable by anyone, and a payload that carried draft text would leak it.
- Evicting inside a render. Call
evictfrom the event listener, never during render, or React warns about updating a component while rendering another.
Verifying the Result
Open two browser windows on the same page, publish a change in the CMS, and watch the Network panel: one EventSource message arrives, followed by one GraphQL request per affected query. Neither window should refetch unaffected queries. For automated coverage, a test can post a signed payload to the route, capture the SSE frame with a test client, and replay it into a MockedProvider cache to assert the eviction.
For the server tiers that the same webhook must reach, see webhook-driven on-demand revalidation and automating static rebuilds with CMS webhooks.
Frequently Asked Questions
Why Server-Sent Events rather than WebSockets?
The traffic only flows from server to browser, and SSE gives that over plain HTTP with automatic reconnection built into EventSource. WebSockets work too, but they add a protocol upgrade, custom reconnect logic and more proxy configuration for no benefit in a one-way channel.
Does eviction cause a flash of loading state?
For queries using cache-and-network or cache-first, evicting a referenced entity makes the query incomplete, so Apollo refetches and the component may briefly report loading. Pass returnPartialData: true on those queries to keep rendering the remaining data while the refetch runs.
How do I invalidate a list when a new entry is published?
A new entry has no cache id yet, so evicting by id cannot help. Evict the list field on ROOT_QUERY for create, unpublish and delete events, which makes every query that reads that list refetch it with the new membership.