Architecting PrimeNG DataTable with Server‑Side Pagination & Sorting
Learn how to design a minimal, robust Angular service for PrimeNG’s DataTable that handles lazy loading, sorting, and pagination while protecting against malformed data and network failures.
21 Apr 2026, 07:29 UTC

Problem Statement
An Angular application needs to display thousands of records in a PrimeNG DataTable. The table must support dynamic filtering, sorting, and pagination while fetching data lazily from a REST API. The goal is to keep the design minimal, isolate data contracts, and provide graceful degradation on network or data errors.
Requirements
- Server‑side pagination: client sends page index, page size, and sort descriptors.
- Dynamic filtering and sorting via PrimeNG’s
lazyLoadevent. - Typed, validated data returned to the component.
- Graceful handling of 5xx errors, timeouts, and malformed payloads.
- Minimal runtime overhead: a single service, one interceptor, and a thin component.
Smallest Suitable Design
The core of the architecture is a single Angular service that encapsulates the DataTable’s lazy loading logic.
// src/app/services/paged-data.service.ts
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams, HttpErrorResponse } from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { catchError, map, retryWhen, delay, take } from 'rxjs/operators';
export interface SortDescriptor {
field: string;
order: number; // 1 for asc, -1 for desc
}
export interface PagedResult<T> {
data: T[];
totalRecords: number;
}
@Injectable({ providedIn: 'root' })
export class PagedDataService {
private readonly endpoint = '/api/items';
constructor(private http: HttpClient) {}
/**
* Fetch a page of data.
* @param pageIndex zero‑based index
* @param pageSize number of rows per page
* @param sort optional array of sort descriptors
* @returns Observable<PagedResult<any>>
*/
fetchPage(pageIndex: number, pageSize: number, sort?: SortDescriptor[]): Observable<PagedResult<any>> {
let params = new HttpParams()
.set('page', pageIndex.toString())
.set('size', pageSize.toString());
if (sort) {
// PrimeNG sends an array of objects; we map it to a single string
params = params.set('sort', sort.map(s => `${s.field},${s.order === 1 ? 'asc' : 'desc'}`).join(','));
}
return this.http.get<any>(this.endpoint, { params })
.pipe(
// Validate payload structure
map(resp => {
if (!Array.isArray(resp.data) || typeof resp.totalRecords !== 'number') {
throw new Error('Invalid API response');
}
return { data: resp.data, totalRecords: resp.totalRecords } as PagedResult<any>;
}),
// Retry transient 5xx errors up to 3 times with exponential back‑off
retryWhen(errors => errors.pipe(
delay(1000),
take(3)
)),
catchError(err => this.handleError(err))
);
}
private handleError(err: HttpErrorResponse | Error) {
// Log to console or external service
console.error('PagedDataService error:', err);
// Convert to user‑friendly message
const message = err instanceof HttpErrorResponse
? `Server returned ${err.status}: ${err.statusText}`
: err.message;
return throwError(() => new Error(message));
}
}
The service uses HttpClient to send query parameters that match the REST API contract. It validates that the response contains a data array and a numeric totalRecords field, guarding the UI against malformed data.
Component Binding
In the component, bind the DataTable’s lazyLoad event to the service. The component remains stateless regarding data fetching logic.
// src/app/components/items-table/items-table.component.ts
import { Component } from '@angular/core';
import { PagedDataService, PagedResult } from '../../services/paged-data.service';
@Component({
selector: 'app-items-table',
templateUrl: './items-table.component.html'
})
export class ItemsTableComponent {
items: any[] = [];
totalRecords = 0;
loading = false;
errorMsg: string | null = null;
constructor(private dataService: PagedDataService) {}
onLazyLoad(event: any) {
this.loading = true;
this.errorMsg = null;
const sort = event.sortField ? [{ field: event.sortField, order: event.sortOrder }] : [];
this.dataService.fetchPage(event.first / event.rows, event.rows, sort)
.subscribe({
next: (res: PagedResult<any>) => {
this.items = res.data;
this.totalRecords = res.totalRecords;
this.loading = false;
},
error: err => {
this.errorMsg = err.message;
this.loading = false;
}
});
}
}
Name
Price
{{item.name}}
{{item.price | currency}}
{{errorMsg}} Retry
Trust/Data Boundaries
- The service is the sole consumer of the REST endpoint. All raw HTTP responses are validated before reaching the component.
- Components receive a
PagedResult<T>type, ensuring that the UI logic never needs to parse or cast raw JSON. - Any unexpected shape triggers the
handleErrorpath, preventing the UI from rendering corrupt data.
Operational Checks
To monitor and maintain reliability, add an HTTP interceptor that logs latency, status codes, and triggers retries for transient failures.
// src/app/interceptors/monitor.interceptor.ts
import { Injectable } from '@angular/core';
import { HttpInterceptor, HttpRequest, HttpHandler, HttpEvent } from '@angular/common/http';
import { Observable, tap } from 'rxjs';
@Injectable()
export class MonitorInterceptor implements HttpInterceptor {
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
const start = Date.now();
return next.handle(req).pipe(
tap(
event => {
if (event.type === 4) { // HttpResponse
const duration = Date.now() - start;
console.log(`Request to ${req.url} finished in ${duration}ms with status ${event.status}`);
}
},
err => {
const duration = Date.now() - start;
console.warn(`Request to ${req.url} failed after ${duration}ms:`, err);
}
)
);
}
}
Register the interceptor in app.module.ts:
providers: [
{ provide: HTTP_INTERCEPTORS, useClass: MonitorInterceptor, multi: true }
]
Failure Modes & Recovery
- Network timeout or 504: Service retries automatically; if still failing, the component shows a retry button.
- Server error 5xx: Retry logic applies; after 3 attempts, a user‑friendly error message is displayed.
- Malformed JSON: Validation in
mapthrows an error; the component receives a clean message and stays in a safe empty state. - Client‑side sorting/pagination mismatch: The component always requests data based on the event’s
firstandrowsvalues, ensuring the table state matches the server.
When to Redesign
Although the minimal design works for most scenarios, consider a redesign if:
- The API returns more rows than requested, causing the client to render thousands of hidden rows. Add server‑side validation or modify the API to honor
sizestrictly. - The dataset grows to millions of rows and the server can’t keep up with pagination requests. Introduce caching or a dedicated data‑sharding strategy.
- Security requirements demand stricter data validation. Move validation logic into a dedicated DTO validator or a backend schema validator.
- The UI requires complex row grouping or nested tables. Replace
p-tablewith a custom component that supports hierarchical data.
Practical Verification
- Run unit tests that mock
HttpClientand assert thatfetchPagesends the correct query parameters. - Simulate a 504 timeout in an integration test and verify that the component displays the retry prompt without throwing.
- Check the browser console after a failed request to ensure the interceptor logs the status and duration.
Limitations
- Assumes PrimeNG 13+ where
lazyLoademitsfirst,rows,sortField, andsortOrder. - Server must support query parameters
page,size, andsortin the format shown. - Retry strategy is simplistic; for production use, consider using
rxjs‑retry‑whenwith jitter. - The service currently returns
anyfor data items. Replace with a specific model for type safety.
Conclusion
This architecture keeps the DataTable logic minimal, isolates API contracts, and provides robust operational checks. By validating data early, logging network performance, and handling failures gracefully, you can confidently scale the table to thousands of records without compromising user experience.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.