Implementing Virtual Scrolling in Svelte for Heavy Rails Data Tables
In the late 19th century, early motion picture devices like the kinetoscope faced a fundamental physical constraint: how to display a lengthy moving image without showing the entire reel of film at once. The solution was to only expose a single frame to the viewer’s eye at any given moment, rapidly swapping them out as the reel advanced. The viewer perceived a continuous, unbroken scene, but the physical apparatus was only ever processing a tiny fraction of the total data.
This physical constraint parallels a very modern problem in web development. A common requirement in Rails applications—especially administrative dashboards, CRM interfaces, and financial reporting tools—is displaying large datasets in a tabular format. When dealing with thousands of records, rendering every row simultaneously in the Document Object Model (DOM) can create significant performance bottlenecks. Browsers struggle to calculate layout and paint thousands of nodes, resulting in a frozen interface and a degraded user experience.
There are three major approaches to handling this volume of data; depending on the particular circumstances you find yourself in, one of them may be more useful than the other two.
The first is traditional server-side pagination, where the backend only delivers a small page of records at a time. This is the oldest and most mature approach in the Rails ecosystem.
The second is infinite scrolling, where the browser loads an initial set of records and appends more as the user scrolls. This approach is popular in social media feeds, but it eventually suffers from the same DOM bloat if the user scrolls far enough.
The third option is virtual scrolling. Like the kinetoscope, virtual scrolling only renders the subset of data that fits within the user’s current viewport, plus a small buffer. As the user scrolls, the component dynamically recycles DOM elements, replacing the old data with new data. The user perceives a continuous, massive table, but the browser is only ever rendering a few dozen rows.
Generally speaking, pagination is a simpler approach that works well for many applications. Virtual scrolling, though, often makes more sense if your users demand continuous scrolling or immediate access to a full dataset without page reloads. For the remainder of this article, we will focus on implementing this third approach using Svelte and Inertia.js within a Rails application.
Understanding Virtual Scrolling Mechanics
Strictly speaking, virtual scrolling isn’t scrolling at all—at least not in the way the browser’s rendering engine understands it. Instead of inserting 10,000 <tr> elements into the document, we calculate the total height of a scrollable container. Inside that container, we position a much smaller number of rows (say, 50) using absolute positioning.
One may wonder: if we are only rendering 50 rows, how do we handle scrolling through 10,000 items? The answer lies in capturing the scroll event. As the user moves the scrollbar, we capture the scroll event and use it to calculate a new subset of data to display. We dynamically recycle the DOM elements, replacing the old data with new data and shifting the absolute position of the rows downward.
This reduces the DOM footprint from thousands of nodes to mere dozens, eliminating the primary source of browser latency. Implementing this, though, requires three core calculations:
- The total height of the container (
Total Items * Item Height). - The index of the first visible item, based on the current scroll offset.
- The index of the last visible item, based on the viewport height.
Preparing the Rails Controller
Before we get into building the frontend component, though, let’s take a step back and look at our Rails backend. When transmitting large arrays over the wire, optimizing serialization is critical. Standard Rails to_json can be slow and memory-intensive for large Active Record collections. We recommend using a faster, more efficient JSON serializer. For example, the oj gem is a popular choice that directly replaces the default JSON encoder. You can add it to your Gemfile like this:
# Gemfile
gem 'oj'
After running bundle install, Rails will use it automatically. Alternatively, gems like blueprinter offer more control over the serialization process. Whichever tool you choose, picking only the necessary columns is a crucial step to reduce memory allocation both on the server and the client.
# app/controllers/reports_controller.rb
class ReportsController < ApplicationController
def index
# We load a large dataset, optimizing the query to select only required fields.
transactions = Transaction
.order(created_at: :desc)
.limit(5000)
.select(:id, :amount, :status, :created_at, :customer_id)
# If using Inertia.js, we pass the serialized array directly to Svelte
render inertia: "Reports/Index", props: {
transactions: transactions.as_json
}
end
end
Building the Svelte Virtual Scroller Component
With our data available on the client, we can construct the virtual scrolling table. We will create a reusable VirtualTable.svelte component that encapsulates all the necessary logic. Svelte’s declarative syntax and reactive statements are particularly well-suited for calculating the visible slice of data.
Here is the complete component:
<!-- app/javascript/components/VirtualTable.svelte -->
<script>
// Props: the full dataset, the height of a single row, and the container's visible height.
export let items = [];
export let itemHeight = 40; // Assumes a fixed height in pixels
export let containerHeight = 600;
// Reactive state: tracks the container's vertical scroll position.
let scrollTop = 0;
// Reactive calculations ($:) re-run whenever their dependencies change.
// 1. Calculate the total scrollable height based on all items.
// This value is used to create a "spacer" element that makes the scrollbar behave correctly.
$: totalHeight = items.length * itemHeight;
// 2. Determine the index of the first visible item based on the scroll position.
// We use Math.floor to get a whole number index.
$: startIndex = Math.max(0, Math.floor(scrollTop / itemHeight));
// 3. Determine the index of the last visible item.
// We add a small buffer (e.g., 5 items) to prevent visual flickering during fast scrolls.
$: endIndex = Math.min(
items.length - 1,
Math.floor((scrollTop + containerHeight) / itemHeight) + 5
);
// 4. Slice the original array to get the subset of items that should be rendered.
$: visibleItems = items.slice(startIndex, endIndex + 1);
// Event handler to update scrollTop when the user scrolls the container.
function handleScroll(event) {
scrollTop = event.target.scrollTop;
}
</script>
<!--
The main container has a fixed height and `overflow-y: auto` to enable scrolling.
The `on:scroll` directive binds our handler to the scroll event.
-->
<div
class="virtual-scroll-container"
style="height: {containerHeight}px;"
on:scroll={handleScroll}
>
<!--
The inner container acts as a spacer. Its height is the total height of all items,
which ensures the scrollbar accurately reflects the full dataset size.
-->
<div class="virtual-scroll-inner" style="height: {totalHeight}px;">
<!--
We iterate over only the `visibleItems`. The `(item.id)` is a keyed each block,
which helps Svelte efficiently update the DOM when items change.
-->
{#each visibleItems as item, i (item.id)}
<!--
Each row is positioned absolutely. The `top` value is calculated based on its
actual index in the full `items` array, not its index in `visibleItems`.
-->
<div
class="virtual-row"
style="height: {itemHeight}px; top: {(startIndex + i) * itemHeight}px;"
>
<!-- Render your data columns here. This part is specific to your application. -->
<span class="cell">{item.id}</span>
<span class="cell">${item.amount}</span>
<span class="cell">{item.status}</span>
</div>
{/each}
</div>
</div>
<style>
.virtual-scroll-container {
overflow-y: auto;
position: relative;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
}
.virtual-scroll-inner {
position: relative;
width: 100%;
}
.virtual-row {
position: absolute;
width: 100%;
display: flex;
align-items: center;
border-bottom: 1px solid #f3f4f6;
padding: 0 1rem;
box-sizing: border-box;
}
.cell {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
Handling Dynamic Row Heights
The implementation above relies on a fixed itemHeight. Fixed heights guarantee exact scrollbar dimensions and precise index calculations. However, if your table rows contain variable amounts of text that cause them to wrap and change height, the complexity increases significantly.
Handling dynamic heights requires tracking the rendered height of every individual node and continuously adjusting the container’s total height as new items are measured. While possible, this introduces substantial computational overhead. For most administrative tables in a Rails application, enforcing a fixed row height with CSS white-space: nowrap and text-overflow: ellipsis is the most pragmatic approach. It ensures maximum performance while keeping the codebase maintainable.
Optimizing for Performance and Maintainability
While this component is efficient, we should also consider its long-term maintainability and performance characteristics in a production application. There are a few key points to keep in mind.
- Memory Management: While virtual scrolling solves DOM bloat, the JavaScript runtime still holds the entire array in memory. For extremely large datasets (e.g., over 50,000 records), the browser may still struggle. In such cases, combine this technique with an infinite loading strategy. You would start with an initial subset of data and fetch additional chunks from the server as the user scrolls near the end of the currently loaded data.
- Keyed Blocks: Notice the
(item.id)in our{#each}block. Providing a unique identifier is crucial. It tells the Svelte compiler exactly which DOM nodes to update, move, or remove, rather than re-rendering the entire visible list when the array slice changes. - Scroll Throttling: For rows containing many interactive components or complex rendering logic, the
handleScrollevent can fire at a very high rate. To prevent performance issues, it is often wise to throttle the event handler usingrequestAnimationFrame. This ensures that yourscrollTopstate is updated at a rate that matches the browser’s rendering cycle, which can lead to a smoother user experience.
Conclusion
Rendering large datasets efficiently requires acknowledging the limitations of the browser’s rendering engine. By implementing a virtual scroller in Svelte, you can deliver heavy data tables from your Rails API without compromising user experience. This technique limits the DOM footprint, provides a fluid interface, and integrates cleanly into modern Rails architectures utilizing Inertia.js. We encourage you to analyze your administrative interfaces and apply virtual scrolling where DOM complexity has become a bottleneck.
Sponsored by Durable Programming
Need help maintaining or upgrading your Ruby on Rails application? Durable Programming specializes in keeping Rails apps secure, performant, and up-to-date.
Hire Durable Programming