Server‑Side Pagination with Angular Material MatTable and CDK DataSource
Learn how to offload pagination, sorting, and filtering to the backend using Angular Material’s MatTable backed by a custom CDK DataSource, reducing initial payload and keeping change detection lean.
02 Jul 2026, 18:29 UTC

Problem: Loading Too Much Data Up Front
When a table displays thousands of rows, fetching the entire dataset on initial load hurts performance and bandwidth. Angular Material’s MatTable works fine with static data, but client‑side pagination forces the UI to keep all rows in memory, leading to slow renders and unnecessary change‑detection cycles.
Thesis: Use a CDK DataSource to Delegate Work to the Backend
By implementing the DataSource interface from the Angular CDK, you can connect MatTable directly to an observable that emits only the slice of data requested by the backend. The table then reacts to pagination, sorting, and filtering events without ever loading the full dataset.
How the CDK DataSource Fits In
connect()returns an observable that the table subscribes to.disconnect()cleans up subscriptions, preventing memory leaks.- The observable can be built from
HttpClientcalls that accept page index, page size, sort direction, and filter values as query parameters.
Worked Example: Minimal Setup
Assume a backend endpoint /api/users that supports:
page(zero‑based)pageSizesort(field name)order(ascordesc)filter(free‑text search)- Response body:
{ data: [], total: number }
1. Define the DataSource
import { DataSource } from '@angular/cdk/collections';
import { Observable, BehaviorSubject, merge } from 'rxjs';
import { map, catchError, startWith, switchMap } from 'rxjs/operators';
import { HttpClient } from '@angular/common/http';
interface UserApiResponse {
data: User[];
total: number;
}
export class UserDataSource extends DataSource {
private pageSubject = new BehaviorSubject<{ pageIndex: number; pageSize: number }>({ pageIndex: 0, pageSize: 5 });
private sortSubject = new BehaviorSubject<{ active: string; direction: string }>({ active: 'id', direction: 'asc' });
private filterSubject = new BehaviorSubject('');
private loadingSubject = new BehaviorSubject(false);
public loading$ = this.loadingSubject.asObservable();
constructor(private http: HttpClient) {
super();
}
connect(): Observable {
return merge(this.pageSubject, this.sortSubject, this.filterSubject).pipe(
startWith({}),
switchMap(() => {
this.loadingSubject.next(true);
const { pageIndex, pageSize } = this.pageSubject.getValue();
const { active, direction } = this.sortSubject.getValue();
const filter = this.filterSubject.getValue();
return this.http.get('/api/users', {
params: {
page: pageIndex.toString(),
pageSize: pageSize.toString(),
sort: active,
order: direction,
filter: filter
}
}).pipe(
map(res => {
this.loadingSubject.next(false);
return res.data;
}),
catchError(err => {
this.loadingSubject.next(false);
console.error('Failed to load users', err);
return [];
})
);
})
);
}
disconnect(): void {
this.pageSubject.complete();
this.sortSubject.complete();
this.filterSubject.complete();
this.loadingSubject.complete();
}
// Helper methods for the table UI
setPage(pageIndex: number, pageSize: number): void {
this.pageSubject.next({ pageIndex, pageSize });
}
setSort(sortState: { active: string; direction: string }): void {
this.sortSubject.next(sortState);
}
setFilter(filter: string): void {
this.filterSubject.next(filter);
}
}
2. Wire the DataSource to the Table Template
<table mat-table [dataSource]="dataSource" class="mat-elevation-z8">
<!-- ID Column -->
<ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef mat-sort-header> ID </th>
<td mat-cell *matCellDef="let row">{{row.id}}</td>
</ng-container>
<!-- Name Column -->
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef mat-sort-header> Name </th>
<td mat-cell *matCellDef="let row">{{row.name}}</td>
</ng-container>
<!-- Email Column -->
<ng-container matColumnDef="email">
<th mat-header-cell *matHeaderCellDef mat-sort-header> Email </th>
<td mat-cell *matCellDef="let row">{{row.email}}</td>
</ng-container>
<mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>
<mat-row *matRowDef="let row; columns: displayedColumns;"></mat-row>
</table>
<mat-paginator
[pageSizeOptions]="[5, 10, 25]"
showFirstLastButtons
aria-label="Select page of users">
</mat-paginator>
<mat-progress-spinner *ngIf="dataSource.loading$ | async" diameter="32"></mat-progress-spinner>
3. Component Glue Code
import { Component, OnInit, ViewChild } from '@angular/core';
import { MatPaginator } from '@angular/material/paginator';
import { MatSort } from '@angular/material/sort';
import { UserDataSource } from './user-data-source';
@Component({
selector: 'app-user-table',
templateUrl: './user-table.component.html',
})
export class UserTableComponent implements OnInit {
displayedColumns = ['id', 'name', 'email'];
dataSource: UserDataSource;
@ViewChild(MatPaginator) paginator!: MatPaginator;
@ViewChild(MatSort) sort!: MatSort;
constructor() {
this.dataSource = new UserDataSource(this.http);
}
ngOnInit(): void {
this.dataSource.sortSubject.subscribe(state => this.sort.active = state.active,
this.sort.direction = state.direction);
this.dataSource.filterSubject.subscribe(f => this.filter = f);
this.paginator.page.subscribe(p => {
this.dataSource.setPage(p.pageIndex, p.pageSize);
});
this.sort.sortChange.subscribe(s => {
this.dataSource.setSort({ active: s.active, direction: s.direction });
});
}
}
Trade‑offs and Limitations
While this approach reduces payload and keeps change detection cheap, it introduces a few considerations:
- Backend contract: The API must return both the data slice and a total count. If the total is missing,
MatPaginatorcannot calculate the correct number of pages, leading to empty rows or wrong page numbers. - Observable cleanup: Forgetting to complete the subjects in
disconnect()can cause memory leaks, especially in long‑lived views. Using atakeUntilpattern with a destroy observable is an alternative. - Loading state: The example exposes a
loading$observable; you must bind it to a spinner or similar indicator to avoid showing stale data while waiting for the backend. - Server‑side filtering: If the backend cannot handle complex filter expressions, you may need to perform additional client‑side filtering after receiving the page, which re‑introduces some client work.
Practical Verification Steps
- Run the Angular app with
ng serveand open the table page. - Open the browser’s Network tab, filter for
/api/usersrequests. - Change the page via the paginator and confirm that a new request is sent with the correct
pageandpageSizeparameters. - Check that the response includes a
totalfield and that the paginator updates itslengthaccordingly. - Click a column header to sort; verify that the request includes
sortandorderparameters and that the table rows reflect the new order. - Type into a filter input (if added) and ensure the request contains the
filterquery param. - Run an accessibility audit (e.g.,
axe-core) to ensure sorting buttons have appropriate ARIA labels and keyboard navigation works.
Actionable Closing
Implementing a custom CDK DataSource gives you the best of both worlds: a declarative, accessible UI from Angular Material and efficient, server‑driven data operations. Start by mocking the endpoint with a tool like json-server to verify the request shape, then swap in your real backend. Keep an eye on the total count observable and always clean up subscriptions to avoid leaks. With those checks in place, your tables will stay fast and responsive even as the dataset grows into the millions.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.