Choosing Client‑Side vs Server‑Side Pagination for Angular Material MatTable
Decide whether to use client‑side or server‑side pagination with Angular Material MatTable, compare trade‑offs, and see code examples that validate each approach.
28 Feb 2026, 01:22 UTC

Decision: Where to Slice the Data?
When you add a MatTable to an Angular app you immediately face a design question: do you let the browser hold the entire dataset and slice it with MatPaginator, or do you let the server deliver only the rows you need for each page? The choice affects performance, memory, and user experience.
What the Decision Affects
- Initial Load – all rows are fetched or only the first page is requested.
- Subsequent Page Changes – instant in‑memory filtering or a new HTTP call.
- Memory Footprint – full array vs. a single page.
- Complexity – built‑in
MatTableDataSourceor a customDataSource. - Scalability – small static tables vs. large, frequently updated datasets.
Comparison Table
| Feature | Client‑Side (MatTableDataSource) | Server‑Side (Custom DataSource) |
|---|---|---|
| Initial Load | All data returned in one request (may be slow for >1 000 rows) | Only first page requested (fast start) |
| Page Transitions | Instant – data already in memory | Network dependent – one request per page |
| Memory Usage | High – stores whole array | Low – stores only current page |
| Implementation Effort | Very low – bind MatPaginator to MatTableDataSource |
Medium – create a custom DataSource and wire events |
| Sorting + Pagination | Built‑in – use MatSort with MatTableDataSource |
Requires server support for sort parameters |
When to Pick Which
- Client‑Side is suitable when:
- The total number of rows is < 1 000 (or the payload size is < 200 KB).
- The table is static or rarely changes.
- Latency on the first load is acceptable.
- Server‑Side is the default for:
- Datasets larger than a few thousand rows.
- Highly dynamic data that can change between page loads.
- Environments where memory or CPU usage is a concern.
Client‑Side Implementation
Use MatTableDataSource and let MatPaginator do the slicing. The code below shows the minimal wiring.
// app.module.ts
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { MatTableModule } from '@angular/material/table';
import { MatPaginatorModule } from '@angular/material/paginator';
import { MatSortModule } from '@angular/material/sort';
import { AppComponent } from './app.component';
@NgModule({
declarations: [AppComponent],
imports: [
BrowserModule,
BrowserAnimationsModule,
MatTableModule,
MatPaginatorModule,
MatSortModule
],
bootstrap: [AppComponent]
})
export class AppModule {}
// app.component.ts
import { Component, ViewChild, AfterViewInit } from '@angular/core';
import { MatTableDataSource } from '@angular/material/table';
import { MatPaginator } from '@angular/material/paginator';
import { MatSort } from '@angular/material/sort';
interface User {
id: number;
name: string;
email: string;
}
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent implements AfterViewInit {
displayedColumns: string[] = ['id', 'name', 'email'];
dataSource = new MatTableDataSource<User>();
@ViewChild(MatPaginator) paginator!: MatPaginator;
@ViewChild(MatSort) sort!: MatSort;
ngAfterViewInit() {
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
}
// Example data – in a real app this would come from a service
fetchData() {
const users: User[] = [];
for (let i = 1; i <= 200; i++) {
users.push({ id: i, name: `User ${i}`, email: `user${i}@example.com` });
}
this.dataSource.data = users;
}
}
<table mat-table [dataSource]="dataSource" class="mat-elevation-z8">
<ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef mat-sort-header> ID </th>
<td mat-cell *matCellDef="let element"> {{element.id}} </td>
</ng-container>
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef mat-sort-header> Name </th>
<td mat-cell *matCellDef="let element"> {{element.name}} </td>
</ng-container>
<ng-container matColumnDef="email">
<th mat-header-cell *matHeaderCellDef mat-sort-header> Email </th>
<td mat-cell *matCellDef="let element"> {{element.email}} </td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns">
<tr mat-row *matRowDef="let row; columns: displayedColumns;" [trackBy]="trackById">
</table>
<mat-paginator [pageSizeOptions]="[5, 10, 25, 100]" showFirstLastButtons></mat-paginator>
Key points:
- Call
this.dataSource.paginator = this.paginatorafter the view is initialized. - Use
trackByonmat-rowto avoid full DOM rebuilds when the page changes. - All sorting and paging logic lives inside
MatTableDataSource.
Server‑Side Implementation
When the dataset is large or highly dynamic you should fetch only the visible slice. The pattern below uses a custom DataSource that communicates with an API.
// user.service.ts – a minimal HTTP wrapper
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
export interface PaginatedResponse<T> {
items: T[];
total: number;
}
@Injectable({ providedIn: 'root' })
export class UserService {
constructor(private http: HttpClient) {}
getUsers(pageIndex: number, pageSize: number, sortActive?: string, sortDirection?: string): Observable<PaginatedResponse<User>> {
let params = new HttpParams()
.set('page', pageIndex.toString())
.set('size', pageSize.toString());
if (sortActive && sortDirection) {
params = params.set('sort', sortActive).set('dir', sortDirection);
}
return this.http.get<PaginatedResponse<User>>('/api/users', { params });
}
}
// server-side-data-source.ts
import { DataSource } from '@angular/cdk/collections';
import { Observable, BehaviorSubject, combineLatest } from 'rxjs';
import { switchMap, tap } from 'rxjs/operators';
import { UserService, PaginatedResponse } from './user.service';
import { User } from './user.model';
export class ServerSideDataSource extends DataSource<User> {
private dataSubject = new BehaviorSubject<User[]>([]);
private totalSubject = new BehaviorSubject<number>(0);
constructor(private userService: UserService) {
super();
}
/**
* Called by the table when it wants the data.
* Emits the current page of users.
*/
connect(): Observable<User[]> {
return this.dataSubject.asObservable();
}
disconnect(): void {
this.dataSubject.complete();
this.totalSubject.complete();
}
/**
* Request a new page from the server.
* @param pageIndex Zero‑based index of the page.
* @param pageSize Number of rows per page.
* @param sortActive Column used for sorting.
* @param sortDirection 'asc' | 'desc'.
*/
loadPage(pageIndex: number, pageSize: number, sortActive?: string, sortDirection?: string): void {
this.userService.getUsers(pageIndex, pageSize, sortActive, sortDirection)
.pipe(
tap((res: PaginatedResponse<User>) => {
this.dataSubject.next(res.items);
this.totalSubject.next(res.total);
})
)
.subscribe();
}
/**
* Expose the total count for the paginator.
*/
get total$(): Observable<number> {
return this.totalSubject.asObservable();
}
}
// app.component.ts (server‑side version)
import { Component, ViewChild, AfterViewInit } from '@angular/core';
import { MatPaginator, PageEvent } from '@angular/material/paginator';
import { MatSort, Sort } from '@angular/material/sort';
import { ServerSideDataSource } from './server-side-data-source';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent implements AfterViewInit {
displayedColumns: string[] = ['id', 'name', 'email'];
dataSource = new ServerSideDataSource();
totalItems = 0;
@ViewChild(MatPaginator) paginator!: MatPaginator;
@ViewChild(MatSort) sort!: MatSort;
ngAfterViewInit() {
// Load the first page
this.loadPage();
// Update total when the data source emits a new total
this.dataSource.total$.subscribe(total => this.totalItems = total);
}
loadPage(event?: PageEvent) {
const pageIndex = event?.pageIndex ?? 0;
const pageSize = event?.pageSize ?? 10;
const sortActive = this.sort?.active;
const sortDirection = this.sort?.direction;
this.dataSource.loadPage(pageIndex, pageSize, sortActive, sortDirection);
}
onPageChange(event: PageEvent) {
this.loadPage(event);
}
onSortChange(sort: Sort) {
// Reset to first page when sorting changes
this.paginator.firstPage();
this.loadPage({ pageIndex: 0, pageSize: this.paginator.pageSize });
}
}
<table mat-table [dataSource]="dataSource" class="mat-elevation-z8">
<ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef mat-sort-header> ID </th>
<td mat-cell *matCellDef="let element"> {{element.id}} </td>
</ng-container>
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef mat-sort-header> Name </th>
<td mat-cell *matCellDef="let element"> {{element.name}} </td>
</ng-container>
<ng-container matColumnDef="email">
<th mat-header-cell *matHeaderCellDef mat-sort-header> Email </th>
<td mat-cell *matCellDef="let element"> {{element.email}} </td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns">
<tr mat-row *matRowDef="let row; columns: displayedColumns;" [trackBy]="trackById">
</table>
<mat-paginator
[length]="totalItems"
[pageSizeOptions]="[5, 10, 25, 100]"
showFirstLastButtons
(page)="onPageChange($event)"
(sortChange)="onSortChange($event)"
></mat-paginator>
Key points for the server‑side flow:
- The
DataSourceexposes atotal$stream so the component can bindlengthon the paginator. - Sorting is handled by the API – the component forwards the active column and direction to
loadPage. - When the paginator emits a
PageEvent, the component callsloadPagewith the new page index and size. - Use
trackByto keep the DOM stable across page changes.
Validation & Diagnostics
- Network Check – Open DevTools → Network. Trigger a page change. A request to
/api/users?page=1&size=10(or similar) should appear, and the response should contain only the requested slice plus atotalfield. - Console Logs – Add
console.log('Page:', pageIndex, 'Size:', pageSize)insideloadPageto confirm the correct parameters are sent. - DOM Count – Inspect the table rows after a page change. The number of
<tr mat-row>elements should equal thepageSize(or the remaining rows on the last page). - Change Detection – If you use
ChangeDetectionStrategy.OnPushon the component, callChangeDetectorRef.detectChanges()after subscribing tototal$or afterloadPagecompletes to force an update.
By following the patterns above you can confidently choose the right pagination strategy for your Angular Material table and verify that each page request behaves as expected.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.