Data Virtualization in Angular 20+: Fast Material/PrimeNG Tables with Virtual Scroll, Memory‑Safe Rows, and 60fps Scrolling

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-storage

Use 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+

Related Resources

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.

Hire Matthew – Remote Angular Expert (Available Now) See Live Angular Dashboards & Products

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