PrimeNG p-table Lazy Loading: When Client-Side Pagination Breaks and How to Fix It
PrimeNG's p-table freezes with large datasets because it paginates in memory. This guide shows how to flip to server-side lazy loading with onLazyLoad, totalRecords, and HttpClient—plus the production trade-offs you'll actually face.
11 Oct 2025, 22:22 UTC

The Problem: Browser Freezes at 10,000 Rows
A PrimeNG p-table with [value]="allCustomers" works fine in development with 50 test records. In production, that same component receives 50,000 rows from an API and the browser tab becomes unresponsive—sorting takes seconds, scrolling stutters, and memory climbs past 500 MB. The component is doing exactly what it was designed for: holding the entire dataset in memory to enable instant client-side sort, filter, and pagination.
The fix is not a configuration tweak but an architectural shift: move pagination, sorting, and filtering to the database so the browser only ever holds the 10–20 rows currently visible.
How Lazy Loading Changes the Contract
Setting [lazy]="true" on p-table flips responsibility. The component stops slicing arrays and starts emitting (onLazyLoad) events containing a LazyLoadEvent with first (zero-based row index), rows (page size), sortField, sortOrder (1 for ascending, -1 for descending), and filters. Your code must fetch the corresponding page and update [value] and [totalRecords].
Two bindings become mandatory: [totalRecords] drives the paginator's page count, and [loading] (optional but recommended) shows a spinner while the request is in flight. Without totalRecords, the paginator shows a single page regardless of actual data size.
Worked Example: Customers Table with Server-Side Sort
This example assumes PrimeNG 17+ and Angular 17+ with standalone components. The LazyLoadEvent import path is primeng/api; verify against your installed version.
Template
<p-table
#dt
[value]="customers"
[lazy]="true"
(onLazyLoad)="onLazyLoad($event)"
[paginator]="true"
[rows]="10"
[totalRecords]="totalRecords"
[loading]="loading"
[rowsPerPageOptions]="[10, 25, 50]">
<ng-template pTemplate="header">
<tr>
<th pSortableColumn="name">Name <p-sortIcon field="name"></p-sortIcon></th>
<th pSortableColumn="email">Email <p-sortIcon field="email"></p-sortIcon></th>
<th pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th>
</tr>
</ng-template>
<ng-template pTemplate="body" let-customer>
<tr>
<td>{{ customer.name }}</td>
<td>{{ customer.email }}</td>
<td>{{ customer.createdAt | date:'short' }}</td>
</tr>
</ng-template>
<ng-template pTemplate="emptymessage">
<tr><td colspan="3">No customers found</td></tr>
</ng-template>
</p-table>Component
import { Component, signal, effect } from '@angular/core';
import { CommonModule } from '@angular/common';
import { TableModule } from 'primeng/table';
import { LazyLoadEvent } from 'primeng/api';
import { CustomerService, CustomerPage } from './customer.service';
@Component({
selector: 'app-customer-list',
standalone: true,
imports: [CommonModule, TableModule],
templateUrl: './customer-list.component.html'
})
export class CustomerListComponent {
customers = signal<any[]>([]);
totalRecords = signal(0);
loading = signal(false);
constructor(private customerService: CustomerService) {}
onLazyLoad(event: LazyLoadEvent): void {
this.loading.set(true);
const page = Math.floor((event.first ?? 0) / (event.rows ?? 10)) + 1;
const size = event.rows ?? 10;
const sortField = event.sortField;
const sortOrder = event.sortOrder === 1 ? 'asc' : 'desc';
this.customerService.getPage(page, size, sortField, sortOrder).subscribe({
next: (res: CustomerPage) => {
this.customers.set(res.data);
this.totalRecords.set(res.total);
this.loading.set(false);
},
error: () => {
this.customers.set([]);
this.totalRecords.set(0);
this.loading.set(false);
}
});
}
}Service Mapping to REST
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
export interface CustomerPage {
data: any[];
total: number;
}
@Injectable({ providedIn: 'root' })
export class CustomerService {
constructor(private http: HttpClient) {}
getPage(page: number, size: number, sortField?: string, sortOrder?: string): Observable<CustomerPage> {
let params = new HttpParams()
.set('page', page)
.set('size', size);
if (sortField) {
params = params.set('sort', `${sortField},${sortOrder}`);
}
return this.http.get<CustomerPage>('/api/customers', { params });
}
}The backend should return { data: [...], total: 12345 } and apply LIMIT/OFFSET (or LIMIT/OFFSET equivalents) plus ORDER BY in the database query. If your API uses zero-based offset instead of page numbers, send event.first directly as offset.
Trade-offs You Will Feel in Production
- Network latency on every interaction. A sort click is now a 150–300 ms round-trip. Mitigate with
[loading]overlay and consider debouncing rapid page clicks (PrimeNG does not debounce automatically). - Backend must do the work. If the service layer loads the full table and slices in Java/Node/Python, you keep the UI complexity but lose the performance gain. Verify the generated SQL uses
LIMIT/OFFSETor keyset pagination. - No instant client-side filter. Global search becomes a debounced API call with a
LIKEor full-text query. Theevent.filtersobject carries column filters; map them to query parameters. - Stale total count. If records are added/deleted concurrently,
totalRecordsdrifts. Refresh it on write mutations or accept approximate pagination.
Verification Steps Before Merge
- Open DevTools Network tab. Click page 2 → confirm request includes
page=2(oroffset=10) and response contains exactly 10 rows. - Click a sortable header → verify
sort=name,ascappears and row order changes. - Change page size via dropdown → verify
sizeparameter updates andtotalRecordsstill yields correct page count. - Trigger an error (stop backend) → confirm
loadingspinner stops and table shows empty state without console errors.
Closing Checklist
Enable [lazy], bind [totalRecords], implement (onLazyLoad) with proper offset/page conversion, and push sort/filter logic to the database. If the backend cannot paginate efficiently, lazy loading adds complexity without benefit—fix the query first.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.