Data Virtualization in Angular 20+: Material and PrimeNG Tables That Scroll at 60fps Without Melting Memory

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.

Related Resources

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.

Hire Matthew – Remote Angular Expert for Virtualization & Dashboards See NG Wave – 110+ Animated Angular Components (Signals)

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
NG Wave Component Library

Related resources