Scaling Angular Material Tables with Server-Side Data Sources
Stop loading thousands of rows into the DOM. Learn how to implement a custom Angular Material DataSource to handle server-side pagination, sorting, and filtering using RxJS.
22 Jan 2026, 01:53 UTC

The Problem: Browser Memory Exhaustion
Loading a dataset of 10,000 rows into an Angular application causes immediate performance degradation. The browser must allocate memory for every object and the DOM must handle thousands of elements, leading to sluggish scrolling and unresponsive filtering. To maintain a 60fps UI, the client should only hold the specific slice of data currently visible to the user.
Thesis: Decouple UI from Data via the DataSource Class
The MatTable component is designed to work with any object that implements the DataSource interface from @angular/cdk/collections. Instead of passing a static array, you can create a custom class that transforms UI events (page changes, sort clicks) into API requests. This keeps the component lean and centralizes data orchestration logic.
Implementing a Reactive Server-Side DataSource
To avoid memory leaks and race conditions, the DataSource should manage a single stream of requests. Using a BehaviorSubject to track the current state (page, sort, filter) allows you to pipe all changes through a single switchMap, ensuring that if a user clicks "Next Page" and then immediately changes the sort, the first request is cancelled.
import { DataSource } from '@angular/cdk/collections';
import { BehaviorSubject, Observable, combineLatest, of } from 'rxjs';
import { catchError, map, switchMap, tap } from 'rxjs/operators';
import { UserService } from './user.service';
export interface User { id: number; name: string; }
export class UserDataSource extends DataSource<User> {
private filterSubject = new BehaviorSubject<string>('')();
private sortSubject = new BehaviorSubject<{active: string, direction: string}>({active: '', direction: ''});
private pageSubject = new BehaviorSubject<{index: number, size: number}>({index: 0, size: 10});
private loadingSubject = new BehaviorSubject<boolean>(false);
public loading$ = this.loadingSubject.asObservable();
constructor(private userService: UserService) { super(); }
connect(): Observable<User[]> {
return combineLatest([
this.filterSubject,
this.sortSubject,
this.pageSubject
]).pipe(
tap(() => this.loadingSubject.next(true)),
switchMap(([filter, sort, page]) =>
this.userService.fetchUsers({
search: filter,
sort: sort.active,
direction: sort.direction,
page: page.index,
size: page.size
}).pipe(
catchError(() => of({ data: [], total: 0 }))
)
),
tap(response => {
this.loadingSubject.next(false);
// Update the paginator length via a callback or service
this.onTotalChanged(response.total);
}),
map(response => response.data)
);
}
disconnect(): void {
this.filterSubject.complete();
this.sortSubject.complete();
this.pageSubject.complete();
this.loadingSubject.complete();
}
// State update methods called by the component
updatePage(index: number, size: number) { this.pageSubject.next({index, size}); }
updateSort(active: string, direction: string) {
this.sortSubject.next({active, direction});
this.updatePage(0, this.pageSubject.value.size); // Reset to page 1 on sort
}
updateFilter(filter: string) {
this.filterSubject.next(filter);
this.updatePage(0, this.pageSubject.value.size); // Reset to page 1 on filter
}
private onTotalChanged(total: number) { /* Logic to update MatPaginator.length */ }
}
Connecting the Component
The component should act as a bridge, passing MatPaginator and MatSort events into the DataSource. It is critical to use ViewChild and wait for ngAfterViewInit to ensure the Material components are initialized before wiring events.
// Inside UserTableComponent
@ViewChild(MatPaginator) paginator: MatPaginator;
@ViewChild(MatSort) sort: MatSort;
ngAfterViewInit() {
this.paginator.page.subscribe(event => {
this.dataSource.updatePage(event.pageIndex, event.pageSize);
});
this.sort.sortChange.subscribe(sort => {
this.dataSource.updateSort(sort.active, sort.direction);
});
this.filterControl.valueChanges.pipe(debounceTime(300)).subscribe(val => {
this.dataSource.updateFilter(val || '');
});
}
Handling the Filter Race Condition
A common mistake is updating the filter without resetting the page index. If a user is on page 10 and searches for a term that only has 2 pages of results, the API will return an empty array because page 10 no longer exists for that specific query. The updateFilter method shown above explicitly calls updatePage(0, size) to prevent this "ghost page" effect.
Trade-offs and Limitations
- API Dependency: This pattern requires a backend that supports
limit,offset, andorderByparameters. If the API only provides a full dump, this implementation is impossible. - Latency: Every sort or page change introduces a network round-trip. To mitigate this, implement a loading spinner using the
loading$observable from theDataSource. - State Synchronization: If the user refreshes the page, the current page and sort are lost unless you synchronize the
DataSourcestate with URL query parameters.
Verification Checklist
- Network Tab: Verify that clicking a sort header sends a request with the correct
sortanddirectionparameters. - Cancellation: Rapidly click the "Next" button; verify in the Network tab that previous pending requests are cancelled (status:
canceled) due toswitchMap. - Filter Reset: Navigate to page 5, enter a search term, and verify the table returns to page 1.
- Memory: Use Chrome DevTools Memory tab to ensure that navigating away from the component triggers the
disconnect()method and clears subscriptions.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.