TanStack Table v9 beta did something that looks small in a changelog and large in a production grid: v9.0.0-beta.33 added web workers for row models. Less than two days later, v9.0.0-beta.34, beta.35, and beta.36 followed with sorting optimizations, sticky pinned-column documentation, cell-order performance work, touch-cancel cleanup, and extra tests. [Source: https://github.com/TanStack/table/releases/tag/v9.0.0-beta.33] [Source: https://github.com/TanStack/table/releases/tag/v9.0.0-beta.34] [Source: https://github.com/TanStack/table/releases/tag/v9.0.0-beta.35] [Source: https://github.com/TanStack/table/releases/tag/v9.0.0-beta.36]
That sequence is the signal. Workerized row models are not a speed button. They are a boundary change. The moment a table’s row pipeline crosses postMessage, your migration moves from “upgrade a package” to “prove serialization, determinism, adapter behavior, SSR fallback, and rollback.” The question is not whether the demo scrolls smoothly; it is whether the grid survives maintenance, handoff, and local constraints after the first bug report.
This post is a migration checklist for teams running real admin grids: finance screens, operations tables, inventory lists, support queues, audit views, or any interface where sorting the wrong row to the top becomes a business problem. I am using TanStack Table as the concrete example, but the operating lesson applies to any frontend feature that moves application logic into a worker.
The practical mistake is to treat the worker as an implementation detail hidden behind the library. It is not hidden for your app. It changes what data is legal, where failures appear, how errors are logged, and how a team rolls back when the table behaves differently under real users. That is why the migration plan needs artifacts before enthusiasm: a DTO contract, parity tests, a production worker bundle check, and a flag that can disable the worker path without waiting for a full release cycle.

The release pattern says “boundary,” not “benchmark”
The official TanStack v9 blog positions the release around explicit feature registration, better tree-shakability, atom-backed state, framework adapters, and a redesigned extension model. [Source: https://tanstack.com/blog/tanstack-table-v9-taking-form] The migration docs for React, Vue, Angular, and Svelte all emphasize a major-release shift: row model factories and feature registration become more explicit, and row model processing functions are registered through the table feature system. [Source: https://tanstack.com/table/beta/docs/framework/react/guide/migrating] [Source: https://tanstack.com/table/beta/docs/framework/vue/guide/migrating] [Source: https://tanstack.com/table/beta/docs/framework/angular/guide/migrating]
Then v9.0.0-beta.33 adds web workers for row models. That is not just a runtime optimization. It changes where data is transformed and how those transformations are represented. When filtering, grouping, sorting, or pagination logic remains on the main thread, it can close over local functions, objects, caches, and UI-adjacent state. Once the row pipeline crosses the worker boundary, that comfort disappears.
The browser’s worker boundary is a message boundary. Values must be transferred or cloned. Functions do not simply travel across. Class instances lose behavior. DOM nodes are forbidden. Some objects are transferable; many are not. If your current table setup relies on inline comparators, formatter closures, row objects with methods, or framework-specific reactive wrappers, the boundary asks a blunt question: what exactly are you sending?
| Release | Date | Relevant signal | Migration reading |
|---|---|---|---|
v9.0.0-beta.33 | 2026-07-06 | web workers for row models | New execution boundary |
v9.0.0-beta.34 | 2026-07-07 | sorting optimizations; sticky pinned-column docs | Sorting behavior still moving |
v9.0.0-beta.35 | 2026-07-07 | cell-order performance improvements | Layout/order assumptions need tests |
v9.0.0-beta.36 | 2026-07-07 | touch-cancel cleanup; more tests | Interaction and utility coverage still growing |
That is healthy beta activity. It is also a reason to test like an operator, not like a package tourist.
The first failing point is serialization
The word to keep in your head is structuredClone. Workers communicate through cloneable or transferable values. That means a sorting function like (a, b) => a.name.localeCompare(b.name) is not a value you should assume you can ship to the worker as-is. A row object that carries methods is not the same thing as a row DTO. A filter predicate that closes over locale, feature flags, user permissions, or a tenant-specific setting is not automatically portable.
This is the first migration rule: make the row-model contract data-first. Send identifiers and config. Rehydrate functions on the worker side from a registry you control. Do not let arbitrary closures become part of the migration surface.
1// table-worker-contract.ts
2export type SortKey = "name" | "createdAt" | "amount";
3export type SortDirection = "asc" | "desc";
4
5export type WorkerSortSpec = {
6 key: SortKey;
7 direction: SortDirection;
8 nulls: "first" | "last";
9};
10
11export type InvoiceRowDto = {
12 id: string;
13 name: string;
14 createdAt: string; // ISO string, not Date instance
15 amount: number | null;
16 status: "draft" | "sent" | "paid" | "void";
17};
18
19const sorters: Record<SortKey, (a: InvoiceRowDto, b: InvoiceRowDto) => number> = {
20 name: (a, b) => a.name.localeCompare(b.name),
21 createdAt: (a, b) => a.createdAt.localeCompare(b.createdAt),
22 amount: (a, b) => (a.amount ?? Number.NEGATIVE_INFINITY) - (b.amount ?? Number.NEGATIVE_INFINITY),
23};
24
25export function sortRows(rows: InvoiceRowDto[], spec: WorkerSortSpec) {
26 const base = sorters[spec.key];
27 const direction = spec.direction === "asc" ? 1 : -1;
28
29 return [...rows].sort((a, b) => {
30 if (a[spec.key] == null && b[spec.key] == null) return 0;
31 if (a[spec.key] == null) return spec.nulls === "first" ? -1 : 1;
32 if (b[spec.key] == null) return spec.nulls === "first" ? 1 : -1;
33 return direction * base(a, b);
34 });
35}
The important part is not the specific table API. The important part is the boundary shape. WorkerSortSpec is cloneable. InvoiceRowDto is cloneable. The comparator registry stays inside the worker bundle. That gives you a contract you can test without a browser UI.
Determinism beats perceived speed
A fast grid that changes row order between main-thread and worker mode is broken. The first quality gate should not be frame rate; it should be deterministic parity. Given the same data, sorting, filtering, grouping, pagination, and pinned-column state, worker mode and non-worker mode must produce the same visible row IDs.
That test belongs outside the UI. It should run against fixtures that include the ugly cases: nulls, duplicate names, timezone strings, mixed status values, rows with missing optional fields, and rows that only differ by a secondary sort key.
1// row-model-parity.test.ts
2import { describe, expect, it } from "vitest";
3import { sortRows, type InvoiceRowDto, type WorkerSortSpec } from "./table-worker-contract";
4
5const rows: InvoiceRowDto[] = [
6 { id: "a", name: "Ari", createdAt: "2026-07-01T01:00:00Z", amount: 120, status: "paid" },
7 { id: "b", name: "Bima", createdAt: "2026-07-01T01:00:00Z", amount: null, status: "sent" },
8 { id: "c", name: "Ari", createdAt: "2026-06-30T23:00:00Z", amount: 120, status: "draft" },
9];
10
11function mainThreadOrder(input: InvoiceRowDto[], spec: WorkerSortSpec) {
12 return sortRows(input, spec).map((row) => row.id);
13}
14
15function workerContractOrder(input: InvoiceRowDto[], spec: WorkerSortSpec) {
16 // This simulates the structured-clone boundary: no functions, no Date instances,
17 // no reactive proxies, no class methods.
18 const clonedRows = structuredClone(input);
19 const clonedSpec = structuredClone(spec);
20 return sortRows(clonedRows, clonedSpec).map((row) => row.id);
21}
22
23describe("row model worker contract", () => {
24 it("keeps sorting order stable across the clone boundary", () => {
25 const spec: WorkerSortSpec = { key: "amount", direction: "asc", nulls: "last" };
26 expect(workerContractOrder(rows, spec)).toEqual(mainThreadOrder(rows, spec));
27 });
28});
This catches the migration bug before a product manager finds it by clicking a column header. It also forces the team to name the data contract. If this test is hard to write, the migration is not ready.
The same pattern works for filtering and grouping. Keep one fixture per business-critical grid. Pin the package version. Run the parity suite in CI. Only then look at browser performance traces.

Bundle and adapter checks are not secondary
Workerized row models also change your bundling story. Vite, Webpack, Rollup, and framework meta-builders handle worker entrypoints differently. A worker import that behaves in a local Vite dev server can break in SSR, in a CDN asset path, under a strict Content Security Policy, or inside a monorepo package that publishes both ESM and CJS builds.
Start with the smallest possible worker smoke test in the same app shell that runs your table. Do not only test it in a standalone playground.
1// row-model.worker.ts
2import { sortRows, type InvoiceRowDto, type WorkerSortSpec } from "./table-worker-contract";
3
4self.onmessage = (event: MessageEvent<{ rows: InvoiceRowDto[]; sort: WorkerSortSpec }>) => {
5 const { rows, sort } = event.data;
6 self.postMessage({ rowIds: sortRows(rows, sort).map((row) => row.id) });
7};
1// create-row-model-worker.ts
2export function createRowModelWorker() {
3 if (typeof Worker === "undefined") {
4 return null;
5 }
6
7 return new Worker(new URL("./row-model.worker.ts", import.meta.url), {
8 type: "module",
9 name: "row-model-worker",
10 });
11}
That typeof Worker === "undefined" check matters for SSR. A server render path does not have a browser worker. If your table setup runs during SSR, hydration, route prefetch, or an Inertia-style page transition, you need a clean fallback path that produces the same initial state without touching window or Worker.
Framework adapters add another layer. React, Vue, Svelte, Angular, Solid, and others have different reactivity and lifecycle shapes. TanStack Table is headless, but your adapter glue is not. Vue refs, React state setters, Svelte stores, Angular signals, and Solid primitives should not leak into the worker contract. Keep the worker payload plain. Keep adapter state at the edge.
| Gate | What to prove | Failure if skipped |
|---|---|---|
| Worker import | Production build emits worker asset correctly | Works locally, 404s after deploy |
| CSP | Worker source is allowed by policy | Table silently falls back or fails |
| SSR guard | Server path never touches Worker | Build/runtime crash in SSR |
| Adapter boundary | Reactive objects do not cross postMessage | Clone errors or stale state |
| Source maps | Worker errors are debuggable | Production bug with unreadable stack |
I keep this separate from the row-order parity suite because it answers a different question. Parity asks, “Does the row model compute the same result?” Bundle checks ask, “Can this code even run in the app we ship?” Both must pass.
Rollback is a product feature
A workerized grid needs a kill switch before it needs a celebration post. If the migration breaks a high-volume admin screen, the rollback path should be a config flag, not a redeploy that requires someone to remember which package version was safe.
The simplest rollout shape is a per-grid mode flag:
1// table-runtime-mode.ts
2export type RowModelMode = "main-thread" | "worker";
3
4export function resolveRowModelMode(params: {
5 gridName: string;
6 userCanary: boolean;
7 featureFlags: Record<string, boolean>;
8}): RowModelMode {
9 const enabled = params.featureFlags[`table-workers:${params.gridName}`] === true;
10
11 if (!enabled) return "main-thread";
12 if (!params.userCanary) return "main-thread";
13
14 return "worker";
15}
That looks boring. Boring is the point. A grid migration that can only be rolled back by reverting a branch is not operationally ready. Put the mode in telemetry. Log row count, sort key, filter count, mode, worker boot time, worker error count, and the final row ID count. If the mode flips back to main-thread, preserve enough context to know why.
Rollback also protects teams from beta churn. TanStack Table v9 is moving quickly, and that is normal for a beta. The point is not to avoid betas forever. The point is to isolate the blast radius so the team can learn from the beta without turning a data grid into a release blocker.

The migration checklist I would run
Here is the checklist I would use before moving a large admin grid from the current TanStack Table setup to v9 workerized row models. I would keep this checklist in the pull request description, not in a private note. The reviewer should see the exact beta version, the fixtures that prove parity, the worker asset produced by the production build, and the switch that returns the grid to main-thread mode. If those artifacts are missing, the migration is still a research branch.
| Check | Artifact | Pass condition |
|---|---|---|
| Version pin | lockfile diff | Exact beta version, no floating range |
| Serialization | DTO/strategy registry | No functions or reactive objects in worker payload |
| Sorting parity | fixture test | Same row IDs in main-thread and worker mode |
| Filtering parity | fixture test | Same filtered set with nulls and edge values |
| Grouping/pagination | fixture test | Same group keys and page boundaries |
| Build output | production build log | Worker asset emitted and served |
| SSR fallback | server test | No Worker access on server path |
| Adapter glue | review checklist | React/Vue/Svelte/Angular state stays outside payload |
| Observability | deploy artifact | mode, errors, row count, worker boot time logged |
| Rollback | feature flag | One flag returns the grid to main-thread mode |
For teams using Vue or Laravel/Vue stacks, I would add one extra check: route transitions. If the grid lives inside an Inertia-style page or a SPA admin shell, make sure the worker shuts down when the page changes and does not keep stale table state alive after navigation. Worker lifecycle is easy to ignore because it is not visible in the DOM.
The strongest migration plan is not “upgrade everything.” It is one representative grid, one fixture suite, one canary group, one rollback flag, and one readback after real users touch it.
What you should do Monday morning
- Pick one representative grid. Choose a table with sorting, filtering, pagination, and enough rows to matter. Do not start with the easiest demo table.
- Pin the exact beta. Use the current tested version, commit the lockfile, and write the upgrade reason in the PR description.
- Inventory non-cloneable state. Search for inline sorters, filter functions, formatter closures,
Dateinstances, class objects, reactive proxies, and DOM references near the table setup. - Create a DTO contract. Convert row payloads to plain data and use named strategy keys for behavior.
- Write parity tests before measuring speed. Prove row IDs match between main-thread and worker mode for sorting, filtering, grouping, and pagination fixtures.
- Run a production build. Verify the worker asset exists, loads with your real base path, and passes CSP.
- Add the rollback flag first. Ship canary support and observability before making worker mode the default.
- Record the readback. After one day, check worker errors, fallback count, row count mismatches, and user complaints before widening the rollout.
This is slower than clicking “upgrade”. It is also how table migrations avoid becoming support tickets.
Further reading
- — the release that added web workers for row models.
- — official overview of the v9 architecture direction.
- — the browser boundary that makes worker migrations concrete.
If this is the kind of migration discipline you want around frontend architecture, start with the broader notes in /developer-tools/ and the SaaS maintenance perspective in /laravel-vue-saas/. The same artifact-first thinking also applies to agent pipelines in /ai-agent-operations/.

