Data Virtualization in Angular 20+: Smooth 200k‑Row Tables with Angular Material and PrimeNG (Scroll, Memory, and A11y)

Data Virtualization in Angular 20+: Smooth 200k‑Row Tables with Angular Material and PrimeNG (Scroll, Memory, and A11y)

What I use in enterprise Angular to render massive tables without jank: CDK/PrimeNG virtualization, Signals + SignalStore windowing, density tokens, and CI budgets.

Virtualization isn’t a feature—it’s the difference between a dashboard that’s glanced at and one that’s trusted under pressure.
Back to all posts

If your Angular table stutters on scroll, you don’t need more spinners—you need virtualization. I’ve shipped real-time analytics for a telecom, device fleets for IoT, and airport kiosks where a 200k-row view must stay smooth at 60fps. Here’s the blueprint I use in Angular 20+ with Material, PrimeNG, Signals, and SignalStore.

Your 200k row table needs data virtualization, not more spinners

As companies plan 2025 Angular roadmaps, tables remain the heaviest UI most teams ship. Data virtualization keeps Core Web Vitals and accessibility intact without gutting features.

A scene from production

A telecom analytics dashboard: 220k events, live WebSocket updates, execs paging furiously during an incident bridge. The table jittered, memory ballooned, and focus randomly jumped. We replaced eager rendering with CDK/PrimeNG virtualization + SignalStore windowing and hit 60fps while cutting memory 60–80%.

Tools I actually use

This isn’t a toy example—these patterns run in production at scale.

  • Angular 20+, Signals, SignalStore

  • CDK Virtual Scroll + Angular Material Table

  • PrimeNG DataTable VirtualScroller

  • RxJS 7, Nx monorepo, Firebase/Node backends

Why Angular tables jitter: rendering, GC, and layout thrash

Jank is cumulative: over-rendering, dynamic heights, and unbounded subscriptions. Virtualization attacks all three.

The 16ms frame budget

If your row template triggers cross-axis layouts or unpredictable heights, you blow the budget. Virtualization constrains work to a small, fixed-height window.

  • ~6ms scripting, ~4ms style/layout, ~4ms paint, ~2ms overhead

GC and memory pressure

Rendering 10k rows forces GC churn. Keep only ~100–400 rows alive, reuse components, and cancel work as rows leave view.

  • Thousands of components

  • Detached DOM nodes

  • Leaky subscriptions

A11y fallout when recycling

Virtualization must preserve focus order and aria relationships as DOM nodes recycle.

  • Losing focus and aria-activedescendant

  • Screen readers re-announce unexpectedly

Implementing windowed data with Signals + SignalStore for Material and PrimeNG

Here’s a minimal store and templates that I’ve adapted across telecom analytics, media schedulers, and IoT fleet consoles.

Choose your virtualization path

If you need bespoke cell renderers or tight a11y control, CDK + MatTable shines. If you want enterprise features quickly, PrimeNG’s DataTable is solid and battle-tested.

  • CDK Virtual Scroll: maximum control, pair with MatTable or custom cells.

  • PrimeNG DataTable: [virtualScroll] + lazy load events, built-in sorting/filters.

Windowed cache via Signals

Signals and SignalStore make calculating the window deterministic and fast. Derive the slice from scroll index + item size; fetch new pages lazily.

  • Keep a small cache around the viewport

  • Prefetch ahead and behind

  • Evict aggressively

Focus and keyboard navigation

Track the focused row’s key and re-apply focus when its node is recycled. PrimeNG exposes hooks; CDK gives you full control.

  • Manage tabindex explicitly

  • Use aria-rowindex and aria-selected

  • Restore focus when recycled

Density and tokens

Density is a performance feature—predictable heights avoid layout thrash and enable precise viewport math.

  • Compact | Cozy | Comfortable scales

  • Fixed row height per density

  • Theme tokens for color/contrast

Server cooperation

Firebase/Node/ASP.NET APIs should expose range-based endpoints; typed event schemas keep streams stable.

  • Return stable IDs

  • Support range queries (offset+limit or cursor)

  • Compress payloads (GZIP/Brotli)

PrimeNG and Angular Material virtual scroll: code you can paste

// virtual-data.store.ts (Angular 20+, SignalStore)
import { signal, computed, effect } from '@angular/core';
import { patchState, signalStore, withMethods, withState } from '@ngrx/signals';

interface Row { id: string; [k: string]: any }
interface State {
  total: number;
  pageSize: number;   // server page size
  itemSize: number;   // px height per row
  viewportStart: number; // index
  viewportEnd: number;   // index
  cache: Map<number, Row[]>; // pageIndex -> rows
}

export const VirtualDataStore = signalStore(
  withState<State>({ total: 0, pageSize: 100, itemSize: 36, viewportStart: 0, viewportEnd: 0, cache: new Map() }),
  withMethods((store, api = fetchPage) => ({
    setViewport(start: number, end: number) {
      patchState(store, { viewportStart: start, viewportEnd: end });
      const neededPages = this.pagesForRange(start, end, store.pageSize());
      neededPages.forEach(p => this.ensurePage(p));
      // evict far pages to cap memory
      const keep = new Set(neededPages);
      for (const key of store.cache().keys()) {
        if (!keep.has(key)) store.cache().delete(key);
      }
    },
    pagesForRange(start: number, end: number, size: number) {
      const s = Math.floor(start / size);
      const e = Math.floor(Math.max(end - 1, 0) / size);
      // prefetch one page ahead/behind
      return [s - 1, s, e, e + 1].filter(i => i >= 0);
    },
    async ensurePage(pageIndex: number) {
      if (store.cache().has(pageIndex)) return;
      const { rows, total } = await api(pageIndex, store.pageSize());
      const cache = new Map(store.cache());
      cache.set(pageIndex, rows);
      patchState(store, { cache, total });
    },
    visibleRows: computed(() => {
      const start = store.viewportStart();
      const end = store.viewportEnd();
      const size = store.pageSize();
      const rows: Row[] = [];
      for (let i = start; i < end; i++) {
        const p = Math.floor(i / size);
        const bucket = store.cache().get(p);
        const inBucket = bucket?.[i % size];
        rows.push(inBucket ?? { id: `ghost-${i}` });
      }
      return rows;
    })
  }))
);

// Replace with your typed API. Cursor-based works too.
async function fetchPage(pageIndex: number, pageSize: number) {
  const res = await fetch(`/api/events?page=${pageIndex}&size=${pageSize}`);
  return res.json() as Promise<{ rows: Row[]; total: number }>; 
}
<!-- CDK + MatTable -->
<cdk-virtual-scroll-viewport 
  itemSize="36" class="table-viewport" (scrolledIndexChange)="store.setViewport($event, $event + buffer)"
  [style.height.px]="600">
  <table mat-table [dataSource]="store.visibleRows()" class="mat-elevation-z0">
    <ng-container matColumnDef="id">
      <th mat-header-cell *matHeaderCellDef>ID</th>
      <td mat-cell *matCellDef="let r" [attr.aria-rowindex]="r?.rowIndex">{{ r.id }}</td>
    </ng-container>
    <tr mat-header-row *matHeaderRowDef="['id']"></tr>
    <tr mat-row *matRowDef="let row; columns: ['id']; trackBy: trackById"></tr>
  </table>
</cdk-virtual-scroll-viewport>
<!-- PrimeNG DataTable Virtual Scroll + Lazy -->
<p-table [value]="rows" [virtualScroll]="true" [virtualScrollItemSize]="densityRowHeight" [rows]="100"
        [scrollHeight]="'600px'" [lazy]="true" (onLazyLoad)="onLazy($event)" [totalRecords]="total">
  <ng-template pTemplate="header">
    <tr><th>ID</th></tr>
  </ng-template>
  <ng-template pTemplate="body" let-row let-ri="rowIndex">
    <tr [attr.aria-rowindex]="ri + 1" [attr.data-id]="row?.id">
      <td>{{ row?.id }}</td>
    </tr>
  </ng-template>
</p-table>
// component.ts (PrimeNG Lazy)
onLazy(e: { first: number; rows: number }) {
  const pageIndex = Math.floor(e.first / e.rows);
  this.store.setViewport(e.first, e.first + e.rows);
  this.total = this.store.total();
  this.rows = this.store.visibleRows();
}
/* Density tokens + AngularUX palette */
:root {
  --ux-density: compact; // compact|cozy|comfortable
  --ux-row-compact: 32px; --ux-row-cozy: 36px; --ux-row-comfortable: 44px;
  --ux-row-height: var(--ux-row-cozy);
  --ux-bg: #0b1220; --ux-surface: #121a2a; --ux-text: #e6ecff;
  --ux-accent: #6ea8fe; --ux-accent-contrast: #0a0f1a;
}
:root[data-density="compact"] { --ux-row-height: var(--ux-row-compact); }
:root[data-density="comfortable"] { --ux-row-height: var(--ux-row-comfortable); }
.table-viewport { background: var(--ux-surface); color: var(--ux-text); }
tr:focus-within { outline: 2px solid var(--ux-accent); outline-offset: -2px; }

SignalStore window + cache

CDK + MatTable template

PrimeNG DataTable lazy + virtual scroll

Density tokens and AngularUX palette

Performance budgets and CI guardrails for virtualized tables

# .github/workflows/lhci.yml
name: Lighthouse CI
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
      - run: npm run build -- --configuration=production
      - run: npx @lhci/cli autorun --collect.staticDistDir=dist/app --upload.target=filesystem --assert.assertions.performance>=0.9
// angular.json (bundle budgets excerpt)
{
  "budgets": [
    { "type": "bundle", "name": "main", "maximumWarning": "500kb", "maximumError": "700kb" },
    { "type": "anyComponentStyle", "maximumWarning": "6kb", "maximumError": "10kb" }
  ]
}

Use Angular DevTools to confirm CDK/PrimeNG rows reuse components (no unexpected re-renders).

Budgets you can defend

Codify budgets so performance isn’t a suggestion.

  • LCP < 2.5s dashboard routes

  • Long tasks < 100ms @ 95th

  • Memory < 400MB after 5 min scroll

CI wiring

Fail PRs that regress. Keep the wins.

  • Lighthouse CI

  • Bundle size limits

  • Angular DevTools traces

Real dashboard results: telecom and media

These aren’t lab numbers—they’re from shipping enterprise dashboards with Nx monorepos, Firebase/Node services, and strict CI.

Telecom analytics

Typed event schemas + SignalStore windowing + PrimeNG virtual scroll = stable real-time table. False alert rate dropped after jitter fixes.

  • 220k rows, 25 rps updates

  • 60–80% memory reduction

  • 0 dropped frames at 60fps on M1

Media VPS scheduler

We combined Highcharts for aggregates and a custom Canvas lane for schedule density. Virtualization kept DOM light while the canvas handled dense visuals.

  • MatTable + CDK vs canvas timeline

  • Hybrid: table for metadata, canvas for 10k+ slots

  • Predictable 36px rows enabled smooth sync scroll

Color, typography, and density: AngularUX palette that stays fast

A11y isn’t optional: role=grid patterns, aria-rowcount, and keyboard affordances must survive virtualization.

Typography ramps

Small sizes ups density without hurting readability when line-height ≥ 1.4 and contrast AA is met.

  • Inter/Roboto 12–14–16–20–24

  • Compact tables at 12/13px, headers 14/16px

AngularUX color palette

We test hover/selected/active against WCAG 2.1 AA. Virtualized rows must show focus/selection even when recycled.

  • Midnight surfaces (#0b1220/#121a2a)

  • Text #e6ecff, Accent #6ea8fe

  • AA contrast on all states

Density controls as a feature

Density ties visual polish to performance. Flip to compact and your viewport renders more rows with the same memory footprint.

  • Persist in local storage/SignalStore

  • Update itemSize + CSS var together

When to hire an Angular developer for data virtualization rescue

If you’re looking to hire an Angular developer with Fortune 100 experience, I can step in quickly and leave your team with maintainable patterns.

Symptoms I look for

If two or more show up in profiling, it’s time to virtualize.

  • Scroll jank over large datasets

  • DOM > 5,000 nodes with tables open

  • Memory over 500MB after 2–3 min

  • Screen reader chaos on scroll

Engagement pattern

I profile, implement CDK/PrimeNG virtualization, add windowed caching, and wire budgets to prevent regressions.

  • 2–4 weeks rescue for a single table

  • 4–8 weeks for multi-table dashboard

  • CI + budgets wired in week 1

What you get

If you need an Angular consultant to steady a dashboard under load, I’m available for remote engagements.

  • Before/after metrics with DevTools traces

  • A11y fixes, density controls, tokens

  • Docs for maintainers

Integrating with D3/Highcharts/Canvas without jank

In insurance telematics dashboards, data virtualization on tables plus Canvas lanes for dense spark-lines kept scrolling fluid while showing per-vehicle metrics.

Defer heavy cells

Render sparingly; cancel when out-of-view to avoid zombie work.

  • IntersectionObserver for charts/images

  • AbortController for fetch/canvas work

Shared window signal

Use a signal for the current index range; Highcharts or Canvas layers subscribe and render only in-view lanes.

  • One source of truth for viewport

  • Sync table and chart lanes

Key takeaways and next steps

  • Virtualize data, fix row heights, and window with Signals + SignalStore.
  • Choose CDK for control or PrimeNG for speed to value; both can hit 60fps.
  • Bake in a11y, density, and color tokens so polish doesn’t cost frames.
  • Lock the wins with CI budgets and DevTools traces.
  • Have a messy table today? Let’s review it and outline a 2-week plan.

FAQ: Data virtualization and hiring

If you need an Angular expert for hire to stabilize large datasets, I can help—from SignalStore windowing to CI guardrails.

How much does it cost to hire an Angular developer for this?

It depends on scope, but a focused 2–4 week rescue for one heavy table is common. I provide a fixed estimate after a code review and profiling session.

How long does an Angular virtualization upgrade take?

Simple CDK/PrimeNG virtualization with caching is usually 1–2 weeks. Multi-table dashboards and a11y remediations take 4–8 weeks including CI budgets.

Will virtualization break SEO or SSR?

Tables aren’t usually SEO-critical. For SSR routes, render initial above-the-fold rows and hydrate virtualization on the client. Keep LCP within budget.

Can this work with Firebase and real-time updates?

Yes. Use typed event schemas, buffer updates, and patch rows within the visible window. Backpressure and exponential retry protect scroll fluidity.

Related Resources

Key takeaways

  • Virtualize, don’t paginate: window data with Signals + SignalStore and only render visible rows.
  • Pick the right tool: CDK Virtual Scroll for control, PrimeNG DataTable for batteries-included lazy loading.
  • Guard the 16ms frame budget: fixed row heights, pure pipes, OnPush/zoneless, and stable row templates.
  • Optimize memory: cache windows, reuse row components, and teardown images/canvases when rows leave view.
  • Make it accessible: preserve tabindex/aria, manage focus on recycle, and keep high-contrast/color‑safe tokens.
  • Prove it in CI: Lighthouse/CWV budgets, Angular DevTools flame charts, and bundle-size thresholds in PRs.
  • Design polish can be fast: density controls, typography ramps, and AngularUX palette with AA contrast.

Implementation checklist

  • Use CdkVirtualScrollViewport or PrimeNG DataTable with [virtualScroll].
  • Implement a windowed cache (N before/after viewport) with Signals + SignalStore.
  • Fix row height and avoid dynamic content that shifts layout.
  • TrackBy stable IDs; avoid anonymous functions in templates.
  • Defer heavy cells (images/charts) until in-view and cancel when out-of-view.
  • Instrument FPS, memory (MB), and scroll jank (long tasks) in CI.
  • Add density and type scales; verify AA contrast and focus outlines.

Questions we hear from teams

How much does it cost to hire an Angular developer for data virtualization?
Most single-table rescues land in 2–4 weeks with a fixed estimate after profiling. Multi-table dashboards with a11y and CI budgets typically run 4–8 weeks.
What’s the difference between CDK Virtual Scroll and PrimeNG virtual scroll?
CDK gives you maximum control and customizability with MatTable or custom rows. PrimeNG provides virtual scroll plus sorting, filtering, and lazy loading out of the box.
Will virtualization affect accessibility?
Handled correctly, no. Preserve focus, use aria-rowindex/rowcount, stable trackBy keys, and ensure keyboard navigation works as rows are recycled.
Can virtualization handle real-time updates from Firebase or WebSockets?
Yes. Buffer events, patch visible rows first, and update cached pages lazily. Use typed events, backoff on errors, and keep the window signal as the source of truth.
Do you offer remote Angular consulting for this?
Yes. I’m a remote Angular consultant with 10+ years experience and Fortune 100 projects. Discovery call within 48 hours; assessment delivered within a 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 how I rescue chaotic codebases at gitPlumbers

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