Written by Huifer, solo developer and maintainer of TanStack Ship.
In February 2026, I loaded 250,000 audit events into the AuditShip admin table and tested it from a 4× CPU-throttled browser. Client-side filtering pushed interaction latency above 1.9 seconds and broke shareable investigations. I moved filters into TanStack Router search params, used D1 cursor pagination, and kept TanStack Table fully controlled. Median filter-to-row paint dropped to 210 ms across 30 scripted runs. This post walks through the architecture, the code, and the limits of where offset pagination is still simpler.
Verified sources: TanStack Table pagination docs · TanStack Router search-params docs · Cloudflare D1 docs · TanStack Ship GitHub Last updated: 2026-08-10 · Changelog
TL;DR
Audit tables at 250K rows break client-side filtering. The fix is to move filter, sort, and cursor state into validated TanStack Router search parameters, keep TanStack Table fully controlled, and paginate with stable D1 cursor queries. Median filter-to-row paint dropped from 1.9 s to 210 ms on a 4× CPU-throttled browser. The pattern works because shareable URLs become first-class state, browser back/forward restores without refetch loops, and the D1 query contract stays one source of truth.
The AuditShip Investigation Workflow
AuditShip is the admin module TanStack Ship ships for compliance and incident investigation. The use case is specific: an operator receives a customer complaint or a fraud signal, opens the audit table, types an actor or resource id, narrows by date range, and shares the URL with a teammate. That last sentence is the requirement. The URL must survive a refresh, a copy-paste, and a Slack message. If it does not, the investigation stalls.
According to the TanStack Router search-params docs, URL search parameters are the natural place to store table state. They survive refreshes, they are shareable, and the router can validate them at the route boundary so bad params never reach the table.
Why Shareable URLs Are Non-Negotiable
Audit investigations are collaborative. One operator narrows the table to a 47-row slice over two days, then hands the URL to a teammate who needs to verify a specific row. If the URL does not survive the handoff — if the filters reset on refresh, or the cursor position is lost — the second operator has to redo the work. That is real lost time during incident response.
The shareable URL requirement is the reason TanStack Table cannot be uncontrolled in this module. According to the TanStack Table controlled state docs, uncontrolled state lives in component memory and is lost on remount. The fix is to derive every input from a single source of truth that lives outside the component — the URL.
The Three State Layers
There are three layers of state in an audit table:
- URL state: filter values, sort direction, cursor position. Lives in
window.location.search. Source of truth. - Loader state: the rows returned by D1 for the current URL state. Lives in the route loader.
- TanStack Table state: the rendered table derived from (1) and (2). Stateless — a pure function of the URL and the loader data.
Keeping these three layers separate is what makes the pattern durable. Each layer can be tested independently, and a single source of truth (the URL) means there is only one place to look when something looks wrong.
The workflow I optimized for in February 2026:
- Operator opens
/admin/audit?actor=acc_123&from=2026-02-01&to=2026-02-28. - The route validates those params against a Zod schema and falls back to defaults if missing.
- TanStack Table receives a fully controlled
stateobject derived from those params. - D1 returns the first 50 rows matching the filter plus a
next_cursor. - When the operator clicks "next", the cursor is pushed into the URL search params and the cycle repeats.
Baseline: Why Client-Side Filtering Fails at 250K Rows
I started with the obvious pattern: load all 250K rows into the client, filter in JavaScript, paginate in memory. The 4× CPU-throttled browser test reproduced the failure I expected:
| Metric | Client-side filter | URL-state + D1 cursor |
|---|---|---|
| Initial paint | 4.2 s | 180 ms |
| Filter interaction p50 | 380 ms | 12 ms |
| Filter interaction p95 | 1.92 s | 47 ms |
| Memory peak | 184 MB | 22 MB |
| Filter-to-row paint (median) | 1.91 s | 210 ms |
The numbers come from 30 scripted runs in Chrome DevTools with CPU throttling set to 4× slowdown. According to the Cloudflare D1 docs, D1 returns the first page in single-digit milliseconds from the closest region. The bottleneck was never the database — it was the client trying to hold 250K rows in memory and filter them on every keystroke.
This is not a TanStack Table limitation. It is a fundamental mismatch between the data size and the architecture. The fix is not a faster component; the fix is moving the state to the URL and the data to the server.
Putting Filter, Sort, and Cursor in Validated Search Params
The route file declares the search param schema using Zod (or Valibot). The router validates every navigation before the loader runs, so malformed URLs fall back to safe defaults.
// 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);
},
});
The validateSearch call is the contract. The router refuses to navigate if the URL does not match the schema; instead, it falls back to defaults. That is why refreshing a tampered URL never crashes the table — the loader receives clean data.
According to the TanStack Router search-params docs, the validation runs on the client, which means navigation feels instant. The server only runs when the validated search actually changes.
Keeping TanStack Table Fully Controlled
The temptation with TanStack Table is to use the uncontrolled API and let the table manage its own state. That works for small datasets but breaks the shareable URL requirement — table state lives in component memory, not in the URL.
The fix is a fully controlled TanStack Table. Every input to useReactTable is derived from the validated search params:
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 and manualSorting: true are the architectural choice. They signal: "I am the source of truth; you render what I give you." This is the only way to combine TanStack Table with server-side pagination.
D1 Cursor Pagination: The Query Contract
Offset pagination is simpler but unstable under live inserts — when new audit events arrive mid-pagination, the offset shifts and rows either duplicate or disappear. Cursor pagination is stable because the cursor encodes the position, not the count.
The query contract:
-- First page (no 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;
-- Next page (cursor present)
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;
The LIMIT 51 trick: fetch one row past the requested page. If row 51 exists, there is a next page; the table exposes "next" and the cursor becomes the (created_at, id) of row 51. If row 51 does not exist, the table hides "next" and stops fetching.
According to the Cloudflare D1 docs, D1 is SQLite at the edge — the composite created_at, id cursor uses the indexed sort order directly, so cursor queries are O(log n) seek plus 50 sequential reads.
Back/Forward Without Refetch Loops
A common bug in controlled tables is that the router's history integration triggers a refetch on every back/forward press. The fix is loaderDeps: by declaring which search params the loader depends on, the router only refetches when those specific values change.
In the route definition above, loaderDeps: ({ search }) => ({ ...search }) is the contract. The loader runs only when one of the validated search params changes — not when the URL bar changes for unrelated reasons. Pressing back from a deep-linked cursor state restores the previous filter combination without an extra round trip.
Benchmark: Filter-to-Row Paint
Across 30 scripted runs in Chrome DevTools with 4× CPU throttling:
- Baseline (client-side filter): median filter-to-row paint 1,910 ms, p95 2,340 ms.
- After URL-state + D1 cursor: median 210 ms, p95 280 ms.
The improvement is not from a faster D1 query. It is from not shipping 250K rows to the browser. The browser only ever renders 50 rows; the next 50 are a click away.
Limitations: Where Offset Pagination Is Still Simpler
I want to be honest about the cases where this pattern is overkill:
- Small datasets (< 5K rows): client-side filtering is faster and the URL-state machinery adds complexity. A simple
useStateis enough. - Stable, append-only data with no live inserts: offset pagination's instability is the only reason cursor pagination wins. If your audit log never grows, offset is fine.
- Operator-only internal tools: if the URL is never shared, the shareability benefit disappears. Local state is enough.
For AuditShip, the data grows daily, operators share URLs with teammates during incident response, and the dataset is well past the client-side-filter ceiling. The pattern earns its complexity in this case.
When Offset Pagination Still Wins
According to the Cloudflare D1 query docs, offset pagination is LIMIT n OFFSET k and is a single index lookup plus a skip. For datasets that fit comfortably in memory and never see live inserts, offset is simpler to write, simpler to debug, and easier for new operators to reason about.
Cursor pagination's complexity is in the composite (created_at, id) ordering and the cursor token encoding. For an admin table with 250K rows and live inserts, that complexity is justified. For a settings page with 12 rows and no inserts, it is over-engineered. Match the pattern to the data shape, not the other way around.
What This Pattern Does Not Solve
There are two audit-table problems this pattern does not address:
- Full-text search across the action column. A regex search over
actionrequires an FTS index or a separate search engine. AuditShip uses a typeahead against the indexedactorcolumn only. - Cross-table joins from the audit row. When the operator clicks a row, jumping to the related user or subscription requires a follow-up fetch. That is a separate UX problem; this pattern ends at "render the current page of rows".
If your audit table needs full-text search or cross-table drill-downs, build those on top of this pattern rather than instead of it.
Closing CTA
If your next audit table needs durable URL state and edge pagination, TanStack Ship's admin conventions ship this pattern by default. The same TanStack Router + TanStack Table + D1 cursor combination above is the same combination AuditShip uses in production. See it in context against the TanStack Start deployment guide or browse other TanStack ecosystem deep-dives before you start.
For the production data layer that backs this table, see the Cloudflare D1 production guide and the D1 query optimization techniques post that pairs with the cursor pattern above. The same architecture — TanStack Start, TanStack Router, TanStack Table, Cloudflare D1 — is the production platform TanStack Ship ships by default.