post

Apollo Client: der normalisierte Cache und optimistische Updates

Warum GraphQL nicht wie REST cachen kann und wie Apollo mit einem normalisierten Objekt-Store und optimistischen Updates trotzdem eine konsistente, schnelle UI liefert.

Wenn ich in einer Schulung zum ersten Mal den Client-Cache von Apollo aufmache, kommt fast immer dieselbe Frage: "Warum ist das so kompliziert? Bei REST habe ich die URL als Key und gut ist." Das ist ein ehrlicher Einwand, und er trifft genau den Punkt. GraphQL cacht anders, weil GraphQL anders aussieht. Und wer verstanden hat, warum die URL hier nicht mehr taugt, versteht auch, warum Apollo den Umweg über einen normalisierten Objekt-Store geht – und warum dieser Umweg am Ende weniger Arbeit macht, nicht mehr.

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 Teilnehmer regelmäßig eine halbe Stunde Debugging kostet.

Warum die URL als Cache-Key nicht mehr funktioniert

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 GraphQL bricht dieses Modell zusammen, und zwar aus einem strukturellen Grund: Es gibt nur einen einzigen Endpunkt. Jede Abfrage geht als POST /graphql, und was zurückkommt, hängt komplett vom Query-Body ab. Zwei völlig verschiedene Antworten teilen sich dieselbe URL. Damit ist die URL als Cache-Key wertlos. Man könnte auf die Idee kommen, den kompletten Query-String zu hashen und die Response darunter abzulegen – und tatsächlich funktioniert das für den simplen Fall. Aber es hilft nicht bei dem, was in echten Anwendungen ständig passiert: dieselbe Entität taucht in mehreren Queries auf.

Ein Beispiel, das ich gern nehme: 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:

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:

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:

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 in der Schulung 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.

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:

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 Faustregel, die ich Teilnehmern mitgebe: Wertänderung ist Apollos Job, Listenzugehörigkeit ist deiner.

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:

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:

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:

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

fetchPolicy: wie stark der Cache das Sagen hat

Ein Detail, nach dem in Schulungen oft gefragt wird, 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.

Ich rate dazu, den Default so lange zu lassen, 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 in fast jeder Schulung mindestens einmal zuschlägt. 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 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:

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.

Was hängen bleiben sollte

GraphQL cacht nicht schlechter als REST, sondern auf einer anderen Ebene. Weil die URL als Key ausfällt, geht Apollo eine Etage tiefer und normalisiert auf Objektebene. Jedes Objekt mit stabiler id und __typename liegt einmal im Store, Queries teilen sich diese Objekte, und Feld-Updates werden dadurch automatisch konsistent. Struktur-Änderungen an Listen bleiben unsere Aufgabe über die update-Funktion. Optimistische Updates machen die UI schnell und rollen bei Fehlern von selbst zurück, solange das optimisticResponse die richtige Form samt __typename hat. Und der eine Fehler, der die meiste Zeit frisst, ist banal und leise zugleich: ein Objekt ohne stabile Identität, das still nicht normalisiert wird. Wer beim Schema-Design von Anfang an auf konsistente Identitäten achtet und im Zweifel keyFields setzt, spart sich genau dieses Debugging.

Weiterführende Quellen

Kommentare

Kommentar schreiben