PrimeNG Lazy Loading: Wiring Server-Side Pagination Without the Empty-Table Trap
Large datasets break client-side PrimeNG tables. Learn how to map the onLazyLoad event to backend offset/limit parameters and keep the paginator accurate with totalRecords.
11 Feb 2026, 22:13 UTC

A PrimeNG table that tries to paginate fifty thousand rows in the browser will stall the initial render, freeze sorting, and eventually mislead the user with a paginator that counts data the server never sent. The useful takeaway is that lazy loading is not a performance toggle—it is a contract. You tell the table to stop slicing data locally, and in exchange you must feed it one page at a time from the server while keeping the paginator informed of the true record count.
The lazy flag shifts pagination from browser to server
By default, p-table expects the full dataset in its value array and handles pagination, sorting, and filtering in memory. Setting [lazy]="true" disables that local slicing. The component no longer assumes it owns every row; instead it emits an onLazyLoad event whenever the user changes page, sorts a column, or applies a filter. If you set lazy to true but omit the handler, the table will render headers and an empty body because nothing ever fetches data.
PrimeNG's table API has changed across major versions; the property and event names above reflect the current p-table component. If you are maintaining an older project that still uses p-dataTable, confirm the equivalent lazy and paginator APIs before migrating.
Wiring onLazyLoad to offset and limit
The onLazyLoad event passes a LazyLoadEvent object. The two fields that matter for paging are first, the zero-based index of the first record on the requested page, and rows, the page size. Most REST APIs do not speak in first and rows; they expect offset and limit. The mapping is direct: offset is first, and limit is rows.
A typical Angular template resembles the following:
<p-table
[value]="records"
[lazy]="true"
[paginator]="true"
[rows]="25"
[totalRecords]="totalRecords"
(onLazyLoad)="loadPage($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>
The component handler maps the event to query parameters and assigns the response:
loadPage(event: LazyLoadEvent) {
this.loading = true;
const params = {
offset: event.first ?? 0,
limit: event.rows ?? 25,
sortField: event.sortField,
sortOrder: event.sortOrder
};
this.http.get<{ data: any[]; total: number }>('/api/records', { params })
.subscribe(response => {
this.records = response.data;
this.totalRecords = response.total;
this.loading = false;
});
}
Notice that the request payload stays constant whether the table holds 100 rows or 100,000. Only the offset changes. The response must carry both the page slice and the total count; the array length alone is not enough because the server knows how many rows match the query, not just how many fit on this page.
totalRecords is the paginator's source of truth
The paginator does not count the rows in the value array when lazy mode is active. It reads the totalRecords property to calculate how many pages exist. If you never assign a value, the paginator shows a single page or a stale count. If you assign the length of the returned page instead of the database count, the paginator will think the dataset is exactly one page long. The backend must return a total count—typically from a COUNT query or an equivalent aggregate—and the component must assign it on every response. This is the single most common cause of a broken paginator in lazy tables.
A quick way to confirm the wiring is correct is to open your browser's developer tools and watch the Network tab. Each page change should produce exactly one request whose offset advances by the page size. The response body should include the total count, and the paginator's page selector should match that count divided by the page size. If the request payload grows with the dataset, lazy mode is not actually enabled. If the paginator never advances past page one, totalRecords is missing or stale.
Trade-offs: empty tables, filter complexity, and request volume
Lazy loading trades client-side memory for network round-trips. Three limitations deserve attention before you commit. First, the empty-table trap: [lazy]="true" without an onLazyLoad handler produces a table that looks broken because no data ever arrives. Second, request volume: every sort, filter, and page navigation triggers an HTTP call. If users type quickly in a global filter, consider debouncing the filter input or guarding against overlapping requests. Third, filtering complexity: multi-column filtering requires translating event.filters into backend query parameters. The more filter combinations you support, the more your component becomes a translation layer between PrimeNG's event shape and your API's query language.
Actionable checklist
- Set
[lazy]="true"and bind[totalRecords]on thep-tablecomponent. - Implement
(onLazyLoad)and mapfirstto your API's offset androwsto limit. - Assign the backend total count to
totalRecordsafter every response. - Verify in the Network tab that each page request fetches only the page size.
- Add sorting and filtering one column at a time, checking request parameters each time.
The pattern is simple, but the paginator will only stay honest if the total count never lies.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.