Most slow dashboards I have worked on were not slow because of the grid, the framework, or the server. They were slow because the frontend asked for the same data again and again, often several times a second.
On one enterprise Angular app, caching and tighter state management cut our API calls by 40%. The backend did not change at all.
Here are the four patterns I now check first on any data-heavy screen.
Start by counting#
Before changing anything, find out what the screen actually sends. The Network tab works, but a tiny interceptor gives you a running count per endpoint in the console:
export const requestCounter: HttpInterceptorFn = (req, next) => {
console.count(`${req.method} ${req.url}`);
return next(req);
};
Register it with provideHttpClient(withInterceptors([requestCounter])), click through the screen like a real user, and read the numbers. Any endpoint with a count higher than you expected is where to start.
1. Filters that fire on every keystroke#
A search box wired straight to the API sends one request per letter. Two RxJS operators fix most of it: debounceTime waits until the user pauses, and distinctUntilChanged skips a search that matches the previous one.
readonly query = signal('');
readonly results = toSignal(
toObservable(this.query).pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap((q) => this.api.search(q)),
),
{ initialValue: [] },
);
switchMap also cancels a request that is still in flight when a newer search starts, so an old, slow response can never overwrite a newer one.
View data
| Item | Requests |
|---|---|
| Every keystroke | 9 |
| debounceTime(300) | 3 |
| + distinctUntilChanged | 2 |
2. The same data, requested twice at once#
Status lists, user lookups and dropdown options are often needed by several components on the same screen. If each one calls the service, each one sends its own request.
Share one request instead:
private readonly statuses$ = this.http
.get<Status[]>('/api/statuses')
.pipe(shareReplay({ bufferSize: 1, refCount: false }));
getStatuses() {
return this.statuses$;
}
Every subscriber now gets the same response, and only the first one triggers the call. This works well for reference data that rarely changes during a session.
If two components need the same data, the second one should never have to ask the server for it.
3. Navigation that refetches unchanged data#
Going from a list to a record and back usually reloads the whole list. For data that changes slowly, keep it in a store and only reload it when it is old or when you know it has changed.
private readonly maxAgeMs = 60_000;
private loadedAt = 0;
loadCases() {
if (Date.now() - this.loadedAt < this.maxAgeMs) return;
this.loadedAt = Date.now();
this.api.getCases().subscribe((cases) => this.cases.set(cases));
}
invalidate() {
this.loadedAt = 0;
}
Call invalidate() after a save, so the next visit loads fresh data. Users get instant navigation without ever seeing stale records they just edited.
4. Grids that ask for more than they show#
AG Grid's server-side row model loads rows in blocks. The default block size is 100 rows, so a grid showing 25 rows per page can fetch four times more data than anyone sees.
Two settings help:
- Set
cacheBlockSizeto match your page size, so each request returns exactly one page. - Set
blockLoadDebounceMillisto a small value, so fast scrolling does not fire a request for every block the user scrolls past.
What to take away#
None of this is clever. It is the boring part of frontend work: count the requests, find the repeats, and remove the ones nobody needed.
If your dashboard feels slow, add the counter first. The numbers usually point straight at the problem, and the fix is often a few lines.