PrimeNG DataTable Lazy Loading: Server-Side Pagination for Large Datasets
Move PrimeNG DataTable pagination, sorting, and filtering to the backend with lazy loading. This guide shows a complete Angular component example, the required LazyLoadEvent handling, backend contract expectations, and common mistakes like sort-order mismatch and missing totalRecords.
26 Sept 2026, 05:54 UTC

The Problem: Client-Side Pagination Doesn't Scale
When an Angular application loads thousands of rows into a PrimeNG DataTable, the browser becomes the bottleneck. Memory usage spikes, initial render time grows, and user interactions like sorting or filtering freeze the UI. The fix is to move pagination, sorting, and filtering to the backend so the DataTable only ever receives the rows visible on the current page.
How Lazy Loading Works in PrimeNG
PrimeNG's DataTable exposes a lazy property. When set to true, the component stops managing data internally and instead emits an onLazyLoad event whenever the user changes page, sorts a column, or applies a filter. Your component receives a LazyLoadEvent object containing:
first— zero-based index of the first row to displayrows— number of rows per page (page size)sortField— column field name being sortedsortOrder—1for ascending,-1for descendingfilters— key-value map of column filters
Your job is to translate these parameters into a backend request, then bind the returned data array and total record count to the table.
Worked Example: Angular Component with Server-Side Pagination
The following example assumes Angular 17+, PrimeNG 17+, and a REST endpoint at /api/products that accepts query parameters page, size, sort, direction, and optional filter fields. Adjust the parameter names to match your API contract.
// product-list.component.ts
import { Component, OnInit } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { LazyLoadEvent } from 'primeng/api';
import { TableModule } from 'primeng/table';
import { CommonModule } from '@angular/common';
interface Product {
id: number;
name: string;
category: string;
price: number;
stock: number;
}
interface PagedResponse {
data: Product[];
totalRecords: number;
}
@Component({
selector: 'app-product-list',
standalone: true,
imports: [CommonModule, TableModule],
template: `
Name
Category
Price
Stock
{{ product.name }}
{{ product.category }}
{{ product.price | currency }}
{{ product.stock }}
`
})
export class ProductListComponent implements OnInit {
products: Product[] = [];
totalRecords = 0;
loading = false;
constructor(private http: HttpClient) {}
ngOnInit() {
// Initial load triggered by the table itself via onLazyLoad
}
loadData(event: LazyLoadEvent) {
this.loading = true;
const page = Math.floor((event.first ?? 0) / (event.rows ?? 10));
const size = event.rows ?? 10;
let params = new HttpParams()
.set('page', page)
.set('size', size);
if (event.sortField) {
// PrimeNG uses 1 for asc, -1 for desc; many APIs expect 'asc'/'desc'
const direction = event.sortOrder === 1 ? 'asc' : 'desc';
params = params.set('sort', event.sortField).set('direction', direction);
}
// Handle filters — structure depends on column filter type
if (event.filters) {
Object.entries(event.filters).forEach(([field, filterMeta]) => {
if (filterMeta && filterMeta.value !== null && filterMeta.value !== '') {
// Simple text/numeric filter: send as field=value
// For matchMode (contains, equals, etc.) you may need extra params
params = params.set(field, filterMeta.value as string);
}
});
}
this.http.get('/api/products', { params }).subscribe({
next: (res) => {
this.products = res.data;
this.totalRecords = res.totalRecords;
this.loading = false;
},
error: (err) => {
console.error('Failed to load products', err);
this.loading = false;
}
});
}
}
Backend Contract Expectations
The example above expects the API to return JSON shaped like:
{
"data": [
{ "id": 1, "name": "Widget A", "category": "Tools", "price": 19.99, "stock": 100 },
...
],
"totalRecords": 12345
}
If your backend returns a different structure (e.g., Spring Data Page with content and totalElements), map it in the subscription before assigning to products and totalRecords.
Common Mistakes and How to Avoid Them
1. Forgetting totalRecords
Without [totalRecords] bound to the total count from the server, the paginator shows only the current page's row count and users cannot navigate to the last page. Always update totalRecords on every successful response.
2. Sort Order Mismatch
PrimeNG sends sortOrder: 1 (asc) or -1 (desc). Many backends expect asc/desc strings or 0/1. Convert explicitly as shown in the example; don't pass the raw value.
3. Filter Serialization Complexity
The filters object contains FilterMetadata with value, matchMode, and operator (for multi-condition filters). A text filter with "contains" sends { value: 'foo', matchMode: 'contains' }. A date range filter sends an array. Build a small helper to normalize these into your API's query format rather than handling each case inline.
4. Missing Loading Indicator
Network latency is now user-visible. The [loading]="loading" binding shows PrimeNG's built-in overlay spinner. For custom skeletons, listen to onLazyLoad start/end and toggle a local flag.
5. Double Initial Load
If you call loadData() in ngOnInit and the table emits onLazyLoad on initialization, you'll fetch twice. Let the table drive the first load; remove the manual call.
Limitations to Consider
- No client-side features: Row grouping, client-side row expansion with full data, and instant local sorting are unavailable because the full dataset never reaches the browser.
- Filter expressiveness: Complex filters (nested OR/AND, regex, custom match modes) require backend support. PrimeNG's
matchModevalues must map to your query language (SQL WHERE, Elasticsearch DSL, etc.). - Stale total count: If records are added/deleted by other users,
totalRecordsbecomes inaccurate until the next load. For real-time accuracy, consider a separate count endpoint or WebSocket invalidation. - Accessibility: Ensure the paginator and sort icons have proper ARIA labels; PrimeNG handles most of this but verify with screen readers when customizing templates.
Verification Checklist
- Open DevTools Network tab; confirm each page change, sort, or filter triggers exactly one request with correct query parameters.
- Inspect the
LazyLoadEventlogged inloadDatato verifyfirst,rows,sortField,sortOrder, andfiltersmatch user actions. - Test edge cases: first page (
first=0), last page (partial page), descending sort, multi-column filters. - Simulate slow network (DevTools throttling) and verify
loadingoverlay appears and blocks interaction. - Check that
totalRecordsupdates when backend data changes (add/delete via another tab).
When to Use This Pattern
Enable lazy loading when any of these apply:
- Dataset exceeds ~2,000 rows (browser memory/render cost becomes noticeable)
- Users need to sort/filter across the full dataset, not just the current page
- Backend already supports pagination and indexed queries
For smaller, relatively static datasets, client-side pagination (lazy=false with [paginator]) remains simpler and faster for subsequent interactions.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.