
Data Virtualization in Angular 20+: Material and PrimeNG Tables That Scroll at 60fps Without Melting Memory
Real-world patterns for virtual scrolling, lazy data windows, and UX polish—tested on telecom analytics, IoT fleets, and enterprise dashboards.
Render a window, not the world—and make that window feel beautifully fast.Back to all posts
I’ve shipped dashboards that ingest millions of rows—ad impressions at a telecom, device pings for an IoT fleet, and time-tracking for a global entertainment company. The difference between a dashboard you can sell and one you hide is usually virtualization: render only what’s visible, fetch only what you need, and never let memory balloon while users flick the wheel.
This is my field guide for Angular 20+ teams using Angular Material or PrimeNG. We’ll wire CDK Virtual Scroll and PrimeNG’s virtual table to Signals/SignalStore, tune scroll performance to 60fps, and keep memory steady—without sacrificing accessibility, typography, or your design system.
The 60fps Table Scene from the Front Lines
A telecom analytics dashboard that jittered
We replaced full arrays with paged windows, moved to Angular 20 + Signals, and applied CDK Virtual Scroll with an LRU cache. Scroll stabilized at 60fps, memory stayed under 180MB after 10 minutes, and analysts stopped exporting to CSV just to browse data.
20M rows across campaigns, creatives, and placements
Scroll hitching and 700MB memory after 3 minutes
AngularJS-era helpers and no trackBy
Airport kiosk logs and offline spools
Virtualization let us inspect huge logs with PrimeNG’s virtual table, prefetching ahead and keeping the UI responsive—even while the device synced in the background.
Kiosk software with hardware simulation in Docker
Offline-first flows dumped large logs on reconnect
Why this matters for 2025 roadmaps
Leaders ask for concrete numbers. A senior Angular engineer or Angular consultant who can prove 60fps and flat memory with telemetry gets greenlit. Virtualization is a quick win with visible ROI.
Core Web Vitals and INP targets
Hybrid SSR/hydration constraints
Hiring season scrutiny
Why Angular Tables Stutter and Leak Memory
Rendering too much
If the framework thinks every row is new, it re-renders everything. Use trackBy with stable IDs and compute expensive values in the view-model, not the template.
Full-array binding to Material or PrimeNG tables
Dynamic pipes in templates, no trackBy
Fetching without backpressure
Use windowed requests, prefetch the next/previous pages, and cancel in-flight fetches when the user scrolls past.
Infinite scroll hitting the API per scroll event
No prefetch buffer or cancellation
State that isn’t windowed
Cache pages in a Map keyed by page index. Evict old pages with LRU. Derive the visible window with a computed signal so it remains deterministic and testable.
Holding millions of rows in memory
Mutating arrays in place (breaking diffing)
Implementation: CDK Virtual Scroll + Signals/SignalStore
// virtual-table.store.ts
import { Signal, computed, signal } from '@angular/core';
import { SignalStore } from '@ngrx/signals';
interface Row { id: string; [k: string]: any }
interface Page { index: number; rows: Row[] }
const PAGE_SIZE = 200;
const MAX_PAGES = 30; // ~6k rows in-memory window
export class VirtualTableStore extends SignalStore<{ total: number; pages: Map<number, Page>; index: number; }> {
readonly total = signal(0);
readonly index = signal(0); // first visible row index
readonly pages = signal(new Map<number, Page>());
constructor(private api: { fetchPage: (pageIndex: number, size: number) => Promise<{ total: number; rows: Row[] }> }) {
super();
}
readonly pageFor = (i: number) => Math.floor(i / PAGE_SIZE);
readonly visibleRows: Signal<Row[]> = computed(() => {
const start = this.index();
const end = start + this.viewportSize(); // set by component
const startPage = this.pageFor(start);
const endPage = this.pageFor(end);
const out: Row[] = [];
for (let p = startPage; p <= endPage; p++) {
const page = this.pages().get(p);
if (page) out.push(...page.rows);
}
return out.slice(start % PAGE_SIZE, (start % PAGE_SIZE) + (end - start));
});
// dynamic, driven by density controls
viewportSize = signal(600);
async ensurePagesAround(index: number) {
const p = this.pageFor(index);
await Promise.all([this.fetchPage(p - 1), this.fetchPage(p), this.fetchPage(p + 1)]);
this.evictIfNeeded();
}
private async fetchPage(p: number) {
if (p < 0 || this.pages().has(p)) return;
const { total, rows } = await this.api.fetchPage(p, PAGE_SIZE);
this.total.set(total);
const pages = new Map(this.pages());
pages.set(p, { index: p, rows });
this.pages.set(pages);
}
private evictIfNeeded() {
const pages = this.pages();
if (pages.size <= MAX_PAGES) return;
// naive LRU: drop farthest from current
const current = this.pageFor(this.index());
const sorted = [...pages.values()].sort((a, b) => Math.abs(a.index - current) - Math.abs(b.index - current));
const keep = new Set(sorted.slice(0, MAX_PAGES).map(p => p.index));
const next = new Map<number, Page>();
for (const [k, v] of pages) if (keep.has(k)) next.set(k, v);
this.pages.set(next);
}
}<!-- virtual-table.component.html -->
<cdk-virtual-scroll-viewport
[itemSize]="rowHeight"
[minBufferPx]="rowHeight * 10"
[maxBufferPx]="rowHeight * 20"
role="table"
aria-rowcount="{{ total() }}"
(scrolledIndexChange)="onIndex($event)"
>
<div
class="row"
role="row"
*cdkVirtualFor="let r of rows(); trackBy: trackById"
[attr.aria-rowindex]="r.__rowIndex"
>
<div role="cell">{{ r.name }}</div>
<div role="cell">{{ r.status }}</div>
<div role="cell">{{ r.updatedAt | date:'short' }}</div>
</div>
</cdk-virtual-scroll-viewport>// virtual-table.component.ts
@Component({
selector: 'ux-virtual-table',
templateUrl: './virtual-table.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class VirtualTableComponent {
rowHeight = 40; // bound to density controls
total = this.store.total;
rows = this.store.visibleRows;
constructor(private store: VirtualTableStore) {}
onIndex(i: number) {
// debounce tiny scroll micro-bursts
this.store.index.set(i);
this.store.ensurePagesAround(i);
}
trackById = (_: number, r: any) => r.id;
}SignalStore for windowed pages
Below is a trimmed example using @ngrx/signals. The store caches fixed-size pages from the server, exposes a computed visibleRows signal, and prefetches neighbors to avoid jank.
Deterministic visible range
Prefetch and LRU eviction
Template with cdk-virtual-scroll-viewport
Bind to visibleRows(), not the full dataset. Keep templates simple—no heavy pipes in the loop.
Item size from density tokens
trackBy for stable identity
Accessibility and keyboard flow
Stick with native table semantics when possible. If you use divs, apply roles, aria-rowindex, and maintain tab order.
role="table" and aria-rowcount
Roving focus without breaking virtualization
PrimeNG Virtual Table + Lazy Loading
<p-table
[value]="rows()"
[scrollable]="true"
[virtualScroll]="true"
[lazy]="true"
[rows]="50"
[virtualScrollItemSize]="rowHeight"
scrollHeight="600px"
(onLazyLoad)="onLazy($event)"
[totalRecords]="total()"
>
<ng-template pTemplate="header">
<tr>
<th>Name</th>
<th>Status</th>
<th>Updated</th>
</tr>
</ng-template>
<ng-template pTemplate="body" let-r let-rowIndex="rowIndex">
<tr [attr.aria-rowindex]="rowIndex + 1">
<td>{{ r.name }}</td>
<td>{{ r.status }}</td>
<td>{{ r.updatedAt | date:'short' }}</td>
</tr>
</ng-template>
</p-table>onLazy(e: { first: number; rows: number }) {
const pageIndex = Math.floor(e.first / e.rows);
this.store.index.set(e.first);
this.store.ensurePagesAround(e.first);
}Configuration that works
PrimeNG’s table ships with virtualization built in. Use lazy mode so your service receives a bounded window request (first, rows).
virtualScroll + lazy + onLazyLoad
virtualScrollItemSize from density tokens
Server API contract
Always return total records to support aria-rowcount and paginator affordances.
Accept page/size or start/limit
Return totalRecords for aria-rowcount
UX Polish Without Performance Regression
:root {
--font-size-base: 14px;
--row-h-compact: 32px;
--row-h-cozy: 40px;
--row-h-spacious: 52px;
--color-text: #0B1220;
--color-accent: #1E88E5;
}
[data-density='compact'] .row { height: var(--row-h-compact); }
[data-density='cozy'] .row { height: var(--row-h-cozy); }
[data-density='spacious'] .row { height: var(--row-h-spacious); }
.row:focus { outline: 2px solid var(--color-accent); outline-offset: -2px; }Density controls and typography tokens
Users who live in tables need compact density; managers prefer readability. Bind row height and font size to tokens and connect them to your virtualization itemSize.
Compact, cozy, spacious densities
Scale row height and font-size via CSS variables
AngularUX color palette and contrast
Our AngularUX palette stays accessible: neutrals (#0B1220–#E7ECF3), blues (#1E88E5), and accent purples (#7E57C2). Keep focus rings visible and respect prefers-reduced-motion.
Neutral 900 for text, vibrant accents for selection
WCAG AA contrast on primary states
A11y keyboard model
Virtualization should not steal focus. Keep arrow-key navigation and announce row counts to screen readers via aria-rowcount.
Native table semantics when possible
Roving tabIndex and type-ahead search
Measure What Matters: Telemetry and Budgets
// telemetry.ts
performance.mark('vs-fill-start');
// after rows() computes and DOM paints next frame
requestAnimationFrame(() => {
performance.mark('vs-fill-end');
performance.measure('vs-fill', 'vs-fill-start', 'vs-fill-end');
});# .lighthouserc.yml
ci:
collect:
numberOfRuns: 3
url:
- http://localhost:4200/table
assert:
assertions:
categories:performance: [error, {minScore: 0.9}]
unused-javascript: [warn, {maxLength: 120000}]Firebase Performance and custom marks
Use Firebase Performance to record a custom trace for virtual scroll frame drops and window fill time; correlate with backend latency.
Trace scroll jank and page fetch latency
Correlate with API timing
Angular DevTools and Lighthouse CI
Gate changes with CI budgets and Lighthouse performance scores.
Profiler flame charts for re-renders
CI budgets to guard regressions
Charts Need Virtualization Too
D3, Highcharts, and Canvas/WebGL choices
For a telematics dashboard we windowed time-series to the viewport and used Highcharts boost (Canvas) for 100k points. In 3D scenes (Three.js), we batch geometry and recycle buffers instead of reallocating each pan/zoom.
Downsample and window the series
Prefer Canvas/WebGL for >10k points
Typed event schemas and backpressure
Keep render cadence decoupled from ingest cadence. Aggregate incoming points at 60Hz with requestAnimationFrame; never render per message.
WebSocket data with exponential backoff
Drop or aggregate high-frequency updates
When to Hire an Angular Developer for Virtualization and Table Performance
Signals your team needs help
I’ve rescued these scenarios across telecom analytics, IoT device portals, and employee tracking. If you need a remote Angular expert to diagnose and fix this fast, bring me in for a focused assessment.
INP spikes during scroll or filter
Memory climbs above 300MB after 5–10 minutes
PrimeNG/Material table freezes on dense data
Engagement model
We start with a trace-driven audit, prove improvements behind a feature flag, and land changes with CI budgets. See how I "stabilize your Angular codebase" at gitPlumbers—my code modernization services.
48-hour discovery, 1-week assessment
2–4 weeks implementation + guardrails
Takeaways and Next Steps
- Render a window, not the world. Cache pages and evict aggressively.
- Bind virtualization to density/typography tokens so UX stays consistent.
- TrackBy IDs, OnPush, and computed signals keep change detection tiny.
- Instrument scroll, fetch, and paint with Firebase Performance and DevTools.
- Protect wins with Lighthouse and bundle budgets in CI.
If you want me to review your tables, charts, and scroll performance—or plan your Angular 20+ roadmap—reach out. I’m an Angular consultant with Fortune 100 experience, available for select remote engagements.
Key takeaways
- Use windowed data + page caching (not full arrays) to keep memory flat under heavy scroll.
- CDK Virtual Scroll and PrimeNG’s virtual scroll both fly when you prefetch adjacent pages and trackBy stable IDs.
- Signals + SignalStore make visible window state deterministic, testable, and SSR-safe.
- Accessibility survives virtualization with correct roles, aria-rowcount, and keyboard focus management.
- UX polish (density, typography, color tokens) can coexist with strict performance budgets and telemetry.
Implementation checklist
- Pick a virtualization primitive: CDK Virtual Scroll for full control or PrimeNG virtual scroll for batteries-included.
- Design a windowed data model with page caches and eviction (LRU).
- Use Signals + SignalStore to derive visible rows and prefetch neighbors.
- Implement trackBy with stable IDs and OnPush change detection.
- Defer heavy parsing to Web Workers and batch DOM writes with requestAnimationFrame.
- Instrument with Firebase Performance and Angular DevTools; set bundle budgets in CI.
- Ship density toggles, contrast-safe palettes, and keyboard navigation that respects virtualization.
Questions we hear from teams
- How long does it take to virtualize a large Angular table?
- A targeted engagement is typically 1–2 weeks for assessment and proof-of-value, then 2–4 weeks to productionize with telemetry, budgets, and accessibility. Complex RBAC or multi-tenant apps may extend the timeline.
- Should we use CDK Virtual Scroll or PrimeNG’s virtual table?
- Use CDK for full control and custom behaviors; use PrimeNG when you want table features out of the box. Both hit 60fps when paired with page caching, trackBy, and prefetching.
- How do you keep memory low during infinite scroll?
- Use a windowed model with fixed-size pages, an LRU cache, and Signals to derive visible rows. Evict pages far from the viewport and avoid holding the full dataset in memory.
- Will virtualization break accessibility?
- Not if you preserve semantics. Use native table markup or proper roles, set aria-rowcount, maintain roving focus, and respect prefers-reduced-motion. Test with screen readers.
- What’s included in a typical Angular engagement?
- Discovery call within 48 hours, assessment report in one week, implementation with feature flags, CI guardrails, and a knowledge handoff. I can collaborate with your team or deliver independently as a remote Angular contractor.
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