PrimeNG Lazy Mode: Turning Your Table Into a Server Contract
PrimeNG's lazy mode turns your table into a query builder. The LazyLoadEvent contract, the totalRecords trap, and how to keep the paginator honest.
23 May 2026, 03:31 UTC

Picture the product table that behaved perfectly in staging with 200 rows, then met production's 380,000. First paint stalls, sorting freezes the tab, and the API ships megabytes of JSON for data nobody will ever scroll to. PrimeNG's table component has a supported answer to exactly this problem: lazy mode.
The thesis is simple. Once your dataset outgrows the browser, the table should stop being a data store and start being a query builder. Set lazy to true, treat onLazyLoad as the query, and make your API the single source of truth for both rows and counts. Get that contract right and the table stays fast; get it half-right and the paginator starts lying to your users.
What lazy mode actually changes
By default, p-table does everything in the browser: you hand it the full array via [value], and it paginates, sorts and filters that array locally. That is the right default for a few thousand rows — instant, and no backend work.
Flip on [lazy]="true" and the component stops doing all of that. Every pagination, sort and filter action instead emits an onLazyLoad event describing what the user asked for, and fetching exactly that slice becomes your job. The event, LazyLoadEvent from primeng/api, carries:
- first — the zero-based index of the first row on the requested page
- rows — the page size
- sortField and sortOrder — the sorted column and direction (1 ascending, -1 descending, undefined when unsorted)
- filters — a map of column field to filter metadata such as
valueandmatchMode(for example'contains')
Two inputs become your responsibility: totalRecords, which drives the paginator, and a fresh [value] array after each fetch. Multi-column sorting and per-filter operators exist in newer releases, but the exact shape of the filter metadata is the most version-sensitive part of this API — check the LazyLoadEvent interface in your installed PrimeNG package before writing the translator.
From table event to API query
The handler lives in the component that hosts the table; the HTTP call belongs in an injectable service. A minimal version:
import { Component } from '@angular/core';
import { LazyLoadEvent } from 'primeng/api';
import { ProductService } from './product.service';
@Component({
selector: 'app-product-list',
templateUrl: './product-list.component.html'
})
export class ProductListComponent {
products: Product[] = [];
totalRecords = 0;
loading = false;
constructor(private productService: ProductService) {}
loadProducts(event: LazyLoadEvent) {
this.loading = true;
this.productService.search({
offset: event.first, // zero-based index of the page's first row
limit: event.rows, // page size
sortField: event.sortField, // undefined when unsorted
sortOrder: event.sortOrder, // 1 ascending, -1 descending
filters: event.filters // field -> { value, matchMode }
}).subscribe(page => {
this.products = page.items;
this.totalRecords = page.total;
this.loading = false;
});
}
}<p-table
[value]="products"
[lazy]="true"
(onLazyLoad)="loadProducts($event)"
[paginator]="true"
[rows]="20"
[totalRecords]="totalRecords"
[loading]="loading">
<!-- column and row templates omitted; template syntax varies by major version -->
</p-table>The first thing to do with this wiring is not to ship it — it's to log it. Add a console.log(event) at the top of the handler, click through paging, sorting and a filter, and confirm the event fields match what you clicked. Then watch the browser's network tab: you should see exactly one request per interaction, carrying the offset, limit, sort and filter parameters.
The subtle part is filters. The table sends a matchMode such as 'contains' or 'equals' alongside the value, and your API has to implement the same semantics — otherwise the UI shows a filter chip while the results mean something else. Decide early which match modes your backend actually supports, and restrict the column filters to those.
The costs you inherit
Lazy mode moves work; it doesn't delete it.
The paginator is only as honest as your count. totalRecords must reflect the real number of matching rows, or the last page will be empty or unreachable. On very large tables, an exact count on every page request can dominate latency, so teams commonly cache the count or refresh it periodically — a deliberate trade of paginator precision for speed, not an accident to drift into.
Payload reduction is not render reduction. Lazy loading fixes the network and memory problem; a row template with dozens of components per cell can still choke change detection. Keep row templates lean regardless of mode.
Every filter and sort combination your UI can emit is now a query your API must run efficiently, which usually means indexes on the sortable and filterable columns.
And lazy isn't always the answer:
| Situation | Better fit |
|---|---|
| A few thousand rows, simple interactions | Default client-side mode |
| A very tall, uniform list with light chrome | Virtual scrolling |
| Hundreds of thousands of rows, server-side filters or row-level permissions | Lazy mode |
One pairing worth knowing: stateStorage="session" with a stateKey preserves the current page, sort and filters across navigation, so returning to the list doesn't dump the user back on page 1 and refetch blindly.
Verify the wiring before you ship
The lazy contract has been stable across PrimeNG's Angular-era releases, but the examples here follow the PrimeNG 17-era table API — treat the filter metadata shape as the first thing to verify against your installed version. A short checklist:
- Log one
LazyLoadEventper interaction type (page, sort, filter) and confirmfirst,rows,sortField,sortOrderandfiltersmatch the click. - Confirm the API's total equals what a direct query returns, and that the paginator's last page renders rows rather than an empty shell.
- Round-trip one filter end to end: type a
'contains'filter, inspect the request parameters and the returned rows, and confirm the semantics agree. - Open the
LazyLoadEventtyping in your installedprimengpackage and reconcile field names — especiallyfilters— with your translator. - Compare modes with browser devtools on a representative dataset: payload per page, and how long a sort takes to reflect.
If all five pass, the table is genuinely server-driven: the browser holds one page of data, the paginator tells the truth, and the 380,000-row problem stays where it belongs — in the database.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.