Short answer
For datasets that scale unpredictably, server-side pagination is the sustainable choice. MatTableDataSource.paginator slices a fully loaded array in the browser, so memory and change-detection cost grow linearly with row count. Once you pass a few thousand rows — and especially if the total is unbounded — client-side slicing becomes a liability rather than a convenience.
Why client-side pagination breaks down
MatTableDataSource expects the entire result set in memory. Its paginator setter wires the paginator into the data source's internal _updateChangeSubscription pipeline, and the table renders only the current slice — but the full array, sort state, and filter predicate all operate on the complete dataset. With Angular Material v17+, this works fine for bounded, modest lists. The failure mode is silent: the app works in development with 200 rows and degrades in production at 50,000.
The server-side pattern
The standard approach is to stop using MatTableDataSource's pagination entirely and drive the table from an observable:
ngAfterViewInit() {
this.paginator.page
.pipe(
startWith({ pageIndex: 0, pageSize: 25 }),
switchMap(e => this.api.getPage(e.pageIndex, e.pageSize))
)
.subscribe(res => {
this.dataSource.data = res.items;
this.paginator.length = res.total; // sync total count
});
}
Key points:
- Do not assign
dataSource.paginator — that re-enables client-side slicing on top of your server pages. - Set
paginator.length from the total field your API returns alongside each page. This is the documented mechanism; the paginator never polls the backend itself. - Return the total count in the same response as the page items (e.g., an
X-Total-Count header or a body field). This avoids a separate count query per page change.
Avoiding redundant API calls
The redundancy risk comes from two directions:
- Multiple emissions on init. If you also trigger a load in
ngOnInit and use startWith on the page stream, you fetch twice. Pick one trigger. - Length updates re-triggering fetches. Setting
paginator.length does not emit a (page) event, so writing it inside your subscription is safe. Redundant calls usually appear when length is updated from a separate polling observable that also resets pageIndex — resetting the index does emit. If the total changes while the user sits on a now-invalid page, clamp the index deliberately rather than letting an implicit reset fire an extra request.
For a dynamically changing total, the pragmatic pattern is: trust the total returned with each page fetch, and only add a separate count refresh (on an interval or via a push channel) if users must see the total change without navigating. Throttle that refresh; a count-only endpoint is cheap but not free.
Verification
- After a page change, confirm
dataSource.data.length equals the page size (except the last page) and exactly one network request fired. - Insert rows server-side, trigger your count refresh, and confirm
paginator.length updates with no page-data request. - Navigate to the last page, then shrink the dataset below that offset — confirm the table recovers to a valid page instead of showing empty rows.
Uncertainty
The behavior above reflects the long-standing, stable API of @angular/material through v17+. Exact event-emission edge cases (e.g., whether pageIndex reset emits in every minor version) are worth a quick check against your installed version's changelog before relying on them in a throttling scheme.