Back to Blog

Building Performant Data Tables for Web Apps

Building Performant Data Tables for Web Apps

Data tables are deceptively hard. They look simple — rows and columns, what could go wrong? — and then you find yourself debugging why the page freezes when a user loads 5,000 invoices, why the sort order resets after a filter change, or why the column widths jump around when data loads asynchronously.

We have built a lot of tables. CRM contact lists in LancerSpace, feedback tracking tables in Trackelio, transaction histories in MindHyv. Every one of them had different requirements, and every one of them taught us something about what makes a data table feel fast and usable versus slow and frustrating.

Here is what we have learned.

The DOM is the bottleneck

The first thing to understand about table performance is that the browser’s rendering engine is the bottleneck, not your JavaScript. Rendering 5,000 rows means creating 5,000 <tr> elements, each containing however many <td> elements you have columns. A table with 10 columns and 5,000 rows means 50,000 DOM nodes just for the cells — plus text nodes, any icons or badges, and whatever wrapper elements your styling requires.

Modern browsers can handle a lot, but 50,000+ DOM nodes will cause measurable jank. Scrolling stutters. Sorting takes seconds. Filtering causes the UI to freeze.

The solution is to not render what the user cannot see.

Spreadsheet with rows of structured data in a table layout

Virtualization

Virtualization — sometimes called windowing — means only rendering the rows that are currently visible in the viewport, plus a small buffer above and below. As the user scrolls, rows are created and destroyed dynamically. The DOM stays small regardless of how many rows exist in the dataset.

The concept is straightforward. The implementation has sharp edges.

// Simplified virtualization logic
interface VirtualConfig {
  totalRows: number;
  rowHeight: number;
  containerHeight: number;
  overscan: number; // Extra rows to render above/below viewport
}

function getVisibleRange(scrollTop: number, config: VirtualConfig) {
  const { totalRows, rowHeight, containerHeight, overscan } = config;

  const startIndex = Math.max(0, Math.floor(scrollTop / rowHeight) - overscan);
  const visibleCount = Math.ceil(containerHeight / rowHeight);
  const endIndex = Math.min(totalRows - 1, startIndex + visibleCount + overscan * 2);

  return {
    startIndex,
    endIndex,
    offsetY: startIndex * rowHeight,
    totalHeight: totalRows * rowHeight,
  };
}

The container element has a total height equal to totalRows * rowHeight, which gives the scrollbar the correct size and behavior. Inside it, a positioned element contains only the visible rows, offset to their correct scroll position.

For most projects, we do not implement virtualization from scratch. TanStack Virtual handles the hard parts — variable row heights, smooth scrolling, horizontal virtualization, and dynamic measurement. But understanding the principle helps you debug issues when they arise.

When to virtualize

Not every table needs virtualization. If your table always has fewer than 200 rows, the DOM can handle it. Virtualization adds complexity — keyboard navigation needs custom handling, screen readers need ARIA attributes to understand the structure, and print stylesheets need a non-virtualized fallback.

Our rule of thumb:

  • Under 200 rows: Render everything. No virtualization needed.
  • 200 to 2,000 rows: Paginate (more on this below). Simpler than virtualization and usually sufficient.
  • Over 2,000 rows: Virtualize. The user cannot meaningfully scan more than this anyway, so you are optimizing for scroll performance and memory.

Server-side vs client-side operations

Sorting, filtering, and pagination can happen on the client or the server. The right choice depends on the total dataset size and what operations you need to support.

Client-side

Load all the data upfront. Sort, filter, and paginate in JavaScript. This is simpler to implement and gives the user instant feedback — sorting is immediate, filtering has no loading state, pagination is just a slice of an array.

interface SortConfig {
  column: string;
  direction: 'asc' | 'desc';
}

function sortData<T extends Record<string, unknown>>(
  data: T[],
  sort: SortConfig
): T[] {
  return [...data].sort((a, b) => {
    const aVal = a[sort.column];
    const bVal = b[sort.column];

    // Handle nulls — push them to the end
    if (aVal == null) return 1;
    if (bVal == null) return -1;

    // String comparison
    if (typeof aVal === 'string' && typeof bVal === 'string') {
      return sort.direction === 'asc'
        ? aVal.localeCompare(bVal)
        : bVal.localeCompare(aVal);
    }

    // Numeric comparison
    const numA = Number(aVal);
    const numB = Number(bVal);
    return sort.direction === 'asc' ? numA - numB : numB - numA;
  });
}

function filterData<T extends Record<string, unknown>>(
  data: T[],
  filters: Record<string, string>
): T[] {
  return data.filter((row) =>
    Object.entries(filters).every(([column, value]) => {
      if (!value) return true;
      const cellValue = String(row[column] ?? '').toLowerCase();
      return cellValue.includes(value.toLowerCase());
    })
  );
}

Client-side works well when:

  • The total dataset is under 5,000 rows
  • The initial load time is acceptable (data can be fetched once)
  • You want instant sort/filter/search feedback
  • You do not need full-text search or complex query capabilities

Server-side

Fetch only the data you need for the current view. Send sort, filter, and pagination parameters to the server. The server runs the query and returns the relevant slice.

// API request
interface TableParams {
  page: number;
  pageSize: number;
  sortColumn: string;
  sortDirection: 'asc' | 'desc';
  filters: Record<string, string>;
  search?: string;
}

// Supabase query builder
async function fetchTableData(params: TableParams) {
  let query = supabase
    .from('invoices')
    .select('id, client_name, amount, status, due_date, created_at', {
      count: 'exact', // Returns total count for pagination
    });

  // Apply filters
  for (const [column, value] of Object.entries(params.filters)) {
    if (value) {
      query = query.ilike(column, `%${value}%`);
    }
  }

  // Apply search across multiple columns
  if (params.search) {
    query = query.or(
      `client_name.ilike.%${params.search}%,` +
      `status.ilike.%${params.search}%`
    );
  }

  // Apply sorting
  query = query.order(params.sortColumn, {
    ascending: params.sortDirection === 'asc',
  });

  // Apply pagination
  const from = params.page * params.pageSize;
  const to = from + params.pageSize - 1;
  query = query.range(from, to);

  const { data, count, error } = await query;

  return { data: data ?? [], totalCount: count ?? 0, error };
}

Server-side is necessary when:

  • The total dataset exceeds 5,000 rows
  • You need full-text search
  • You need complex filtering (date ranges, nested conditions, joins)
  • Memory is a concern (mobile devices, low-powered clients)
  • Data changes frequently and stale client-side data is unacceptable

The tradeoff is latency. Every sort, filter, or page change triggers a network request. Debounce filter inputs and show loading states to keep the experience feeling responsive.

Analytics dashboard displaying data tables and charts

// Debounced filter input
const [filterValue, setFilterValue] = useState('');
const [debouncedFilter] = useDebounce(filterValue, 300);

useEffect(() => {
  fetchTableData({ ...params, filters: { name: debouncedFilter } });
}, [debouncedFilter]);

Pagination strategies

There are three common pagination patterns, each with different UX tradeoffs.

Offset pagination

The classic. Page 1 shows rows 1-25, page 2 shows rows 26-50, and so on. Simple to implement, simple to understand, and it lets users jump to any page directly.

The problem: offset pagination is slow on large datasets. OFFSET 50000 means the database has to scan and discard 50,000 rows before returning the ones you want. On tables with millions of rows, deep pagination gets progressively slower.

Cursor pagination

Instead of “give me page 200,” you say “give me the next 25 rows after this row.” The cursor is typically a unique, sortable column value — a timestamp, an auto-incrementing ID, or a combination.

-- Instead of this (slow for large offsets):
SELECT * FROM invoices ORDER BY created_at DESC OFFSET 5000 LIMIT 25;

-- Use this (constant time regardless of position):
SELECT * FROM invoices
WHERE created_at < '2026-03-01T10:30:00Z'
ORDER BY created_at DESC
LIMIT 25;

Cursor pagination is fast at any depth but does not support jumping to arbitrary pages. It is best for infinite scroll or “load more” patterns where users navigate sequentially.

Hybrid approach

We often use offset pagination for the first few hundred pages and switch to cursor-based when the user scrolls deep. For most business applications, users rarely go past page 10. Optimizing for page 500 is not worth the UX cost of losing page numbers.

TanStack Table

TanStack Table (formerly React Table) is the library we reach for on most projects. It is headless — it provides the logic for sorting, filtering, pagination, column ordering, and row selection without any UI. You bring your own markup and styling.

This matters because every project has different design requirements. A headless library lets you build a table that looks like your app, not like a generic component library.

import {
  useReactTable,
  getCoreRowModel,
  getSortedRowModel,
  getFilteredRowModel,
  getPaginationRowModel,
  flexRender,
  type ColumnDef,
  type SortingState,
} from '@tanstack/react-table';

interface Invoice {
  id: string;
  client: string;
  amount: number;
  status: 'paid' | 'pending' | 'overdue';
  dueDate: string;
}

const columns: ColumnDef<Invoice>[] = [
  {
    accessorKey: 'client',
    header: 'Client',
    cell: (info) => info.getValue(),
  },
  {
    accessorKey: 'amount',
    header: 'Amount',
    cell: (info) =>
      new Intl.NumberFormat('en-US', {
        style: 'currency',
        currency: 'USD',
      }).format(info.getValue<number>()),
  },
  {
    accessorKey: 'status',
    header: 'Status',
    cell: (info) => {
      const status = info.getValue<string>();
      return (
        <span className={`badge badge-${status}`}>
          {status.charAt(0).toUpperCase() + status.slice(1)}
        </span>
      );
    },
    filterFn: 'equals', // Exact match for status filter
  },
  {
    accessorKey: 'dueDate',
    header: 'Due Date',
    cell: (info) =>
      new Date(info.getValue<string>()).toLocaleDateString(),
    sortingFn: 'datetime',
  },
];

function InvoiceTable({ data }: { data: Invoice[] }) {
  const [sorting, setSorting] = useState<SortingState>([]);
  const [globalFilter, setGlobalFilter] = useState('');

  const table = useReactTable({
    data,
    columns,
    state: { sorting, globalFilter },
    onSortingChange: setSorting,
    onGlobalFilterChange: setGlobalFilter,
    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
  });

  return (
    <div>
      <input
        type="text"
        value={globalFilter}
        onChange={(e) => setGlobalFilter(e.target.value)}
        placeholder="Search invoices..."
        className="mb-4 w-full rounded border px-3 py-2"
      />

      <table className="w-full border-collapse">
        <thead>
          {table.getHeaderGroups().map((headerGroup) => (
            <tr key={headerGroup.id}>
              {headerGroup.headers.map((header) => (
                <th
                  key={header.id}
                  onClick={header.column.getToggleSortingHandler()}
                  className="cursor-pointer border-b px-4 py-2 text-left"
                >
                  {flexRender(
                    header.column.columnDef.header,
                    header.getContext()
                  )}
                  {{
                    asc: ' ↑',
                    desc: ' ↓',
                  }[header.column.getIsSorted() as string] ?? ''}
                </th>
              ))}
            </tr>
          ))}
        </thead>
        <tbody>
          {table.getRowModel().rows.map((row) => (
            <tr key={row.id} className="border-b hover:bg-gray-50">
              {row.getVisibleCells().map((cell) => (
                <td key={cell.id} className="px-4 py-2">
                  {flexRender(cell.column.columnDef.cell, cell.getContext())}
                </td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>

      <div className="mt-4 flex items-center gap-2">
        <button
          onClick={() => table.previousPage()}
          disabled={!table.getCanPreviousPage()}
        >
          Previous
        </button>
        <span>
          Page {table.getState().pagination.pageIndex + 1} of{' '}
          {table.getPageCount()}
        </span>
        <button
          onClick={() => table.nextPage()}
          disabled={!table.getCanNextPage()}
        >
          Next
        </button>
      </div>
    </div>
  );
}

This gives you a fully sortable, filterable, paginated table in about 100 lines. The headless approach means the markup is yours — you can use Tailwind, a component library, or plain CSS without fighting the library’s opinions.

Column resizing

Column resizing is one of those features that users rarely ask for but immediately notice when it is missing — especially in data-heavy applications where column content varies in width.

TanStack Table supports column resizing out of the box:

const table = useReactTable({
  // ...other config
  columnResizeMode: 'onChange',
  columnResizeDirection: 'ltr',
});

// In your header cell
<th
  style={{ width: header.getSize() }}
  className="relative"
>
  {/* Header content */}

  {/* Resize handle */}
  <div
    onMouseDown={header.getResizeHandler()}
    onTouchStart={header.getResizeHandler()}
    className={`absolute right-0 top-0 h-full w-1 cursor-col-resize
      ${header.column.getIsResizing() ? 'bg-blue-500' : 'bg-transparent hover:bg-gray-300'}`}
  />
</th>

Persist column widths to localStorage so they survive page reloads. Users who carefully size their columns will be frustrated if the layout resets every time they navigate away.

Person analyzing data in a spreadsheet application on a laptop

Performance tips we have learned the hard way

Memoize row data

If your table data comes from a computation — joining arrays, computing derived fields — memoize the result. Without memoization, every render recalculates every row, even if the source data has not changed.

const processedData = useMemo(() => {
  return rawInvoices.map((invoice) => ({
    ...invoice,
    clientName: clients.get(invoice.clientId)?.name ?? 'Unknown',
    isOverdue: new Date(invoice.dueDate) < new Date() && invoice.status !== 'paid',
    formattedAmount: formatCurrency(invoice.amount),
  }));
}, [rawInvoices, clients]);

Avoid inline cell renderers that create new functions

Every render creating a new function reference for cell means React cannot skip re-rendering that cell. Extract cell renderers to stable references or memoized components.

Debounce global filter

If your table does client-side filtering on every keystroke, 5,000 rows getting filtered 10 times per second will cause jank. Debounce the filter input by 200-300ms.

Use content-visibility: auto for non-virtualized tables

If you have a table with a few hundred rows and do not want the complexity of virtualization, CSS content-visibility: auto tells the browser to skip rendering off-screen rows. It is not as effective as proper virtualization but it is a one-line CSS property that can help.

tbody tr {
  content-visibility: auto;
  contain-intrinsic-size: 0 48px; /* Estimated row height */
}

Accessible tables

Performance is wasted if the table is not usable. A few accessibility essentials:

  • Use semantic <table>, <thead>, <tbody>, <th>, and <td> elements. Screen readers understand these. They do not understand <div> grids pretending to be tables.
  • Add scope="col" to header cells.
  • Mark the current sort with aria-sort="ascending" or aria-sort="descending".
  • If the table updates dynamically (live data, filtering), use aria-live="polite" on a status element that announces the change: “Showing 25 of 1,234 results.”

Tables are one of the most common components in business applications, and they are worth getting right. A table that is fast, sortable, filterable, and accessible is a foundation you will build on for the life of the product.

If you are building a data-heavy web application and need help getting the tables right, reach out at hello@threshline.com.