Pagination and $index in Knockout ObservableArrays: Practical Patterns for Large Lists
Learn how to paginate Knockout observable arrays efficiently while still using $index for row‑level logic. This blog covers computed slicing, splice‑based paging, virtualization trade‑offs, and a complete worked example you can drop into a project.
03 Sept 2026, 12:34 UTC

The problem: large client‑side lists kill responsiveness
Knockout’s observableArray makes it trivial to keep the UI in sync when items are added, removed, or reordered. But once the array grows beyond a few hundred entries the browser spends most of its time creating and destroying DOM nodes, and the $index binding that many developers rely on for alternating row styles starts to re‑evaluate on every change. The result is a sluggish UI even though the data model is tiny.
Why $index matters
Inside a foreach binding Knockout injects a special context property called $index. It is a zero‑based observable that updates automatically when the underlying array mutates. Typical uses include:
- Conditional rendering:
<!-- ko if: $index() % 2 === 0 -->…<!-- /ko -->for zebra striping. - Unique keys for child components:
data-bind="attr: { id: 'row-' + $index() }". - Debugging: logging the current position without extra view‑model state.
Because $index is an observable, any binding that reads it becomes a dependency. In a list of 5 000 rows, a single push triggers 5 000 re‑evaluations of the striping logic. That cost is often hidden until the page feels unresponsive.
Client‑side pagination strategies
1. Slice with a computed observable
function ViewModel() {
this.allItems = ko.observableArray(/* 10 000 records */);
this.pageSize = ko.observable(50);
this.currentPage = ko.observable(1);
this.pagedItems = ko.pureComputed(() => {
const start = (this.currentPage() - 1) * this.pageSize();
return this.allItems.slice(start, start + this.pageSize());
});
}
The pagedItems computed returns a plain JavaScript array, so the foreach only ever binds to 50 elements. $index now reflects the position *within the page*, not the global index. If you need the absolute index, compute it as start + $index() inside the template.
2. Separate paging observable with splice
this.visibleItems = ko.observableArray();
this.refreshPage = () => {
const start = (this.currentPage() - 1) * this.pageSize();
const page = this.allItems.slice(start, start + this.pageSize());
this.visibleItems.splice(0, this.visibleItems().length, ...page);
};
Here the same observableArray instance is reused, so any subscriptions on visibleItems fire once per page change. The downside: splice mutates the array in place, which can trigger extra change notifications if other bindings also watch visibleItems.
Performance considerations and virtualization
Even with pagination, a page size of 200–300 rows can still cause noticeable layout thrashing on low‑end devices. Two practical mitigations:
- CSS‑only virtualization: give the container a fixed height and
overflow:auto, then render only the rows that intersect the viewport using a library likeknockout-virtual-list. The library swaps DOM nodes as the user scrolls, keeping the node count constant. - Server‑side paging: fetch only the required page via AJAX and replace
allItemswith the new page data. This eliminates the need for a massive client‑side array altogether.
Both approaches keep the foreach binding small, which in turn limits the number of $index subscriptions.
Worked example: paginated table with alternating rows
The following markup shows a complete, self‑contained Knockout view. It uses the computed‑slice strategy, preserves a global index for tooltips, and applies zebra striping via $index.
<table id="grid">
<thead>
<tr><th>#</th><th>Name</th><th>Status</th></tr>
</thead>
<tbody data-bind="foreach: pagedItems">
<tr data-bind="css: { alt: $index() % 2 === 0 }">
<td data-bind="text: $parent.startIndex() + $index() + 1"></td>
<td data-bind="text: name, attr: { title: 'Row ' + ($parent.startIndex() + $index() + 1) }"></td>
<td data-bind="text: status"></td>
</tr>
</tbody>
</table>
<div class="pager">
<button data-bind="click: prevPage, enable: currentPage() > 1">Prev</button>
<span data-bind="text: 'Page ' + currentPage() + ' of ' + totalPages()"></span>
<button data-bind="click: nextPage, enable: currentPage() < totalPages()">Next</button>
</div>
<script>
function GridVM(rawData) {
this.allItems = ko.observableArray(rawData);
this.pageSize = ko.observable(25);
this.currentPage = ko.observable(1);
this.startIndex = ko.pureComputed(() => (this.currentPage() - 1) * this.pageSize());
this.pagedItems = ko.pureComputed(() => {
const s = this.startIndex();
return this.allItems.slice(s, s + this.pageSize());
});
this.totalPages = ko.pureComputed(() => Math.ceil(this.allItems().length / this.pageSize()));
this.prevPage = () => this.currentPage(this.currentPage() - 1);
this.nextPage = () => this.currentPage(this.currentPage() + 1);
}
// Sample data – 1 000 rows
const sample = Array.from({length: 1000}, (_, i) => ({name: `Item ${i+1}`, status: i%3===0?'Active':'Inactive'}));
ko.applyBindings(new GridVM(sample), document.getElementById('grid'));
</script>
Key points in the example:
startIndexis a pure computed that supplies the global offset.- The row’s tooltip uses
$parent.startIndex() + $index() + 1to show a 1‑based absolute position. - Alternating row class
altis driven solely by$index() % 2 === 0– no extra view‑model state required.
Trade‑offs and limitations
| Approach | Pros | Cons |
|---|---|---|
| Computed slice (pureComputed) | Simple, no mutation side‑effects, easy to test | Creates a new array each page change; GC pressure if page size is large |
| ObservableArray splice | Reuses same array instance, fewer allocations | Triggers change notifications for every removed/added item; can cause cascade re‑renders |
| Virtualized list | Constant DOM node count, smooth scrolling | Requires extra library, more complex template logic |
| Server‑side paging | Minimal client memory, works for arbitrarily large data sets | Network latency on each page, loses instant client‑side filtering/sorting |
Remember that $index is only reliable inside a foreach binding. If you nest another foreach, the inner $index shadows the outer one; use $parentContext.$index to reach the outer index.
How to verify the implementation
- Open the page in Chrome DevTools → Performance tab. Record a page‑change interaction and confirm that the scripting time stays below ~30 ms for a 25‑row page.
- Inspect the DOM: the
tbodyshould contain exactlypageSizetrelements regardless of the total data size. - Toggle the
altclass by adding a temporaryconsole.log($index())in the template (via a custom binding) to ensure the index updates correctly when navigating pages. - Stress‑test: increase
sampleto 50 000 rows and verify that the UI remains responsive and memory usage stays flat.
Actionable next steps
Start by measuring the current render cost of your largest foreach binding. If the script time exceeds 50 ms per interaction, introduce a pureComputed slice as shown above. Keep the page size between 20–50 rows for a good balance between scroll comfort and DOM weight. When the dataset grows beyond 10 000 records, evaluate a virtualized list or move paging to the server. The pattern is deliberately simple: a computed that returns a small array, a pager that mutates only currentPage, and $index doing the heavy lifting for UI concerns.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.