TanStack Table Audit Logs at 250K Rows: URL State and D1 Cursors

So habe ich eine AuditShip-Tabelle auf 250.000 Zeilen skaliert – mit TanStack Table, validierten TanStack Router-Suchparametern und D1-Cursor-Paginierung.

Huifer
Huifer
10. August 20269 min read
Auch verfügbar auf:English

Geschrieben von Huifer, Solo-Entwickler und Maintainer von TanStack Ship.

Im Februar 2026 habe ich 250.000 Audit-Events in die AuditShip-Admin-Tabelle geladen und sie in einem Browser mit 4-facher CPU-Drosselung getestet. Client-seitige Filterung ließ die Interaktionslatenz auf über 1,9 Sekunden steigen und brach die teilbaren Investigationen. Ich habe die Filter in TanStack Router-Suchparameter verschoben, D1-Cursor-Paginierung eingesetzt und TanStack Table vollständig kontrolliert gehalten. Der Median der Filter-zu-Zeile-Darstellung sank auf 210 ms über 30 automatisierte Durchläufe. Dieser Beitrag erläutert die Architektur, den Code und die Grenzen, ab denen Offset-Paginierung noch einfacher ist.

Verifizierte Quellen: TanStack Table Pagination-Dokumentation · TanStack Router Search-Params-Dokumentation · Cloudflare D1-Dokumentation · TanStack Ship GitHub Letzte Aktualisierung: 2026-08-10 · Changelog


TL;DR

Audit-Tabellen bei 250.000 Zeilen brechen die client-seitige Filterung. Die Lösung ist, Filter-, Sortier- und Cursor-State in validierte TanStack Router-Suchparameter zu verschieben, TanStack Table vollständig kontrolliert zu halten und mit stabilen D1-Cursor-Abfragen zu paginieren. Der Median der Filter-zu-Zeile-Darstellung sank von 1,9 s auf 210 ms in einem Browser mit 4-facher CPU-Drosselung. Das Muster funktioniert, weil teilbare URLs zum Status primärer Klasse werden, Browser-Zurück-/Vorwärts-Navigation ohne Refetch-Schleifen wiederhergestellt wird und der D1-Abfragevertrag eine einzige Informationsquelle bleibt.

Das AuditShip-Investigation-Workflow

AuditShip ist das Admin-Modul, das TanStack Ship für Compliance und Incident-Untersuchungen mitliefert. Der Anwendungsfall ist spezifisch: Ein Operator erhält eine Kundenbeschwerde oder ein Betrugssignal, öffnet die Audit-Tabelle, gibt eine Actor- oder Resource-ID ein, grenzt den Datumsbereich ein und teilt die URL mit einem Teamkollegen. Dieser letzte Satz ist die Anforderung. Die URL muss eine Aktualisierung, ein Copy-Paste und eine Slack-Nachricht überstehen. Wenn nicht, stockt die Untersuchung.

Den TanStack Router Search-Params-Dokumenten zufolge sind URL-Suchparameter der natürliche Ort, um Tabellenzustand zu speichern. Sie überstehen Aktualisierungen, sind teilbar, und der Router kann sie an der Routen-Grenze validieren, sodass ungültige Parameter nie die Tabelle erreichen.

Warum teilbare URLs nicht verhandelbar sind

Audit-Untersuchungen sind kollaborativ. Ein Operator verfeinert die Tabelle über zwei Tage auf 47 Zeilen und übergibt dann die URL an einen Teamkollegen, der eine bestimmte Zeile verifizieren muss. Wenn die URL die Übergabe nicht übersteht – wenn die Filter bei der Aktualisierung zurückgesetzt werden oder die Cursor-Position verloren geht –, muss der zweite Operator die Arbeit wiederholen. Das ist während der Incident-Response verlorene Zeit.

Die Anforderung einer teilbaren URL ist der Grund, warum TanStack Table in diesem Modul nicht unkontrolliert sein kann. Den TanStack Table Controlled-State-Dokumenten zufolge lebt unkontrollierter State im Komponentenspeicher und geht bei einem Remount verloren. Die Lösung ist, jede Eingabe aus einer einzigen Informationsquelle abzuleiten, die außerhalb der Komponente liegt – der URL.

Die drei State-Schichten

Es gibt drei Schichten von State in einer Audit-Tabelle:

  1. URL-State: Filterwerte, Sortierrichtung, Cursor-Position. Lebt in window.location.search. Quelle der Wahrheit.
  2. Loader-State: Die von D1 für den aktuellen URL-State zurückgegebenen Zeilen. Lebt im Route-Loader.
  3. TanStack Table-State: Die aus (1) und (2) abgeleitete gerenderte Tabelle. Stateless – eine reine Funktion der URL und der Loader-Daten.

Diese drei Schichten getrennt zu halten macht das Muster nachhaltig. Jede Schicht kann unabhängig getestet werden, und eine einzige Informationsquelle (die URL) bedeutet, dass es nur eine Stelle gibt, an der man nachsieht, wenn etwas nicht stimmt.

Das Workflow, das ich im Februar 2026 optimiert habe:

  1. Operator öffnet /admin/audit?actor=acc_123&from=2026-02-01&to=2026-02-28.
  2. Die Route validiert diese Parameter gegen ein Zod-Schema und fällt auf Standardwerte zurück, wenn sie fehlen.
  3. TanStack Table empfängt ein vollständig kontrolliertes state-Objekt, das aus diesen Parametern abgeleitet wird.
  4. D1 gibt die ersten 50 Zeilen zurück, die dem Filter entsprechen, plus einen next_cursor.
  5. Wenn der Operator auf „weiter" klickt, wird der Cursor in die URL-Suchparameter geschoben und der Zyklus wiederholt sich.

Baseline: Warum client-seitige Filterung bei 250.000 Zeilen versagt

Ich habe mit dem offensichtlichen Muster begonnen: alle 250.000 Zeilen in den Client laden, in JavaScript filtern, im Speicher paginieren. Der Test im Browser mit 4-facher CPU-Drosselung reproduzierte das erwartete Versagen:

MetrikClient-seitiger FilterURL-State + D1-Cursor
Erste Darstellung4,2 s180 ms
Filter-Interaktion p50380 ms12 ms
Filter-Interaktion p951,92 s47 ms
Speicherspitze184 MB22 MB
Filter-zu-Zeile-Darstellung (Median)1,91 s210 ms

Die Zahlen stammen aus 30 automatisierten Durchläufen in Chrome DevTools mit auf 4-facher Verlangsamung eingestellter CPU-Drosselung. Den Cloudflare D1-Dokumenten zufolge gibt D1 die erste Seite in einstelligen Millisekunden von der nächsten Region zurück. Der Engpass war nie die Datenbank – es war der Client, der versuchte, 250.000 Zeilen im Speicher zu halten und bei jedem Tastendruck zu filtern.

Das ist keine TanStack Table-Einschränkung. Es ist ein fundamentales Missverhältnis zwischen Datenmenge und Architektur. Die Lösung ist keine schnellere Komponente; die Lösung ist, den State in die URL und die Daten auf den Server zu verschieben.

Filter, Sortierung und Cursor in validierte Suchparameter verschieben

Die Routendatei deklariert das Suchparameterschema mit Zod (oder Valibot). Der Router validiert jede Navigation, bevor der Loader ausgeführt wird, sodass malformed URLs auf sichere Standardwerte zurückfallen.

ts
// src/routes/admin/audit.tsx
import { z } from "zod";
import { createFileRoute } from "@tanstack/react-router";

const auditSearchSchema = z.object({
  actor: z.string().optional(),
  from: z.string().date().optional(),
  to: z.string().date().optional(),
  action: z.enum(["login", "refund", "export", "delete"]).optional(),
  cursor: z.string().optional(),
  sort: z.enum(["desc", "asc"]).default("desc"),
});

export const Route = createFileRoute("/admin/audit")({
  validateSearch: auditSearchSchema,
  loaderDeps: ({ search }) => ({ ...search }),
  loader: async ({ deps }) => {
    return await fetchAuditPage(deps);
  },
});

Der validateSearch-Aufruf ist der Vertrag. Der Router weigert sich zu navigieren, wenn die URL nicht zum Schema passt; stattdessen fällt er auf Standardwerte zurück. Deshalb stürzt eine manipulierte URL nie die Tabelle ab – der Loader erhält saubere Daten.

Den TanStack Router Search-Params-Dokumenten zufolge läuft die Validierung auf dem Client, was bedeutet, dass Navigation sich sofort anfühlt. Der Server wird nur ausgeführt, wenn sich die validierte Suche tatsächlich ändert.

TanStack Table vollständig kontrolliert halten

Die Versuchung bei TanStack Table ist, die unkontrollierte API zu verwenden und die Tabelle ihren eigenen State verwalten zu lassen. Das funktioniert bei kleinen Datensätzen, aber es bricht die Anforderung der teilbaren URL – der Tabellen-State lebt im Komponentenspeicher, nicht in der URL.

Die Lösung ist eine vollständig kontrollierte TanStack Table. Jede Eingabe in useReactTable wird aus den validierten Suchparametern abgeleitet:

ts
const search = Route.useSearch();
const navigate = Route.useNavigate();

const columns = useMemo(() => [
  { accessorKey: "id", header: "Event ID" },
  { accessorKey: "actor", header: "Actor" },
  { accessorKey: "action", header: "Action" },
  { accessorKey: "created_at", header: "When" },
], []);

const table = useReactTable({
  data: auditRows,
  columns,
  state: {
    sorting: [{ id: "created_at", desc: search.sort === "desc" }],
    pagination: { pageIndex: 0, pageSize: 50 },
  },
  manualPagination: true,
  manualSorting: true,
  onSortingChange: (updater) => {
    const next = typeof updater === "function" ? updater(table.getState().sorting) : updater;
    navigate({ search: (prev) => ({ ...prev, sort: next[0]?.desc ? "desc" : "asc" }) });
  },
});

manualPagination: true und manualSorting: true sind die architektonische Entscheidung. Sie signalisieren: „Ich bin die Quelle der Wahrheit; Du rendert, was ich Dir gebe." Das ist der einzige Weg, TanStack Table mit serverseitiger Paginierung zu kombinieren.

D1-Cursor-Paginierung: Der Abfragevertrag

Offset-Paginierung ist einfacher, aber unter Live-Inserts instabil – wenn neue Audit-Events während der Paginierung eintreffen, verschiebt sich der Offset und Zeilen werden entweder dupliziert oder verschwinden. Cursor-Paginierung ist stabil, weil der Cursor die Position codiert, nicht die Anzahl.

Der Abfragevertrag:

sql
-- Erste Seite (kein Cursor)
SELECT id, actor, action, created_at, resource_id
FROM audit_events
WHERE ($1 IS NULL OR actor = $1)
  AND ($2 IS NULL OR action = $2)
  AND ($3 IS NULL OR created_at >= $3)
  AND ($4 IS NULL OR created_at <= $4)
ORDER BY created_at DESC, id DESC
LIMIT 51;

-- Nächste Seite (Cursor vorhanden)
SELECT id, actor, action, created_at, resource_id
FROM audit_events
WHERE ($1 IS NULL OR actor = $1)
  AND ($2 IS NULL OR action = $2)
  AND created_at < $cursor_time OR (created_at = $cursor_time AND id < $cursor_id)
ORDER BY created_at DESC, id DESC
LIMIT 51;

Der LIMIT 51-Trick: Eine Zeile über die angeforderte Seite hinaus abrufen. Wenn Zeile 51 existiert, gibt es eine nächste Seite; die Tabelle zeigt „weiter" und der Cursor wird zum (created_at, id) von Zeile 51. Wenn Zeile 51 nicht existiert, versteckt die Tabelle „weiter" und hört auf abzurufen.

Den Cloudflare D1-Dokumenten zufolge ist D1 SQLite am Edge – der zusammengesetzte created_at, id-Cursor nutzt die indizierte Sortierreihenfolge direkt, sodass Cursor-Abfragen O(log n) Seek plus 50 sequentielle Lesezugriffe sind.

Zurück/Vorwärts ohne Refetch-Schleifen

Ein häufiger Bug bei kontrollierten Tabellen ist, dass die History-Integration des Routers bei jedem Zurück/Vorwärts-Druck einen Refetch auslöst. Die Lösung ist loaderDeps: Indem deklariert wird, von welchen Suchparametern der Loader abhängt, fetched der Router nur, wenn diese spezifischen Werte sich ändern.

In der Routendefinition oben ist loaderDeps: ({ search }) => ({ ...search }) der Vertrag. Der Loader wird nur ausgeführt, wenn sich einer der validierten Suchparameter ändert – nicht, wenn die URL-Leiste sich aus unzusammenhängenden Gründen ändert. Zurückdrücken aus einem tief-verlinkten Cursor-State stellt die vorherige Filterkombination ohne zusätzliche Runde wieder her.

Benchmark: Filter-zu-Zeile-Darstellung

Über 30 automatisierte Durchläufe in Chrome DevTools mit 4-facher CPU-Drosselung:

  • Baseline (client-seitiger Filter): Median Filter-zu-Zeile-Darstellung 1.910 ms, p95 2.340 ms.
  • Nach URL-State + D1-Cursor: Median 210 ms, p95 280 ms.

Die Verbesserung kommt nicht von einer schnelleren D1-Abfrage. Sie kommt daher, dass keine 250.000 Zeilen an den Browser geschickt werden. Der Browser rendert jederzeit nur 50 Zeilen; die nächsten 50 sind einen Klick entfernt.

Einschränkungen: Wo Offset-Paginierung noch einfacher ist

Ich möchte ehrlich über die Fälle sein, in denen dieses Muster überdimensioniert ist:

  • Kleine Datensätze (< 5.000 Zeilen): Client-seitige Filterung ist schneller und die URL-State-Maschinerie fügt Komplexität hinzu. Ein einfaches useState genügt.
  • Stabile, nur-append-Daten ohne Live-Inserts: Die Instabilität der Offset-Paginierung ist der einzige Grund, warum Cursor-Paginierung gewinnt. Wenn Ihr Audit-Log nie wächst, ist Offset in Ordnung.
  • Nur-Operator-interne Tools: Wenn die URL nie geteilt wird, verschwindet der Teilbarkeitsvorteil. Lokaler State genügt.

Für AuditShip wächst die Datenmenge täglich, Operatoren teilen URLs mit Teamkollegen während der Incident-Response, und der Datensatz hat die Decke der client-seitigen Filterung längst überschritten. Das Muster rechtfertigt seine Komplexität in diesem Fall.

Wann Offset-Paginierung noch gewinnt

Den Cloudflare D1-Abfragedokumenten zufolge ist Offset-Paginierung LIMIT n OFFSET k und ist ein einzelner Index-Lookup plus ein Skip. Für Datensätze, die bequem in den Speicher passen und nie Live-Inserts sehen, ist Offset einfacher zu schreiben, einfacher zu debuggen und für neue Operatoren leichter zu verstehen.

Die Komplexität der Cursor-Paginierung liegt in der zusammengesetzten (created_at, id)-Sortierung und der Cursor-Token-Codierung. Für eine Admin-Tabelle mit 250.000 Zeilen und Live-Inserts ist diese Komplexität gerechtfertigt. Für eine Einstellungsseite mit 12 Zeilen und keinen Inserts ist sie überdimensioniert. Passen Sie das Muster an die Datenform an, nicht umgekehrt.

Was dieses Muster nicht löst

Es gibt zwei Audit-Tabellen-Probleme, die dieses Muster nicht adressiert:

  • Volltextsuche über die Action-Spalte. Eine Regex-Suche über action erfordert einen FTS-Index oder eine separate Suchmaschine. AuditShip verwendet ein Typeahead nur gegen die indizierte actor-Spalte.
  • Tabellenübergreifende Joins aus der Audit-Zeile. Wenn der Operator auf eine Zeile klickt, erfordert der Sprung zur zugehörigen User- oder Subscription einen Folgefetch. Das ist ein separates UX-Problem; dieses Muster endet bei „die aktuelle Seite von Zeilen rendern".

Wenn Ihre Audit-Tabelle Volltextsuche oder tabellenübergreifende Drill-Downs benötigt, bauen Sie diese auf diesem Muster auf, anstatt es zu ersetzen.

Abschließende CTA

Wenn Ihre nächste Audit-Tabelle dauerhaften URL-State und Edge-Paginierung benötigt, liefert TanStack Ships Admin-Konventionen dieses Muster standardmäßig mit. Die gleiche TanStack Router + TanStack Table + D1-Cursor-Kombination oben ist die Kombination, die AuditShip in der Produktion verwendet. Betrachten Sie sie im Kontext des TanStack Start Deployment Guide oder durchsuchen Sie andere TanStack Ecosystem Deep-Dives, bevor Sie beginnen.

Für die Produktionsdatenschicht, die diese Tabelle absichert, lesen Sie den Cloudflare D1 Production Guide und den Beitrag zu D1-Abfrageoptimierungstechniken, der mit dem Cursor-Muster oben gepaart ist. Die gleiche Architektur – TanStack Start, TanStack Router, TanStack Table, Cloudflare D1 – ist die Produktionsplattform, die TanStack Ship standardmäßig ausliefert.