Keep large PrimeNG tables responsive with virtual scrolling and lazy loading
Bind [virtualScroller] and lazy=true on p-table to keep the DOM bounded and fetch only visible rows from your backend.
24 Sept 2025, 19:49 UTC

The problem: thousands of table rows freeze the browser
You bind a PrimeNG p-table to a collection of 10,000 records. Angular renders a DOM node for every row, the page becomes unresponsive, and the browser tab's memory use climbs. Sorting or filtering feels sluggish because the entire dataset lives in the client. This is a DOM-bound problem, not a network problem.
The practical fix is to keep only visible rows in the DOM and request data from the server in pages. PrimeNG supports this by combining [virtualScroller] with lazy="true". Virtual scrolling reuses a small set of row elements as the user scrolls; lazy loading means the table emits a LazyLoadEvent whenever it needs a new page instead of holding every record in memory.
Template: bounded rows and server-side pages
Add TableModule to your Angular module, and BrowserAnimationsModule if your PrimeNG version requires animations for table features. Then configure the table like this:
<p-table
[value]="records"
[virtualScroller]="true"
[virtualScrollItemSize]="48"
[lazy]="true"
[paginator]="false"
[scrollable]="true"
[scrollHeight]="'500px'"
(onLazyLoad)="onLazyLoad($event)"
>
<ng-template pTemplate="header">
<tr>
<th pSortableColumn="id">Id</th>
<th pSortableColumn="name">Name</th>
</tr>
</ng-template>
<ng-template pTemplate="body" let-record>
<tr>
<td>{{ record.id }}</td>
<td>{{ record.name }}</td>
</tr>
</ng-template>
</p-table>
Run this in an Angular component template. No special permissions are required. Replace records and onLazyLoad with your own properties. virtualScrollItemSize is the estimated height of one row in pixels, including padding; choose a conservative value because variable row heights can make the scrollbar drift. [value] may need to be a placeholder array whose length equals the total record count so the scrollbar represents the full set; confirm this behavior against your installed PrimeNG version.
Component: map LazyLoadEvent to your API
import { Component } from '@angular/core';
import { LazyLoadEvent } from 'primeng/api';
export interface Record { id: number; name: string; }
@Component({
selector: 'app-records-table',
templateUrl: './records-table.component.html',
})
export class RecordsTableComponent {
records: Record[] = [];
totalRecords = 0;
loading = false;
constructor(private recordsService: RecordsService) {}
onLazyLoad(event: LazyLoadEvent) {
this.loading = true;
const page = (event.first ?? 0) / (event.rows ?? 50);
this.recordsService
.query({
page,
pageSize: event.rows ?? 50,
sortField: event.sortField,
sortOrder: event.sortOrder,
filters: event.filters,
})
.subscribe({
next: (response) => {
this.records = response.data;
this.totalRecords = response.total;
this.loading = false;
},
error: () => {
this.loading = false;
},
});
}
}
PrimeNG does not call your API automatically. The LazyLoadEvent carries first, rows, sort state, and filter state; your service must translate those into backend query parameters and return only the requested page. Do not apply client-side sorting or filtering unless the full dataset is already loaded, because the results would be incomplete.
Check your PrimeNG version first
Virtual scrolling options change between major versions. Run this in your project root:
grep '"primeng"' package.json
Required permissions: none. Expected check: a line such as "primeng": "16.0.0". Risk: if your version predates the virtual scrolling API or uses different property names, the template may fail silently. Compare the result against your release's component documentation before shipping.
Verify the result in the browser
- Open DevTools, choose the Elements panel, and inspect the table wrapper.
- Scroll through several thousand rows. The number of
<tr>elements should stay roughly constant, equal to the visible rows plus a small buffer. - Open the Network panel. Each scroll movement that crosses a page boundary should produce a request containing the lazy-load parameters.
- Resize the window, scroll to the bottom, then scroll back up. Rows should not duplicate or vanish, and the scroll position should remain stable.
Trade-offs and limitations
- Virtual scrolling assumes stable row heights. Wrapped text, images, or theme-driven padding can cause scroll-position drift.
- Row expansion, detail templates, and some cell editors may behave differently under virtual scrolling. Validate complex templates early.
- Virtualizing rows does not help if the table has hundreds of wide columns. Keep the column count reasonable.
- If users need page numbers and exact totals, traditional paginator with
lazyLoadMetadatais often simpler than virtual scrolling.
Closing
Start with a conservative row height, test with at least five thousand rows, and confirm the DOM node count stays flat while scrolling. If the scroll position drifts or complex cell templates break, fall back to paginated lazy loading. The goal is a bounded DOM, not zero server requests.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.