
Data Virtualization in Angular 20+: Fast Material/PrimeNG Tables with Virtual Scroll, Memory‑Safe Rows, and 60fps Scrolling
Render 100k+ rows without jank: CDK/PrimeNG virtual scroll, Signals‑based windowing + caching, density tokens, and accessible typography using the AngularUX palette.
Fast isn’t an accident—virtualization, Signals, and disciplined UX tokens make 100k‑row tables feel instant.Back to all posts
When a director asks why the analytics table jitters at 5,000 rows, you don’t want theory—you want a playbook. I’ve shipped high‑volume dashboards for a leading telecom provider (ads analytics at billions of events/day), a broadcast media network VPS scheduling, and a global entertainment company internal systems. The fix almost always starts with data virtualization and ends with design tokens that make performance predictable.
In Angular 20+, virtual scroll is mature—both Angular Material (CDK) and PrimeNG support it. The trick is marrying it to Signals/SignalStore so you fetch only the visible window, keep memory flat, and still deliver accessible, polished UI with density and typography that reflect your design system.
Why Angular Tables Jitter with Large Datasets
As enterprises plan 2025 Angular roadmaps, fast tables are a non‑negotiable for operations, finance, and ad-tech teams. If you need an Angular consultant or want to hire an Angular developer to stabilize large data grids, the right virtualization strategy gives you 60fps scroll and predictable memory.
The hidden enemy: DOM + main thread
A naive mat-table/p-table with 10k rows creates tens of thousands of nodes. Every scroll triggers layout and paint. Add an impure date pipe or a per‑cell async pipe and you’ve multiplied work. If cells render small charts or avatars, you’ve introduced synchronous CPU on each scroll.
Too many nodes increase layout/paint cost
Per‑cell observables/pipes explode change detection
Charts/images inside rows block the main thread
Memory spikes and GC churn
I’ve seen tables balloon to 700MB in Chrome because rows were mutated in place and templates created closures per cell. The scroll seemed fine, then GC paused and FPS cratered. Virtualization, immutability, and trackBy fix this.
Detached DOM fragments linger
Repeated closures in templates
Row object mutation prevents memoization
Virtual Scroll in Material and PrimeNG
Both libraries are excellent. If you’re already on PrimeNG for enterprise widgets, p-table is convenient. If you want minimal DOM and custom cells, CDK table + virtual scroll is a strong base.
Angular Material (CDK) setup
CDK’s virtual-for keeps a constant number of row components in memory. Bind itemSize to a density token (e.g., 32/40/48px) so UX and performance move together.
Use cdk-virtual-scroll-viewport with itemSize
Prefer cdk-table for large grids; mat-table adds extra DOM
Provide trackBy and immutable row arrays
PrimeNG p-table setup
PrimeNG’s p-table integrates virtual scroll with lazy window events. Avoid mixing paginator and virtual scroll for the same table; pick one.
Enable [virtualScroll] and [lazy]
Use (onLazyLoad) to request window
Bind [virtualRowHeight] to density token
Signals Windowing and Page Cache (SignalStore)
Here’s a simplified SignalStore for a PrimeNG p-table.
State shape
Signals give deterministic reads in Angular 20+. A SignalStore wraps the window and a page cache keyed by pageIndex.
window: {start,end}
pageSize (e.g., 200)
cache: Map<number,Row[]>
total, loading, error
Stable keys + trackBy
Without stable ids, Angular can’t reuse row components, causing re-renders and GC churn.
Row.id must be stable across pages
Server must guarantee sort
trackBy: (i,row) => row.id
Exponential backoff + typed contracts
In ad‑tech dashboards we used WebSocket updates with typed schemas and fall back to paged HTTP for historical windows. The UI cache remains consistent.
Backoff on 429/5xx
Typed DTOs and guards
Telemetry for latency/error rate
Code: PrimeNG Virtual Scroll + SignalStore
import { HttpClient } from '@angular/common/http';
import { signalStore, withState, withMethods, patchState } from '@ngrx/signals';
import { computed, inject, signal } from '@angular/core';
interface Row { id: string; name: string; metric: number[]; }
interface Page { index: number; rows: Row[]; total: number; }
const PAGE_SIZE = 200;
export const TableStore = signalStore(
withState(() => ({
windowStart: 0,
windowEnd: PAGE_SIZE,
pageSize: PAGE_SIZE,
total: 0,
cache: new Map<number, Row[]>(),
loading: false,
error: null as string | null,
density: signal<'compact' | 'cozy' | 'comfortable'>('compact'),
})),
withMethods((store, http = inject(HttpClient)) => ({
setWindow(start: number, size: number) {
patchState(store, { windowStart: start, windowEnd: start + size });
const firstPage = Math.floor(start / store.pageSize);
const lastPage = Math.floor((start + size) / store.pageSize);
for (let p = firstPage; p <= lastPage; p++) this.ensurePage(p);
},
async ensurePage(index: number) {
if (store.cache.has(index)) return;
patchState(store, { loading: true });
try {
const page = await backoffFetch<Page>(() => http
.get<Page>(`/api/rows?page=${index}&size=${store.pageSize}`)
.toPromise());
patchState(store, (s) => {
s.cache.set(index, page.rows);
s.total = page.total;
s.loading = false;
});
} catch (e: any) {
patchState(store, { loading: false, error: e?.message ?? 'load failed' });
}
},
trackById(_: number, row: Row) { return row.id; },
rowHeightPx: computed(() => {
const d = store.density();
return d === 'compact' ? 32 : d === 'cozy' ? 40 : 48;
}),
visibleRows: computed(() => {
const { windowStart, windowEnd, pageSize, cache } = store;
const startPage = Math.floor(windowStart / pageSize);
const endPage = Math.floor(windowEnd / pageSize);
let rows: Row[] = [];
for (let p = startPage; p <= endPage; p++) {
const chunk = cache.get(p);
if (chunk) rows = rows.concat(chunk);
}
const offset = windowStart - startPage * pageSize;
return rows.slice(offset, offset + (windowEnd - windowStart));
})
}))
);
async function backoffFetch<T>(fn: () => Promise<T>, retries = 4) {
let attempt = 0; let delay = 250;
while (true) {
try { return await fn(); }
catch (e) { if (attempt++ >= retries) throw e; await new Promise(r => setTimeout(r, delay)); delay *= 2; }
}
}<p-table
[value]="store.visibleRows()"
[virtualScroll]="true"
[lazy]="true"
[totalRecords]="store.total"
[rows]="store.pageSize"
[virtualRowHeight]="store.rowHeightPx()"
scrollHeight="600px"
(onLazyLoad)="store.setWindow($event.first, $event.rows)"
[trackBy]="store.trackById"
[attr.aria-rowcount]="store.total">
<ng-template pTemplate="header">
<tr>
<th scope="col">Name</th>
<th scope="col">Metric</th>
</tr>
</ng-template>
<ng-template pTemplate="body" let-row>
<tr>
<td>{{ row.name }}</td>
<td>
@defer (on viewport) {
<app-sparkline [data]="row.metric"></app-sparkline>
} @loading { <span class="muted">…</span> }
</td>
</tr>
</ng-template>
</p-table>// A tiny canvas sparkline to keep the main thread light
import { AfterViewInit, Component, ElementRef, Input, ViewChild, ChangeDetectionStrategy } from '@angular/core';
@Component({
selector: 'app-sparkline',
template: '<canvas #c width="60" height="18" aria-hidden="true"></canvas>',
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true
})
export class SparklineComponent implements AfterViewInit {
@ViewChild('c', { static: true }) c!: ElementRef<HTMLCanvasElement>;
@Input() data: number[] = [];
ngAfterViewInit() {
const ctx = this.c.nativeElement.getContext('2d')!;
const w = this.c.nativeElement.width, h = this.c.nativeElement.height;
const max = Math.max(...this.data, 1);
ctx.clearRect(0,0,w,h);
ctx.strokeStyle = getComputedStyle(document.documentElement).getPropertyValue('--ux-primary-500').trim();
ctx.beginPath();
this.data.forEach((v,i)=>{
const x = (i/(this.data.length-1))*w;
const y = h - (v/max)*h;
i===0 ? ctx.moveTo(x,y) : ctx.lineTo(x,y);
});
ctx.stroke();
}
}Store and component wiring
CDK Virtual Scroll for Material: Minimal DOM, Max Control
<cdk-virtual-scroll-viewport [itemSize]="rowHeightPx()" class="viewport" aria-label="Results">
<table class="cdk-table" role="table" [attr.aria-rowcount]="total()">
<tr class="cdk-row" *cdkVirtualFor="let row of visibleRows(); trackBy: trackById" role="row">
<td class="cdk-cell" role="cell">{{ row.name }}</td>
<td class="cdk-cell" role="cell">
@defer (on viewport) { <app-sparkline [data]="row.metric"/> }
</td>
</tr>
</table>
</cdk-virtual-scroll-viewport>.viewport { height: 600px; contain: strict; }
.cdk-row { will-change: transform; }The contain: strict hint and will-change on rows reduce layout thrash and keep scroll smooth. Measure in Chrome Performance: aim for <16ms/frame and minimal layout events.
Viewport + cdk-table
For highly customized cells or 200k+ rows, CDK often outperforms. You control exactly what renders.
Use cdkVirtualFor over your data slice
Bind [itemSize] to density token
Avoid mat-table features you don’t need
A11y, Typography, Density, and the AngularUX Palette
:root {
/* AngularUX color palette */
--ux-surface-0: #0b0d12; /* dark bg */
--ux-surface-1: #131722;
--ux-elev-1: #1b2130;
--ux-text-1: #e6eaf2;
--ux-text-2: #b7c0d1;
--ux-primary-500: #3b82f6; /* blue */
--ux-accent-500: #22c55e; /* green */
--ux-danger-500: #ef4444;
/* Typography & density */
--ux-font-sans: Inter, system-ui, -apple-system, Segoe UI, Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
--ux-font-size-100: 12px;
--ux-font-size-200: 14px;
--ux-line-200: 20px;
--ux-row-compact: 32px;
--ux-row-cozy: 40px;
--ux-row-comfort: 48px;
}
.table-compact { --row-h: var(--ux-row-compact); }
.table-cozy { --row-h: var(--ux-row-cozy); }
.table-comfort { --row-h: var(--ux-row-comfort); }
p-table, .cdk-table {
color: var(--ux-text-1);
background: var(--ux-surface-1);
font: 400 var(--ux-font-size-200)/var(--ux-line-200) var(--ux-font-sans);
}
.cdk-row, .p-datatable-tbody > tr { height: var(--row-h); }
/* Visible focus that fits the brand */
:focus-visible { outline: 2px solid var(--ux-primary-500); outline-offset: 2px; }
/* Ensure contrast for badges */
.badge { color: #0b0d12; background: var(--ux-accent-500); }These tokens ensure AA contrast by default while letting density inform scroll performance. In SageStepper I use similar tokens to keep adaptive UIs readable across 320 communities without regressing FPS.
Density controls that drive performance
Tie virtualRowHeight/itemSize to density tokens so design choices translate to predictable performance. Feature‑flag density via Firebase Remote Config when shipping to large user bases.
Compact 32px rows: ~18 visible rows in 600px
Cozy 40px rows: ~15 rows
Comfortable 48px rows: ~12 rows
AA contrast and focus
Screen readers should announce total rows while keyboard users retain predictable tab order. Provide skip‑to‑filters links for long tables.
Tokens enforce contrast >= 4.5:1
Visible focus rings that respect brand
Avoid text in low‑contrast badges
AngularUX design tokens
Below are the minimal tokens I use to keep tables readable and on‑brand while staying performant.
Server Contracts and Real‑Time Streams
Typed event schemas and a telemetry pipeline are critical: log latency, error rate, and dropped frames. If you need a senior Angular engineer to wire this end‑to‑end, I’m available as a remote Angular contractor.
Stable pagination API
Your API makes or breaks virtualization. If sort changes between requests, rows will jump or duplicate.
Request pages by index + size
Return total, version, rows[]
Guarantee sort by stable key
Real‑time overlays
On Charter’s dashboards we streamed live metrics over WebSockets (typed with Zod/io-ts) while the UI window fetched history. Typed schemas + decimation meant Highcharts updated without thrash.
WebSocket for deltas with typed schemas
Apply patches to cached pages
Backoff + retry for resilience
Charts inside tables
For D3/Highcharts cell charts, decimate on the server and render only in view. For 100k rows, sparklines must be Canvas or OffscreenCanvas.
Decimate data on the server
Use Canvas for sparklines; SVG for small sets
Defer render until row is visible
Measuring Success: Performance Budgets and CI
// angular.json (excerpt)
{
"budgets": [
{ "type": "bundle", "name": "main", "maximumWarning": "300kb", "maximumError": "350kb" },
{ "type": "anyScript", "maximumWarning": "500kb", "maximumError": "600kb" }
]
}# .github/workflows/perf.yml (excerpt)
name: perf-checks
on: [pull_request]
jobs:
lhci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci && npm run build -- --configuration=production
- run: npx @lhci/cli autorun --collect.staticDistDir=dist/app --upload.target=temporary-public-storageUse Angular DevTools flame charts locally to confirm change detection is quiet during scroll. Target <16ms/frame and flat memory while scrolling 30s. Document thresholds in your Nx monorepo README so new hires understand the budget.
Angular budgets
Budgets won’t measure FPS, but they stop bloat that often correlates with jank.
Bundle size budgets keep features honest
Fail CI if exceeded
Automated profiling
Track a synthetic 100k-row scenario during PRs. Fail fast if frame times exceed thresholds.
Angular DevTools profiles checked periodically
Lighthouse CI for accessibility and perf
When to Hire an Angular Developer for Legacy Rescue
If your tables are janky or your dashboards blow past performance budgets, let’s talk. We can stabilize without freezing delivery—see how we rescue chaotic code at gitPlumbers.
Symptoms I watch for
I’ve rescued legacy AngularJS ➜ Angular migrations and vibe‑coded Angular 20 apps with these exact issues. Moving to Signals + virtual scroll usually cuts memory by 70–90% and restores 60fps.
mat-table with *ngFor over 50k rows
Per‑cell async pipes/impure pipes
No trackBy; mutable row objects
Charts rendering off‑screen
GC pauses and scroll hitching
Expected timelines
Zero‑risk rollout via feature flags and canaries is standard. If you need an Angular expert for hire with enterprise experience, I’m available for remote contracts.
Assessment in 3–5 days
First PR in week 1
Full stabilization in 2–4 weeks
Quick Examples That Scale
These patterns shipped in production at a broadcast media network (VPS scheduling grids), Charter (ads analytics), and an enterprise IoT hardware company (device fleet tables). We combined Highcharts for dashboards and Canvas/Three.js for dense visuals, deferring off‑screen work to keep the UI snappy.
Material + CDK
cdk-virtual-scroll-viewport
itemSize bound to tokens
trackBy id
PrimeNG
[virtualScroll] + [lazy]
onLazyLoad ➜ SignalStore.setWindow
[virtualRowHeight] bound to tokens
Visualizations
Canvas sparkline
Defer heavy charts
Server decimation
Takeaways
- Virtualization + Signals windowing is the baseline for large tables.
- Bind density tokens to itemSize so design and performance move together.
- Use trackBy and immutable rows; avoid per‑cell work.
- Defer charts; favor Canvas for sparklines.
- Measure with budgets + profiling; keep <16ms/frame.
- Make it accessible: totals, focus order, AA contrast.
FAQs: Data Virtualization in Angular 20+
Key takeaways
- Use CDK and PrimeNG virtual scroll to keep DOM nodes constant while paging data windows via Signals + SignalStore.
- Bind itemSize/virtualRowHeight to density tokens so UX polish and performance budgets move together.
- Cache pages with typed keys and trackBy to avoid GC churn; avoid per‑cell observables and impure pipes.
- Defer heavy cell content (sparklines/charts) with @defer or IntersectionObserver for 60fps scroll.
- Expose total row count and keyboard affordances for screen readers; verify AA contrast with your design tokens.
- Instrument with Angular DevTools, Chrome Performance, and budgets to keep main‑thread work <16ms/frame.
Implementation checklist
- Adopt virtual scroll (CDK or PrimeNG) instead of naive *ngFor over large arrays.
- Implement a Signals + SignalStore window (start,end) and a page cache (Map<number,Row[]>).
- Use trackBy and immutable rows; avoid per‑cell subscriptions/pipes.
- Bind itemSize/virtualRowHeight to density tokens; measure viewport row count.
- Defer heavy cell templates with @defer (on viewport) or IntersectionObserver.
- Announce total records, maintain focus order, and respect color‑contrast tokens.
- Use typed APIs for server paging; guarantee stable sort and deterministic keys.
- Set performance budgets and profile: 60fps goal, <50MB incremental memory for table.
- Test with 10k/100k rows locally; run Lighthouse + Angular DevTools in CI.
- Feature‑flag density changes via Firebase Remote Config if needed.
Questions we hear from teams
- How long does it take to virtualize a large Angular table?
- Typical engagements take 2–4 weeks: 3–5 days to assess, first PR in week 1, and staged rollout with feature flags. Complex charts or real‑time feeds add another 1–2 weeks.
- Do we need PrimeNG or can we use Angular Material?
- Both work. PrimeNG p-table offers built-ins for enterprise grids. CDK table + virtual scroll is leaner and often faster. I pick based on existing stack, accessibility needs, and customization.
- How many rows can we support with virtualization?
- UI can handle 100k–200k+ logical rows smoothly when virtualization, caching, and trackBy are correct. Memory remains flat because only the visible window renders.
- Will accessibility suffer with virtual scroll?
- It doesn’t have to. Announce total records, keep keyboard order predictable, and ensure AA contrast in tokens. PrimeNG and CDK support ARIA roles; validate with screen readers.
- What does a typical Angular engagement cost?
- I scope fixed-price or weekly retainers after a brief assessment. Most teams see ROI quickly by unblocking operations dashboards. Discovery call within 48 hours; assessment in 1 week.
Ready to level up your Angular experience?
Let AngularUX review your Signals roadmap, design system, or SSR deployment plan.
NG Wave
Angular Component Library
A comprehensive collection of 110+ animated, interactive, and customizable Angular components. Converted from React Bits with full feature parity, built with Angular Signals, GSAP animations, and Three.js for stunning visual effects.
Explore Components