TanStack Query Stale-Data Bug: Postmortem nach Server-Funktion-Invalidierung

Server-Funktion-Mutation verwendete die falsche Query-Key-Form beim Aufruf von queryClient.invalidateQueries, sodass 12 % der Admin-Nutzer 4 Stunden lang veraltete MRR-Daten sahen.

Huifer
Huifer
25. Juli 20268 min read
Auch verfügbar auf:English · 中文

TL;DR: Eine Server-Funktion-Mutation verwendete die falsche Query-Key-Form beim Aufruf von invalidateQueries, sodass 12 % der Admin-Nutzer 4 Stunden lang veraltete MRR-Daten sahen. Ursache: string-basierte Query-Keys ohne Factory, was zu einer Scope-Kollision zwischen User- und Admin-Scope-Abfragen führte. Lösung: Typed Query-Key Factory + E2E-Test, der bei veralteten Daten fehlschlägt. Query-Keys sind ein Vertrag; behandeln Sie sie wie ein Datenbank-Schema.


Geschrieben von Huifer, Solo-Entwickler und Maintainer von TanStack Ship. Ich habe diesen Bug beim Deployment des Credit-System-Features im März 2026 eingeführt. Die Suche dauerte 6 Stunden, weil das Symptom (veralteter MRR) wie ein Datenbank-Caching-Problem aussah. Die eigentliche Lösung waren 4 Zeilen; der Test, der das Problem nun verhindert, hat 40 Zeilen.


Hintergrund

Das Admin-MRR-Dashboard in TanStack Ship nutzt TanStack Query, um Abonnement-Umsatzdaten abzurufen. Die Abfrage war bereichsbezogen: Admins sahen eine globale MRR-Zahl; reguläre Nutzer sahen ihren eigenen Subscription-MRR.

TanStack Query trennt das Management von Query-Keys von den Query-Daten. Die Keys sind Arrays — [scope, ...args]. invalidateQueries filtert Keys nach Präfix. Wenn Sie das falsche Präfix übergeben, invalidieren Sie die falschen Abfragen. Oder gar keine.

Am 14. März 2026 habe ich das Credit-System-Feature deployed — eine neue Server-Funktion, die Subscription-Credit-Salden änderte. Die Mutation war korrekt. Der invalidateQueries-Aufruf war es nicht.

Incident

Um 14:23 UTC am 14. März 2026 löste ein Finanz-Alert aus: „MRR ist in der letzten Stunde um 12 % gesunken." Der Alert-Schwellenwert war auf 10 % gesetzt. Mein erster Gedanke: Ein Kunde hat gekündigt. Mein zweiter Gedanke: Niemand hat gekündigt.

Der MRR-Wert im Admin-Dashboard zeigte einen veralteten Wert aus der Zeit vor den Credit-System-Änderungen. Das Credit-System hatte die Datenbank korrekt aktualisiert. Der Query-Cache war nicht invalidiert worden.

Konkret:

  • Die credit-system-Mutation rief queryClient.invalidateQueries({ queryKey: ['subscriptions'] }) auf.
  • Die Admin-MRR-Abfrage nutzte ['admin', 'mrr', 'global'].
  • Die Subscription-Liste-Abfrage nutzte ['subscriptions', 'list'].
  • Keiner dieser Keys hatte ein gemeinsames Präfix. invalidateQueries({ queryKey: ['subscriptions'] }) berührte ['admin', 'mrr', 'global'] nicht.

Das Ergebnis: 12 % der Admin-Nutzer sahen 4 Stunden und 17 Minuten lang einen veralteten MRR-Wert. Der korrekte Wert befand sich in der Datenbank; der Query-Cache lieferte den alten Wert.

Investigation

Ich habe 6 Stunden damit verbracht. Die anfänglichen Symptome sahen nach einem Datenbank-Caching-Problem aus — falsche Daten, korrekter Datenbankzustand. Ich habe zuerst die Datenbank-Lesezugriffe instrumentiert, nichts gefunden und mich dann dem Query-Cache zugewandt.

Der Durchbruch kam von den TanStack Query DevTools: Die Admin-MRR-Abfrage zeigte einen staleAt-Zeitstempel von 14:11 UTC. Die Credit-System-Mutation lief um 14:23 UTC. Die Abfrage war zeitlich invalidiert worden, nicht durch die Mutation.

Bei der Betrachtung des Mutationscodes:

typescript
// Credit-system Server Function — der Bug war hier
export const applyCredits = createServerFn({ method: 'POST' })
  .validator(z.object({ userId: z.string(), amount: z.number() }))
  .handler(async ({ data, context }) => {
    const result = await applyCreditBalance(data.userId, data.amount)
    // Bug: Invalide ['subscriptions'] berührt nicht den Admin-MRR
    context.queryClient.invalidateQueries({ queryKey: ['subscriptions'] })
    return result
  })

Die Admin-MRR-Abfrage nutzte ['admin', 'mrr', 'global']. Keine Invalidierung berührte jemals diesen Key. Der Bug war eine Key-Scope-Kollision: Die Mutation invalidiierte einen anderen Scope als den, aus dem das Admin-Dashboard las.

Root Cause

Das System war anfällig, weil Query-Keys als Strings und Arrays ohne Factory verwendet wurden. Es gab keine erzwungene Konvention dafür, wie Query-Keys strukturiert waren. Jeder Entwickler (in diesem Fall ich) verwendete ad-hoc Key-Namen.

Dies ist ein häufiges TanStack-Query-Fehlermuster. Query-Keys sind implizit bereichsbezogen — wenn Sie einen Key invaliden, invalidiert das nur Abfragen, die ein Präfix teilen. Wenn Sie Keys ad-hoc aufbauen, werden Sie irgendwann den falschen Scope invaliden oder den beabsichtigten Scope verfehlen.

Die konkrete Fehlerkette:

  1. Die Admin-MRR-Abfrage nutzte ['admin', 'mrr', 'global'].
  2. Die Credit-Mutation verwendete ['subscriptions'] für ihre Invalidierung.
  3. Diese Keys teilen kein Präfix. Die Mutation invalidiierte die Admin-Abfrage nicht.
  4. Die Abfrage blieb für das gesamte staleTime-Fenster veraltet: 4 Stunden.

Das sekundäre Versagen: Es gab keinen E2E-Test, der ein Stale-Data-Szenario nach einer Mutation erkennen konnte.

Fix

Zwei Änderungen, gemeinsam eingecheckt am 14. März 2026:

1. Typed Query-Key Factory

Query-Keys werden nun durch eine typisierte Factory erstellt. Die Factory erzwingt Namenskonventionen und bietet typsichere Key-Generierung. Jede Abfrage und jede Invalidierung läuft über die Factory.

typescript
// src/lib/query-keys.ts — die typisierte Factory
export const queryKeys = {
  // User-scoped Abfragen
  subscriptions: {
    all: ['subscriptions'] as const,
    list: (userId: string) => ['subscriptions', 'list', userId] as const,
    detail: (userId: string, subId: string) =>
      ['subscriptions', 'detail', userId, subId] as const,
  },

  // Admin-scoped Abfragen
  admin: {
    all: ['admin'] as const,
    mrr: {
      global: () => ['admin', 'mrr', 'global'] as const,
      byPlan: (planId: string) => ['admin', 'mrr', 'byPlan', planId] as const,
    },
  },
} as const

Jede Abfrage nutzt die Factory. Jeder invalidateQueries-Aufruf nutzt die Factory. Die Factory erzwingt eine Namenskonvention: Admin-Keys liegen unter ['admin', ...], User-Keys unter ['subscriptions', ...]. Sie können nicht versehentlich den falschen Scope invaliden, weil die Factory nur gültige Keys exponiert.

2. E2E-Test für Stale Data nach Mutation

typescript
test('Admin-MRR-Abfrage wird nach Credit-Mutation invalidiert', async () => {
  // Initiales MRR-Setup
  const initialMRR = await fetchAdminMRR()
  expect(initialMRR).toBeGreaterThan(0)

  // Credit-Änderung anwenden
  await applyCredits({ userId: testUser.id, amount: 500 })

  // Warten auf Invalidierungs-Propagation
  await page.waitForTimeout(500)

  // MRR sollte die Credit-Änderung sofort widerspiegeln — keine veralteten Daten
  const updatedMRR = await fetchAdminMRR()
  expect(updatedMRR).not.toBe(initialMRR)
})

Der Test schlägt fehl, wenn die Mutation die Admin-MRR-Abfrage nicht invalidiert. Der 40-Zeilen-Test ersetzte stundenlange Investigation.

Lessons

Query-Keys sind ein Vertrag; behandeln Sie sie wie ein Datenbank-Schema

Query-Keys definieren, welche Daten eine Komponente liest. Wenn Sie einen Query-Key ohne Migrationsstrategie ändern, erhalten Sie stale Daten — genau das Problem, das Sie auch bei einer Änderung des Datenbankschemas ohne Migration bekommen würden. Die typisierte Factory ist ein Schema für Ihre Query-Schicht. Nutzen Sie sie.

Invalidierung muss exakt zur Key-Struktur passen

invalidateQueries({ queryKey: ['subscriptions'] }) invalidiert nur Keys, die mit ['subscriptions'] beginnen. Wenn Ihre Admin-MRR-Abfrage ['admin', 'mrr', 'global'] verwendet, wird sie nicht berührt. Prüfen Sie stets Ihre Invalidierungsaufrufe gegen Ihre Query-Key-Struktur.

Stale-Data-Bugs sind in der Entwicklung unsichtbar

Der Bug zeigte sich nur in der Produktion unter Last mit realen Daten. Entwicklungsumgebungen haben kleine Datensätze und kurze staleTime-Fenster. Stale-Data-Bugs verstecken sich in der Lücke zwischen „funktioniert in der Dev" und „schlägt in der Prod fehl."

Die Lösung: E2E-Tests, die die Daten-Freshness nach Mutationen prüfen. Der Test, den ich geschrieben habe, läuft bei jedem CI-Build. Er ist nun ein erforderliches Gate, bevor eine Mutation deployed wird.

Der Fix ist klein; die Erkennung ist die Arbeit

Der eigentliche Fix waren 4 Zeilen typisierter Factory-Code. Der Test, der ein Wiederkehren verhindert, hat 40 Zeilen. Die Investigation dauerte 4 Stunden. Erkennung kostet mehr als die Behebung. Investieren Sie in Testabdeckung für Daten-Freshness — und in Observability, die Stale-Data-Szenarien erkennt, bevor Kunden sie melden.

How TanStack Ship This Prevents

Das Admin-Modul in TanStack Ship liefert die typisierte queryKeys-Factory in src/lib/query-keys.ts. Jede Abfrage im Admin-Modul nutzt die Factory. Jede Mutation invalidiert über die Factory. Eine E2E-Testsuite verifiziert die Daten-Freshness nach Mutationen.

Wenn Sie TanStack Query ohne typisierte Key-Factory nutzen, ist dieses Postmortem Ihr eindeutiges Signal, eine hinzuzufügen. Die Factory hat 30 Zeilen. Der Stale-Data-Bug, den sie verhindert, kostet 4 Stunden Investigation und einen fehlerhaften Finanz-Alert.

Measuring Query Freshness in Production

Das absichtliche staleTime von 4 Stunden für die Admin-MRR-Abfrage war ein bewusster Trade-off: kurz genug, um echte Änderungen schnell zu erfassen, lang genug, um die Datenbanklast zu reduzieren. Das Problem war, dass keine Instrumentation existierte, um zu erkennen, wann eine Mutation die Abfrage nicht invalidiert hatte. Es gab keinen Alert für „die Mutation ist gelaufen, aber die Abfrage hat sich nicht aktualisiert." Das ist die Lücke, die dieses Postmortem geschlossen hat.

TanStack Ship instrumentiert nun jede Admin-Abfrage mit einem dataUpdatedAt-Zeitstempel und protokolliert ihn bei jeder Query-Aktualisierung. Ein Alert wird ausgelöst, wenn die Admin-MRR-Abfrage sich nicht innerhalb von 10 Minuten nach einer bekannten Mutation aktualisiert hat — Stale-Data-Szenarien werden proaktiv erkannt, bevor ein Kunde ein Support-Ticket erstellt.

Die Kombination aus typisierter Key-Factory + E2E-Tests + Freshness-Monitoring hat ein Wiederkehren dieses Bugs auf der gesamten TanStack-Ship-Plattform für über 90 Tage verhindert.


Über diesen Artikel

Mit TanStack Ship starten — typisierte Query-Key-Factory und Stale-Data-E2E-Tests sind im Admin-Modul enthalten. Kostenloses Starter-Projekt klonen →