# Apollo Client: der normalisierte Cache und optimistische Updates

URL: https://www.mikebild.dev/de/blog/graphql-client-caching-mit-apollo/

Wer bei GraphQL ganze Antworten unter einem gehashten Query-String ablegt, hat keinen Cache gebaut, sondern eine Sammlung von Kopien. Ändert ein Nutzer seinen Namen, wissen diese Kopien nichts voneinander: Die Listenansicht zeigt weiter den alten Wert, die Detailansicht schon den neuen, und niemand merkt es, bis jemand hin und her klickt. Der Schlüssel, unter dem eine Antwort abgelegt wird, ist bei GraphQL das eigentliche Problem – und die Lösung liegt eine Ebene tiefer als die Antwort selbst.

In diesem Beitrag geht es ausschließlich um den Cache im Browser, also den Client. Es gibt daneben serverseitiges Caching – Request-Batching und Deduplizierung mit DataLoader zum Beispiel – aber das ist eine völlig andere Baustelle mit anderen Zielen. Hier reden wir über den `InMemoryCache` von Apollo Client 3, über Normalisierung, über optimistische Updates und über den einen Fallstrick, der schnell mal eine halbe Stunde Debugging kostet.

## Warum die URL allein als Cache-Key nicht reicht

Bei einer klassischen REST-API ist Caching konzeptionell einfach. Jede Ressource hat ihre eigene URL, und ein `GET /users/1` liefert immer denselben Ausschnitt. Der Browser, ein CDN oder eine HTTP-Cache-Schicht kann die Antwort unter genau dieser URL ablegen und beim nächsten Aufruf zurückgeben. Der Key ist die Adresse.

Bei vielen GraphQL-APIs teilen sich unterschiedliche Abfragen einen Endpunkt, und häufig laufen sie als `POST /graphql`. Klassisches HTTP-Caching ist damit nicht ausgeschlossen: GET-Anfragen, der Query-Body als Teil des Cache-Keys oder persistierte Queries können Antworten cachebar machen. Für den Zustand einer interaktiven Anwendung bleibt ein reiner Response-Cache aber unhandlich, sobald dieselbe Entität in mehreren Queries auftaucht und gezielt aktualisiert werden soll.

Der Fall, an dem das sichtbar wird: Eine Listenansicht lädt alle Nutzer, eine Detailansicht lädt einen einzelnen Nutzer. Beide zeigen den Namen von `User:1`. Ändert dieser Nutzer seinen Namen, müssten bei einem Response-basierten Cache beide Einträge separat invalidiert werden – und der Cache müsste überhaupt erst wissen, dass in beiden Responses dasselbe Objekt steckt. Genau das leistet ein reiner Response-Cache nicht.

## Normalisierung: ein Objekt, einmal, mit stabiler Identität

Apollos Antwort darauf ist Normalisierung. Statt ganze Antworten abzulegen, zerlegt der `InMemoryCache` jede Response in ihre einzelnen Objekte und speichert jedes Objekt genau einmal – flach, unter einer stabilen Cache-ID. Diese ID bildet Apollo per Default aus zwei Feldern: dem `__typename` und dem Primärschlüssel des Objekts. Das Ergebnis sieht aus wie `User:1` oder `Product:42`.

Als Schlüsselfeld nimmt Apollo standardmäßig `id`, und falls das nicht existiert, `_id`. Fehlen beide und ist nichts anderes konfiguriert, passiert etwas Wichtiges, auf das ich weiter unten zurückkomme: Das Objekt wird nicht normalisiert, sondern inline im Elternobjekt eingebettet.

Der Effekt der Normalisierung lässt sich am besten an dem eben genannten Fall zeigen. Zwei Queries, ein geteiltes Objekt:

```mermaid
graph TD
  QA["Query GET_USERS<br/>(Listenansicht)"] --> N["User:1<br/>{ name, email }"]
  QB["Query GET_USER<br/>(Detailansicht)"] --> N
  N --> S[("Normalisierter<br/>InMemoryCache")]
```

Beide Queries verweisen auf denselben Knoten `User:1`. Im Store liegt der Nutzer genau einmal. Ändert sich sein Name, ändert er sich für alle Views, die auf diesen Knoten zeigen. Das ist der Kern, um den sich alles andere dreht.

Damit das funktioniert, muss jede Query die Identitätsfelder mitselektieren. Apollo fügt `__typename` automatisch hinzu, aber `id` müssen wir selbst anfragen:

```graphql
query GET_USERS {
  users {
    id
    name
    email
  }
}

query GET_USER($id: ID!) {
  user(id: $id) {
    id
    name
    email
  }
}
```

Der Cache-Aufbau selbst ist unspektakulär. Man instanziiert einen `InMemoryCache` und übergibt ihn an den `ApolloClient`:

```ts
import { ApolloClient, InMemoryCache, HttpLink } from "@apollo/client";

const client = new ApolloClient({
  link: new HttpLink({ uri: "/graphql" }),
  cache: new InMemoryCache(),
});
```

Was Normalisierung konkret bringt, fasse ich so zusammen:

- Jede Entität liegt genau einmal im Speicher, egal über wie viele Queries sie geladen wurde.
- Ein Feld-Update an einer Stelle propagiert automatisch in jede View, die dasselbe Objekt zeigt – ohne manuelle Invalidierung.
- Der Cache wird kleiner und konsistenter, weil doppelte Daten zu einer Referenz zusammenfallen.
- Die UI kann Daten sofort aus dem Cache rendern und im Hintergrund nachladen, ohne dass verschiedene Ansichten auseinanderlaufen.

## Feld-Updates passieren automatisch, Struktur-Updates nicht

Hier trennt sich in der Praxis, was Apollo für uns erledigt und wo wir selbst ran müssen. Der Unterschied ist wichtig, weil er die häufigste Verwirrung auflöst.

Ändere ich den Wert eines Feldes an einem bereits gecachten Objekt, erledigt Apollo das automatisch. Eine Mutation, die `User:1` mit neuem Namen zurückgibt, überschreibt das Feld im Store, und jede View aktualisiert sich. Ich muss dafür nichts schreiben – vorausgesetzt, die Mutation liefert `id` und `__typename` mit zurück, damit Apollo weiß, welchen Eintrag es treffen soll.

```ts
const UPDATE_USER = gql`
  mutation UpdateUser($id: ID!, $name: String!) {
    updateUser(id: $id, name: $name) {
      id
      name
    }
  }
`;

// Apollo matches the response onto User:1 and updates
// every view that shows this user - no update function needed.
const [updateUser] = useMutation(UPDATE_USER);
```

Anders sieht es aus, wenn sich die Struktur ändert, also ein Element zu einer Liste hinzukommt oder aus ihr verschwindet. Lege ich per Mutation einen neuen Nutzer an, normalisiert Apollo das neue Objekt zwar brav in den Store – aber es weiß nicht, in welche Liste dieser Nutzer gehört. Die Zugehörigkeit zu einer bestimmten `GET_USERS`-Query ist keine Eigenschaft des Objekts, und deshalb kann Apollo sie nicht erraten. Ergebnis: Das neue Objekt existiert im Cache, taucht in der Listenansicht aber nicht auf, bis man neu lädt.

Für diese Fälle gibt es die `update`-Funktion an der Mutation. Sie bekommt den Cache und die Response und schreibt die Liste explizit fort:

```ts
const ADD_USER = gql`
  mutation AddUser($name: String!) {
    addUser(name: $name) {
      id
      name
    }
  }
`;

const [addUser] = useMutation(ADD_USER, {
  update(cache, { data }) {
    const newUser = data.addUser;
    cache.modify({
      fields: {
        users(existingRefs = [], { toReference }) {
          return [...existingRefs, toReference(newUser)];
        },
      },
    });
  },
});
```

Die Trennlinie verläuft also genau hier: Wertänderungen erledigt Apollo, die Listenzugehörigkeit erledigen wir.

## Optimistische Updates: die UI reagiert, bevor der Server antwortet

Das zweite große Thema ist gefühlte Geschwindigkeit. Jede Mutation läuft übers Netz, und selbst 150 Millisekunden fühlen sich bei einem Toggle-Button träge an. Optimistische Updates lösen das, indem sie den erwarteten Endzustand sofort in den Cache schreiben – bevor der Server geantwortet hat. Die UI reagiert ohne Latenz. Kommt die echte Antwort, ersetzt sie die optimistische Schicht. Schlägt die Mutation fehl, verwirft Apollo die optimistische Schicht automatisch und stellt den vorherigen Zustand wieder her. Das ist der entscheidende Punkt: Man muss den Rollback nicht selbst schreiben.

Der Ablauf lässt sich als Sequenz gut zeigen:

```mermaid
sequenceDiagram
  participant UI
  participant Cache
  participant Server
  UI->>Cache: optimistisches Ergebnis schreiben
  Cache-->>UI: sofort neu rendern
  UI->>Server: Mutation senden
  alt Erfolg
    Server-->>Cache: echtes Ergebnis schreiben
  else Fehler
    Server-->>Cache: optimistische Schicht verwerfen
    Cache-->>UI: vorigen Zustand wiederherstellen
  end
```

In Code sieht das so aus. Ein Todo lässt sich abhaken, und wir schreiben den `completed`-Zustand optimistisch:

```ts
const TOGGLE_TODO = gql`
  mutation ToggleTodo($id: ID!, $completed: Boolean!) {
    toggleTodo(id: $id, completed: $completed) {
      id
      completed
    }
  }
`;

const [toggleTodo] = useMutation(TOGGLE_TODO, {
  optimisticResponse: {
    toggleTodo: {
      __typename: "Todo",
      id: todo.id,
      completed: !todo.completed,
    },
  },
});
```

Zwei Dinge sind hier nicht verhandelbar. Erstens muss das `optimisticResponse` dieselbe Form haben wie die echte Server-Antwort – dieselben Felder, dieselbe Verschachtelung. Zweitens muss es `__typename` und die Schlüsselfelder enthalten. Ohne `__typename` und `id` kann Apollo den optimistischen Eintrag nicht auf `Todo:...` normalisieren, der Merge trifft das falsche Ziel und der Rollback verhält sich unerwartet. Das ist einer der Fehler, die still passieren: Es sieht fast richtig aus, aber die UI flackert oder springt zurück.

Wer will, kann `optimisticResponse` auch als Funktion schreiben, die die Variablen bekommt – praktisch, wenn der optimistische Zustand aus den Mutation-Argumenten abgeleitet wird:

```ts
optimisticResponse: (vars) => ({
  toggleTodo: {
    __typename: "Todo",
    id: vars.id,
    completed: vars.completed,
  },
}),
```

## fetchPolicy: wie stark der Cache das Sagen hat

Ein Detail, das dabei leicht untergeht, ist die `fetchPolicy` einer Query. Sie steuert, in welchem Verhältnis Cache und Netzwerk stehen. Der Default ist `cache-first`: Apollo schaut erst in den Cache, und nur wenn die Daten fehlen, geht es ans Netz. Das ist meistens genau richtig und der Grund, warum die zweite Detailansicht eines schon geladenen Nutzers ohne Request auskommt.

Die wichtigsten Werte, die man kennen sollte:

- `cache-first` – Default; Cache zuerst, Netzwerk nur bei fehlenden Daten.
- `cache-and-network` – rendert sofort aus dem Cache und lädt parallel frisch nach; gut für Listen, die aktuell sein sollen, aber sofort etwas zeigen dürfen.
- `network-only` – ignoriert den Cache beim Lesen, schreibt das Ergebnis aber in den Cache.
- `no-cache` – liest und schreibt gar nicht am Cache; die Daten existieren nur innerhalb dieser Query.
- `cache-only` – nur Cache, nie Netzwerk; wirft, wenn nichts da ist.

Den Default lasse ich so lange stehen, bis es einen konkreten Grund gibt, ihn zu ändern. `cache-and-network` ist der häufigste bewusste Griff, weil es das Beste aus beidem verbindet: sofort sichtbar und trotzdem frisch.

## Der Fallstrick: Objekte ohne stabile Identität

Jetzt zu dem Problem, das ich weiter oben angekündigt habe – und das lange unbemerkt bleibt, weil es sich nirgends meldet. Normalisierung funktioniert nur, wenn Apollo eine stabile Identität bilden kann. Fehlt `id` und `_id`, und ist kein alternatives Schlüsselfeld konfiguriert, normalisiert Apollo das Objekt nicht. Es bettet es stattdessen still in das Elternobjekt ein. Kein Fehler, keine Warnung in der Konsole – die App läuft, aber der Cache tut nicht, was man erwartet.

Die Symptome kommen später und wirken zusammenhanglos: Dieselbe Entität liegt mehrfach im Cache, ein Update trifft nur eine Kopie, und zwei Views zeigen unterschiedliche Werte für dasselbe Ding. Besonders gern passiert das bei berechneten oder aggregierten Feldern, bei Wrapper-Typen aus der Paginierung oder bei Objekten, deren id man in der Query schlicht vergessen hat mitzuselektieren. Wer mit Cursor-basierter Blätterung arbeitet, kennt solche Wrapper-Typen aus der Praxis – ich habe das Thema in [GraphQL-Paginierung](https://www.mikebild.dev/de/blog/graphql-paginierung-und-connections/) ausführlicher behandelt.

Es gibt zwei saubere Lösungen. Die einfachste: das Identitätsfeld einfach mitanfragen. Oft ist die id da, sie stand nur nicht in der Selektion. Wenn der Typ tatsächlich keinen `id`-Schlüssel hat, aber ein anderes eindeutiges Feld – eine Artikelnummer etwa – dann sagt man Apollo das über `typePolicies` und `keyFields`:

```ts
const cache = new InMemoryCache({
  typePolicies: {
    Product: {
      keyFields: ["upc"],
    },
    Book: {
      keyFields: ["isbn"],
    },
  },
});
```

`keyFields` kann mehr als nur ein Ersatzfeld sein:

- `keyFields: ["upc"]` – ein alternatives Schlüsselfeld statt `id`.
- `keyFields: ["name", "email"]` – ein zusammengesetzter Schlüssel aus mehreren Feldern.
- `keyFields: []` – ein Singleton, von dem es genau einen Eintrag pro Typ gibt (etwa ein globaler `Settings`-Typ).
- `keyFields: false` – Normalisierung für diesen Typ bewusst abschalten, wenn Einbettung gewünscht ist.

Am Rande: In Apollo Client 2 hat man dafür `dataIdFromObject` benutzt. In Version 3 existiert das noch zur Migrationserleichterung, aber es ist der alte Weg. Für neuen Code sind `typePolicies` mit `keyFields` die richtige Wahl, weil sie pro Typ präzise und lokal konfigurierbar sind statt global über eine einzige Funktion.

Ein verwandter Fall, in dem Identität auf dem Server entscheidet, ist die Autorisierung: Wer welches Objekt überhaupt sehen darf, gehört nicht in den Client-Cache, sondern in den Resolver-Context. Wie ich das dort verankere, steht in [GraphQL-Auth im Context](https://www.mikebild.dev/de/blog/graphql-auth-im-context/).

## Was hängen bleiben sollte

GraphQL kann HTTP-Antworten cachen; Apollo löst im Browser ein anderes Problem. Der Client normalisiert Entitäten auf Objektebene, damit mehrere Queries dieselbe Instanz teilen und gezielte Feld-Updates konsistent bleiben. Strukturänderungen an Listen bleiben unsere Aufgabe über die `update`-Funktion. Optimistische Updates reagieren sofort und rollen bei Fehlern zurück, solange das `optimisticResponse` die richtige Form samt `__typename` hat. Der häufigste leise Fehler bleibt ein Objekt ohne stabile Identität, das nicht normalisiert wird. Konsistente IDs und bewusst gesetzte `keyFields` sparen hier viel Debugging.

## Weiterführende Quellen

- Cache configuration – cache IDs und keyFields: https://www.apollographql.com/docs/react/caching/cache-configuration
- Cache normalization (Konzept): https://www.apollographql.com/docs/react/caching/overview
- Optimistic mutation results: https://www.apollographql.com/docs/react/performance/optimistic-ui
- Mutations und die update-Funktion: https://www.apollographql.com/docs/react/data/mutations
- fetchPolicy und Zusammenspiel mit dem Cache: https://www.apollographql.com/docs/react/data/queries#setting-a-fetch-policy
