Skip to content
ExponaBLOG
SVELTE & SVELTEKIT · DAY 050 / 100

Tables and large lists for Svelte & SvelteKit developers

Render lots of data efficiently.

Expona Admin7 min read

Rendering thousands of rows of customer data, transaction logs, or product catalogs can tank your Svelte app if you're not careful. We've seen this pattern across e-commerce platforms, CRM systems, and analytics dashboards: developers load everything at once and wonder why the page becomes unresponsive. In this article, we'll walk through proven techniques for tables and large lists for Svelte & SvelteKit developers that keep your UI snappy and your users happy.

Keyed each blocks

The foundation of efficient list rendering in Svelte is understanding the key directive in each blocks. Without a key, Svelte performs DOM patching that can become expensive and unpredictable, especially when your data changes, filters, or reorders.

Consider a product listing where you're adding items to a cart or marking them as favorites. Here's what not to do:

typescript
<!-- ❌ No key: risky and slow -->
{#each products as product}
  <div class="product-row">
    <input type="checkbox" bind:checked={product.favorite} />
    {product.name}
  </div>
{/each}

When your product array reorders or updates, Svelte doesn't know which row corresponds to which product. The checkbox state might get mixed up, or the DOM might re-render unnecessarily. Always use a stable, unique identifier as the key:

typescript
<!-- ✓ Keyed by unique ID -->
{#each products as product (product.id)}
  <div class="product-row">
    <input type="checkbox" bind:checked={product.favorite} />
    {product.name}
  </div>
{/each}

In production systems handling ride-hailing ride histories or e-commerce order lists, we've seen this single change reduce re-render times by 40–60% when the data updates frequently. The key tells Svelte to maintain component identity across renders, preserving internal state and DOM references.

Virtual lists for thousands of rows

Once your table exceeds a few hundred rows, the DOM itself becomes the bottleneck. Virtual scrolling (windowing) solves this by rendering only the visible rows plus a small buffer, removing and adding DOM nodes as the user scrolls.

Imagine a trading platform showing 50,000 historical transactions, or a travel platform listing 20,000 flights. Rendering all of them causes browser memory spikes and jank. The solution is a virtual list library. We recommend svelte-virtual-list or svelte-window, or building your own lightweight scroller if you need fine-grained control.

Here's a practical example using a community-maintained virtual scrolling library:

typescript
<script>
  import VirtualList from 'svelte-virtual-list';
  
  let transactions = []; // Your large dataset
  let rowHeight = 48; // Fixed row height in pixels
</script>

<VirtualList items={transactions} let:item height="600px" {rowHeight}>
  <div class="transaction-row">
    <span>{item.id}</span>
    <span>{item.amount}</span>
    <span>{item.timestamp}</span>
  </div>
</VirtualList>

The virtual list wrapper manages a scrollable container and only renders rows that fit in the viewport. When you scroll, it efficiently unmounts rows above and below the visible area, keeping your DOM slim. We've deployed this pattern in production systems handling 100,000+ rows with no performance degradation.

A critical detail: use fixed row heights whenever possible. This allows the virtual scroller to calculate which rows are visible without measuring the DOM. If your rows have variable heights, the scroller becomes slower, but it's still feasible for 5,000–10,000 rows.

Server-side pagination

Virtual scrolling is powerful, but it doesn't reduce server load or network bandwidth. For truly massive datasets—millions of customer records in a CRM, or billions of log entries in an analytics system—you need server-side pagination.

The strategy is simple: fetch data in chunks (pages) as the user navigates. Here's a SvelteKit example:

typescript
<script>
  import { page } from '$app/stores';
  
  let currentPage = 1;
  let pageSize = 50;
  let totalRecords = 0;
  let records = [];
  
  async function loadPage(pageNum) {
    const response = await fetch(
      `/api/records?page=${pageNum}&limit=${pageSize}`
    );
    const data = await response.json();
    records = data.records;
    totalRecords = data.total;
    currentPage = pageNum;
  }
  
  // Load first page on mount
  $: loadPage(currentPage);
</script>

<div class="table-container">
  <table>
    <thead>
      <tr>
        <th>ID</th>
        <th>Name</th>
        <th>Status</th>
      </tr>
    </thead>
    <tbody>
      {#each records as record (record.id)}
        <tr>
          <td>{record.id}</td>
          <td>{record.name}</td>
          <td>{record.status}</td>
        </tr>
      {/each}
    </tbody>
  </table>
</div>

<div class="pagination">
  <button disabled={currentPage === 1} on:click={() => loadPage(1)}>
    First
  </button>
  <button disabled={currentPage === 1} on:click={() => loadPage(currentPage - 1)}>
    Previous
  </button>
  <span>Page {currentPage} of {Math.ceil(totalRecords / pageSize)}</span>
  <button 
    disabled={currentPage * pageSize >= totalRecords} 
    on:click={() => loadPage(currentPage + 1)}
  >
    Next
  </button>
</div>

On the server, your endpoint should accept page and limit query parameters and return only the requested slice of data along with the total count. This keeps network payload small and lets you scale to millions of records without client-side memory issues.

For SvelteKit, you can combine this with load functions to pre-fetch data server-side, reducing the initial load time and improving SEO if the table is search-indexed.

Sort and filter on the server

Sorting and filtering 100,000 rows in the browser is wasteful. Push that logic to the server where your database can use indexes and perform the work efficiently.

Extend your pagination API to accept sort and filter parameters:

typescript
<script>
  let sortBy = 'name';
  let sortOrder = 'asc';
  let filterStatus = '';
  
  async function loadPage(pageNum) {
    const params = new URLSearchParams({
      page: pageNum,
      limit: pageSize,
      sort: sortBy,
      order: sortOrder,
    });
    
    if (filterStatus) {
      params.append('status', filterStatus);
    }
    
    const response = await fetch(`/api/records?${params}`);
    const data = await response.json();
    records = data.records;
    totalRecords = data.total;
  }
  
  function handleSort(column) {
    if (sortBy === column) {
      sortOrder = sortOrder === 'asc' ? 'desc' : 'asc';
    } else {
      sortBy = column;
      sortOrder = 'asc';
    }
    currentPage = 1;
    loadPage(currentPage);
  }
  
  function handleFilter(status) {
    filterStatus = status;
    currentPage = 1;
    loadPage(currentPage);
  }
</script>

<div class="controls">
  <select on:change={(e) => handleFilter(e.target.value)}>
    <option value="">All statuses</option>
    <option value="active">Active</option>
    <option value="inactive">Inactive</option>
  </select>
</div>

<table>
  <thead>
    <tr>
      <th>
        <button on:click={() => handleSort('name')}>
          Name {sortBy === 'name' ? (sortOrder === 'asc' ? '↑' : '↓') : ''}
        </button>
      </th>
      <th>
        <button on:click={() => handleSort('status')}>
          Status {sortBy === 'status' ? (sortOrder === 'asc' ? '↑' : '↓') : ''}
        </button>
      </th>
    </tr>
  </thead>
  <tbody>
    {#each records as record (record.id)}
      <tr>
        <td>{record.name}</td>
        <td>{record.status}</td>
      </tr>
    {/each}
  </tbody>
</table>

Your backend endpoint receives the sort column, order, and filter criteria, applies them before querying the database, and returns the filtered and sorted results. This is how mature platforms in ride-hailing, travel, and SaaS handle user-facing tables with millions of rows. The database does the heavy lifting, and your Svelte component stays lightweight and responsive.

Common mistakes

  • Forgetting keys in each blocks. This causes unnecessary re-renders and can corrupt component state. Always use a stable, unique identifier.
  • Rendering all data upfront. If you have 10,000+ rows and no virtual scrolling or pagination, your app will stutter on first load and consume excessive memory.
  • Client-side sort and filter on large datasets. Sorting 50,000 records in JavaScript is slow. Use server-side filtering and sorting with database indexes for orders of magnitude better performance.
  • Variable row heights without measuring. If you use a virtual scroller with variable heights and don't provide an accurate row-height calculation, the scroller will jump and behave erratically during scrolling.
  • Ignoring pagination limits. Sending 10,000 rows over the network because a user didn't scroll wastes bandwidth and time. Always paginate.

Key takeaways

  • Use keyed each blocks with stable identifiers to ensure predictable re-renders and preserve component state.
  • For datasets with hundreds to thousands of rows, deploy virtual scrolling to render only visible rows and dramatically reduce DOM size and memory pressure.
  • For larger datasets, implement server-side pagination to fetch data in manageable chunks and keep network traffic and memory usage constant regardless of total data size.
  • Push sort and filter logic to the server, leveraging database indexes for fast, scalable operations instead of filtering arrays in JavaScript.
  • Test your table performance with realistic data volumes in production-like environments; a table that feels fast with 100 rows may choke with 10,000.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles