Server‑Side Pagination with Angular Material MatTable: A Practical Guide
Large datasets can choke a browser. This post walks through wiring Angular Material’s MatTable to a REST API that supplies paged data, sorting, and filtering on the server. Learn the pattern, see a concrete component, and understand the trade‑offs.
01 Jun 2026, 14:16 UTC

Why Server‑Side Matters for Big Tables
Angular Material’s MatTable is great for small to medium lists, but when you hit tens of thousands of rows, client‑side pagination and sorting become a memory and CPU nightmare. The browser has to keep the entire dataset in memory, run sort operations on it, and re‑render the view on every interaction. A server‑side approach solves this by delegating heavy lifting to the backend and only sending the slice of data the user is looking at.
Core Design Pattern
The typical architecture looks like this:
- Backend API – Accepts
pageIndex,pageSize,sortBy,sortDirection, andfilterquery parameters and returns a JSON object:{"data": [...], "total": 12345}. - Angular Service – Wraps the HTTP call in an
Observablestream and exposes agetPagemethod. - Component – Uses
MatPaginator,MatSort, andMatTable, wiring their events to the service. - Virtual Scrolling (Optional) –
cdk-virtual-scroll-viewportcan be added to reduce DOM nodes further, but the core server‑side logic remains unchanged.
Concrete Example
Below is a minimal, but complete, example that demonstrates the pattern. Replace API_URL with your endpoint.
1. Service – data.service.ts
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
export interface PageResult<T> {
data: T[];
total: number;
}
@Injectable({ providedIn: 'root' })
export class DataService {
private readonly API_URL = 'https://example.com/api/items';
constructor(private http: HttpClient) {}
/**
* Fetch a page of data from the server.
* @param pageIndex Zero‑based page index.
* @param pageSize Number of rows per page.
* @param sortBy Column name to sort on.
* @param sortDir 'asc' | 'desc'.
* @param filter Optional search string.
*/
getPage(pageIndex: number, pageSize: number, sortBy: string, sortDir: string, filter: string = ''): Observable<PageResult<any>> {
let params = new HttpParams()
.set('pageIndex', pageIndex.toString())
.set('pageSize', pageSize.toString());
if (sortBy) params = params.set('sortBy', sortBy).set('sortDir', sortDir);
if (filter) params = params.set('filter', filter);
return this.http.get<PageResult<any>>(this.API_URL, { params });
}
}
2. Component – table.component.ts
import { Component, OnInit, ViewChild } from '@angular/core';
import { MatPaginator, PageEvent } from '@angular/material/paginator';
import { MatSort, Sort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { DataService, PageResult } from './data.service';
@Component({
selector: 'app-item-table',
templateUrl: './table.component.html',
})
export class ItemTableComponent implements OnInit {
displayedColumns: string[] = ['id', 'name', 'price'];
dataSource = new MatTableDataSource<any>();
totalItems = 0;
@ViewChild(MatPaginator) paginator!: MatPaginator;
@ViewChild(MatSort) sort!: MatSort;
constructor(private dataService: DataService) {}
ngOnInit() {
this.loadPage();
}
loadPage(event?: PageEvent | Sort) {
const pageIndex = event instanceof PageEvent ? event.pageIndex : this.paginator?.pageIndex ?? 0;
const pageSize = event instanceof PageEvent ? event.pageSize : this.paginator?.pageSize ?? 10;
const sortBy = event instanceof Sort ? event.active : this.sort?.active ?? '';
const sortDir = event instanceof Sort ? event.direction : this.sort?.direction ?? 'asc';
const filter = ''; // Add a filter string if you have an input.
this.dataService.getPage(pageIndex, pageSize, sortBy, sortDir, filter).subscribe((res: PageResult<any>) => {
this.dataSource.data = res.data;
this.totalItems = res.total;
});
}
}
3. Template – table.component.html
<mat-table [dataSource]="dataSource" matSort (matSortChange)="loadPage($event)"
class="mat-elevation-z8">
<ng-container matColumnDef="id">
<mat-header-cell *matHeaderCellDef mat-sort-header>ID</mat-header-cell>
<mat-cell *matCellDef="let element">{{element.id}}</mat-cell>
</ng-container>
<ng-container matColumnDef="name">
<mat-header-cell *matHeaderCellDef mat-sort-header>Name</mat-header-cell>
<mat-cell *matCellDef="let element">{{element.name}}</mat-cell>
</ng-container>
<ng-container matColumnDef="price">
<mat-header-cell *matHeaderCellDef mat-sort-header>Price</mat-header-cell>
<mat-cell *matCellDef="let element">{{element.price | currency}}</mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>
<mat-row *matRowDef="let row; columns: displayedColumns;"></mat-row>
</mat-table>
<mat-paginator [length]="totalItems" [pageSizeOptions]="[10, 25, 50]" (page)="loadPage($event)"
showFirstLastButtons></mat-paginator>
4. Backend Contract
For the client to stay in sync, the API must return a consistent total count. A mismatched total can cause the paginator to display wrong page numbers or leave blank rows. Example response:
{
"data": [
{"id": 101, "name": "Widget A", "price": 9.99},
{"id": 102, "name": "Widget B", "price": 12.49}
],
"total": 12345
}
Testing the Flow
- Mock Backend – Use
json-serveror a simple Express route that accepts the query params and slices an array. - Network Check – Open Chrome DevTools → Network. Trigger a page change or sort; you should see a single
GETrequest with the correct query string and a payload containing only the current page. - DOM Size – Inspect the table rows. Only the rows for the current page should be rendered, not the entire dataset.
Trade‑Offs and Mitigations
- Latency vs. Memory – Server‑side reduces memory footprint but introduces round‑trip delays. If your users experience noticeable lag, consider prefetching the next page when the user scrolls near the end.
- Caching – Store recently fetched pages in a simple in‑memory cache keyed by
pageIndex|pageSize|sortBy|sortDir. This keeps the UI snappy without complex state management. - Sorting & Filtering on the Backend – Ensure the backend implements the same sorting logic as the client to avoid mismatches. If you need custom sort logic (e.g., locale‑aware string comparison), implement it server‑side.
- Virtual Scroll + Server‑Side – If you still need to support infinite scroll, combine
cdk-virtual-scroll-viewportwith the same service. The viewport will request new pages as the user scrolls.
Actionable Checklist
- Define the API contract:
pageIndex,pageSize,sortBy,sortDir,filter,total. - Implement the Angular service to build the query string and expose a
getPagemethod. - Wire
MatPaginatorandMatSortevents to triggergetPage. - Verify that only the requested page is fetched and rendered.
- Optionally add a simple cache to reduce repeated requests for the same slice.
- Document the caching strategy and potential latency in your design docs.
By following this pattern you keep the browser lean, scale to millions of rows, and still provide a responsive, feature‑rich table experience. Happy coding!
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.