Scaling PrimeNG DataTables: Lazy Loading with Virtual Scrolling for Large Datasets
Rendering 50,000 rows in a PrimeNG DataTable freezes the browser. Combine lazy loading with virtual scrolling to fetch only visible slices and keep the DOM light.
17 Oct 2026, 10:51 UTC

The bottleneck is the DOM, not just the payload
When a PrimeNG DataTable is asked to display tens of thousands of rows, the browser usually stalls before the network request even finishes. Even if the JSON arrives quickly, creating 50,000 <tr> elements overwhelms layout and memory. Lazy loading reduces the initial payload by fetching data on demand. Virtual scrolling reduces the number of rendered nodes by keeping only the visible slice in the document. Used together, they solve two different halves of the same performance problem.
How the two modes fit together
Setting [lazy]="true" tells the table not to expect a complete array up front. Instead, it emits a LazyLoadEvent whenever it needs more rows. That event carries the current pagination offset, page size, sort field, sort direction, and active filters. Setting [virtualScroll]="true" changes the rendering strategy: the table renders only the rows inside the viewport plus a small buffer, and it triggers the lazy load as the user scrolls. For this to work, the table also needs [scrollable]="true" and a fixed scrollHeight; omitting them often leads to blank rows or flicker.
The LazyLoadEvent properties map cleanly to typical REST query parameters:
| LazyLoadEvent property | Typical REST parameter |
|---|---|
first | offset or page |
rows | limit or pageSize |
sortField | sortBy |
sortOrder | sortDir (1 = asc, -1 = desc) |
filters | Backend-specific filter object |
A worked configuration
The following illustrates a typical setup. It assumes PrimeNG 13 or later and Angular with the standalone or module-based component model. Adjust the import paths to match your project.
<!-- large-table.component.html -->
<p-table
[value]="rows"
[lazy]="true"
[virtualScroll]="true"
[scrollable]="true"
scrollHeight="400px"
[rows]="30"
[totalRecords]="totalRecords"
(onLazyLoad)="loadData($event)">
<ng-template pTemplate="header">
<tr>
<th>ID</th>
<th>Name</th>
</tr>
</ng-template>
<ng-template pTemplate="body" let-row>
<tr>
<td>{{row.id}}</td>
<td>{{row.name}}</td>
</tr>
</ng-template>
</p-table>
// large-table.component.ts
import { Component, ChangeDetectionStrategy } from '@angular/core';
import { LazyLoadEvent } from 'primeng/api';
import { HttpClient, HttpParams } from '@angular/common/http';
@Component({
selector: 'app-large-table',
templateUrl: './large-table.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class LargeTableComponent {
rows: any[] = [];
totalRecords = 0;
constructor(private http: HttpClient) {}
loadData(event: LazyLoadEvent): void {
const params = new HttpParams()
.set('offset', (event.first ?? 0).toString())
.set('limit', (event.rows ?? 30).toString());
if (event.sortField) {
params.set('sortBy', event.sortField);
params.set('sortDir', event.sortOrder === 1 ? 'asc' : 'desc');
}
this.http.get<any>('https://api.example.com/records', { params })
.subscribe(response => {
this.rows = response.data;
this.totalRecords = response.total;
});
}
}
Because the component uses ChangeDetectionStrategy.OnPush, assigning a new array reference to this.rows and a new number to this.totalRecords is what triggers the view update. If you mutate the existing array in place, the table may not refresh.
Trade-offs and version caveats
- No built-in paginator. Virtual scrolling replaces the pager. If your design requires page-number controls, you must implement a custom paginator or accept standard pagination without virtual scroll.
- Server-side sorting and filtering only. Sorting or filtering the current slice on the client produces incorrect results because the table does not hold the full dataset. Always delegate those operations to the backend.
- Older releases have rough edges. In PrimeNG versions before 13.x, virtual scrolling has documented issues with row selection and row expansion. Verify the behavior against your installed version before relying on those features.
- Layout thrashing risk. Nesting virtual scrollable containers inside other scrollable or animated containers can cause repeated layout recalculation. Keep the scrollable area as flat as possible.
- Diminishing returns with caching. Once the client can cache previously fetched pages, the bottleneck shifts from network to rendering. Prefetching the next slice can reduce perceived latency, but it adds state-management complexity.
Checking that it actually works
Two quick diagnostics confirm the combination is behaving as intended:
- Network traffic. Open your browser’s developer tools and scroll the table. Each scroll should emit a request whose query string reflects the current
offsetandlimit. If requests fire on every pixel of scroll rather than when the buffer is exhausted, your[rows]value may be too small relative toscrollHeight. - DOM node count. Inspect the rendered table and count the
<tr>elements inside the body. The count should stay close to[rows]plus a small buffer, not grow towardtotalRecords. If it climbs, virtual scrolling is not active—check thatscrollHeightand[scrollable]are set.
Start with [lazy]="true", [virtualScroll]="true", a fixed scrollHeight, and a loadData handler that maps LazyLoadEvent to your API. If your users need a pager, weigh the DOM savings against the cost of building custom pagination controls.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.