PrimeNG p-table: Choosing Client-Side vs Lazy (Server-Side) Loading
Decide between client-side and lazy (server-side) loading in PrimeNG's p-table: constraints, a compact comparison, trade-offs, and a working lazy-load pattern with validation steps.
02 Apr 2026, 04:18 UTC

Your Angular grid works fine in development with 200 rows, then a real customer loads 40,000 records and the tab freezes. The decision you face is whether p-table should paginate, sort, and filter in the browser or delegate all of that to your backend. The short answer: if the dataset can grow beyond a few thousand rows, use lazy loading; if it is a bounded lookup list, keep it client-side and save yourself the backend work.
The decision and its constraints
PrimeNG's p-table supports both modes natively. In client-side mode you bind the full array to [value] and PrimeNG paginates, sorts, and filters in memory. In lazy mode ([lazy]="true") the table renders only what you give it and emits a LazyLoadEvent whenever the user pages, sorts, or filters — your code turns that event into an API call.
The constraints that actually matter:
- Dataset size ceiling. Client-side mode transfers and holds every row. Payload and memory grow linearly; tens of thousands of rows with several columns each will visibly degrade rendering and initial load.
- Backend capability. Lazy mode requires an endpoint that accepts page offset, page size, sort field/direction, and filter criteria. If you cannot change the API, lazy mode is off the table.
- Interaction feel. Client-side sorting and filtering are instant. Lazy mode adds a network round trip to every interaction, so you must manage loading state and debounce text filters.
Option comparison
| Concern | Client-side | Lazy (server-side) |
|---|---|---|
| Dataset size | Up to a few thousand rows comfortably | Effectively unbounded |
| Initial payload | Full dataset | One page plus a total count |
| Sort/filter latency | Instant (in memory) | One request per interaction |
| Backend work | None beyond a list endpoint | Paged query contract with sort/filter params |
| Component complexity | Low — bind array, done | Moderate — handle LazyLoadEvent, totalRecords, loading flag |
| Consistency risk | Low | Server match modes must mirror PrimeNG filter semantics |
Trade-offs worth understanding before you commit
Lazy mode is not "client-side but bigger." It changes who owns the logic. Built-in global filtering and column sorting do not operate on the client in lazy mode — every interactive control must funnel through the same onLazyLoad path. If you mix modes (lazy pagination but client sorting), users will sort only the current page and see inconsistent results. Pick one mode per table.
Filtering semantics are the other trap. PrimeNG describes filters with metadata such as match mode (contains, equals, etc.). Your backend must implement the same semantics, or a user who types "smi" expecting "Smith" gets nothing because the server did an exact match.
Finally, large page sizes undermine lazy mode. Rendering 500 rows per page still costs DOM work; for very large pages consider PrimeNG's virtual scroll support instead of raising rows.
Concrete lazy-loading implementation
Template (Angular component template, PrimeNG table):
<p-table
[value]="rows"
[lazy]="true"
[paginator]="true"
[rows]="20"
[totalRecords]="total"
[loading]="loading"
(onLazyLoad)="load($event)">
<ng-template pTemplate="header">
<tr>
<th pSortableColumn="name">Name <p-sortIcon field="name"></p-sortIcon></th>
<th>Email</th>
</tr>
</ng-template>
<ng-template pTemplate="body" let-r>
<tr><td>{{ r.name }}</td><td>{{ r.email }}</td></tr>
</ng-template>
</p-table>Component logic. The event carries first (row offset), rows (page size), sortField/sortOrder, and filters. Map them to your API's parameters — the exact names are your contract:
load(event: LazyLoadEvent) {
this.loading = true;
const params = {
offset: event.first ?? 0,
limit: event.rows ?? 20,
sort: event.sortField,
dir: event.sortOrder === 1 ? 'asc' : 'desc',
name: event.filters?.['name']?.value ?? ''
};
this.api.queryCustomers(params).subscribe(resp => {
this.rows = resp.items; // reassign, don't mutate in place
this.total = resp.total; // paginator needs this
this.loading = false;
});
}Three pitfalls account for most broken lazy tables:
- Forgetting
[totalRecords]. Without it the paginator assumes the bound array is everything and renders a single page. - Mutating the array in place. Pushing into
this.rowsmay not trigger change detection; always assign a new array reference. - No debounce on text filters. Wire filter inputs through a 300–500 ms debounce (e.g., RxJS
debounceTime) so each keystroke does not fire a request.
How to validate the choice
Generate a test endpoint returning 10,000 synthetic rows and verify behavior in browser dev tools:
- With
[lazy]="true", confirm a network request fires on page change, column sort, and filter entry — and that no request fires when you have not interacted. - Check the paginator's page count matches
totalRecords / rows. - Test edge cases: sort while on page 3 (the event should carry both state values), clear a filter (a request with empty criteria should fire), and type rapidly in a filter box (debounce should collapse this into one request).
- Compare against client-side mode on the same data: measure initial payload size and time-to-interactive. This gives you a concrete row-count threshold for your own app rather than a rule of thumb.
Limitations and version caveats
The exact shape of LazyLoadEvent and the filter metadata object varies across PrimeNG major versions, as do virtual scroll inputs. Before finalizing your backend contract, check the documentation or changelog for the PrimeNG version actually installed (npm list primeng in your project). The pattern above reflects long-standing behavior, but treat property names as version-dependent and confirm them in your build.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.