Skip to content

When a product has a dozen modules and everyone wants their screen "just like Excel", the frontend stops being pretty components and starts being logistics: who can see what, which data to fetch, and when not to.

Dashboards rarely feel slow because of rendering. They feel slow because of accidental work: fetches nobody asked for, renders nothing changed, and permission checks copied into fifteen places. This is what I look at, in order, on the enterprise compliance and case-management screens I work on.

Find the real bottleneck first#

Open the Network tab, click through the screen like a user, and count. If a single filter change causes four requests, or opening a row refetches the whole list, no amount of grid tuning will help. Fix the data pipeline before the UI; the grid is rarely the real problem.

Put permissions on routes, not in templates#

Sprinkling @if (can('edit')) copies of the same access check across the app gets old fast, and one missed check is worse than ugly code. Keep permission logic close to routing and navigation:

  • Route guards decide whether a feature loads at all.
  • Lazy-loaded features mean a user who can't see Reports never downloads the Reports bundle.
  • A single permissions service answers can(action); templates call it in one or two places per screen, not dozens.

This is faster for users (less code shipped) and safer for you (one place to audit).

Grids: pay attention to the boring parts#

AG Grid is usually worth it on data-heavy screens, but only if you use it properly:

  • Server-side row model. The server pages, sorts and filters; the browser holds one page. Ten thousand rows in the DOM is never the answer.
  • Virtualisation on. Only visible rows and columns render.
  • Debounced filters. A text filter wired straight to the API sends a request per keystroke. Wait until the user pauses, and skip a search that matches the last one.
  • Don't refetch the universe. Changing one row should update one row, not reload the page of data around it.
  • Saved views. If users rebuild the same filters every morning, save them per page. It costs little and is the feature people mention most.

Keep state out of components#

A screen that holds its data in component fields refetches whenever it is recreated: every navigation, every tab switch. Put it in a store (a Signals store or a small RxJS service) with a cache keyed by the request parameters. Navigating back shows the data instantly, and the store decides when it is stale enough to refresh.

The same store is where you dedupe concurrent requests: when two widgets ask for the same lookup at the same time, one request goes out and both get the answer.

Where the time goes: Routing (Lazy features, guards: ship and check only what this user needs), then Store (Cache by request, dedupe, decide when to refresh), then Grid (Server-side rows, virtualisation, debounced filters, saved views), then API (Paged, filtered, sorted on the server)RoutingLazy features, guards: ship and check only what this user needsStoreCache by request, dedupe, decide when to refreshGridServer-side rows, virtualisation, debounced filters, saved viewsAPIPaged, filtered, sorted on the server
Where the time goes

Small habits that add up#

  • OnPush everywhere, Signals for local state. Angular only re-renders what changed.
  • trackBy on every list. Without it, a refreshed array rebuilds every row.
  • Charts and heavy widgets load on demand. A KPI tile the user hasn't scrolled to shouldn't cost anything yet.
  • Loading states that don't jump. Keep the last data visible and dimmed while new data arrives; a full-screen spinner on every filter change feels slower than it is.

What to take away#

The win is always the same: less accidental work. Fewer renders, fewer mystery refetches, fewer "why is this spinner still here?" tickets at six in the evening. Measure first, fix the data flow, then tune the grid.