Data Virtualization in Angular 20+: Smooth 100k‑Row Tables with Angular Material/PrimeNG, Signals, and Memory‑Safe Scrolling

Data Virtualization in Angular 20+: Smooth 100k‑Row Tables with Angular Material/PrimeNG, Signals, and Memory‑Safe Scrolling

How I keep 60fps scroll and sub‑200ms interactions on 100k+ row dashboards—without sacrificing AA accessibility, typography, or density controls.

Smooth scroll at 60fps isn’t luck—it’s a repeatable windowing strategy, a deterministic Signals slice, and ruthless attention to AA, density, and memory.
Back to all posts

I’ve shipped dashboards where a jittery scroll kills confidence faster than any bug. at a leading telecom provider’s ads analytics and a broadcast media network’s scheduler, we had 100k+ rows, real‑time updates, and execs flicking touchpads like DJ decks. If you need a senior Angular engineer to keep 60fps and AA compliance while rendering massive tables, this is the playbook I use today in Angular 20+ with Signals, PrimeNG, and the CDK.

As enterprises plan 2025 Angular roadmaps, virtualization is table stakes. You can’t render 100k DOM nodes. You barely want 500. We’ll walk through viewport windowing, server cursors, memory‑safe cells, and how UX polish—typography, density, and the AngularUX color palette—coexists with strict performance budgets.

When 100k‑Row Tables Jitter: How I Stopped the Scroll Stutter

If you’re looking to hire an Angular developer or Angular consultant for a data-heavy dashboard, virtualization is a non‑negotiable capability. The good news: Angular Material/CDK and PrimeNG both have first‑class support.

A real scene from enterprise dashboards

At a leading telecom provider, our ads dashboard had 100k+ line‑items with live pacing. The first build used plain p-table pagination. Execs switched to trackpad scroll and the UI tore—GC pauses, 20+ FPS drops, and INP spikes > 400ms. We replaced full renders with windowed virtualization, moved to Signals-backed slices, and cut heap growth by 70% while holding a stable 60fps.

Why it matters for Angular 20+ teams

  • Q1 hiring season is around the corner—don’t demo a laggy dashboard.

  • Virtualization reduces DOM, memory, and change detection work.

  • Signals + SignalStore make the view slice deterministic—critical for SSR and tests.

Why Angular Tables Choke on Large Datasets (and What to Measure)

I instrument dashboards the same way I instrument real‑time telemetry: typed events, repeatable test runs, and budget checks in CI. If your table feels slow, profile before you refactor.

Root causes

  • Too many DOM nodes (layout/paint thrash).

  • Inefficient change detection (no trackBy, heavy templates).

  • Large in‑memory arrays causing GC churn.

  • Synchronized charts updating at cell cadence.

Measure like an adult system

Set budgets in CI using Lighthouse CI and keep a regression gate on INP and heap. Tie scroll jank bugs to a metric, not a vibe.

  • FPS and dropped frames via Chrome Performance.

  • JS heap growth and GC pauses.

  • INP (Interaction to Next Paint) and LCP via Lighthouse/GA4.

  • Zone and template hotspots via Angular DevTools flame charts.

Data Virtualization Strategies for Angular Material and PrimeNG

Below are concrete snippets for CDK and PrimeNG, plus a Signals-backed store for the window.

Windowed scrolling with CDK Virtual Scroll

Render the rows the user can see plus a small buffer. Always use trackBy and lightweight cells.

PrimeNG VirtualScroller + TurboTable

PrimeNG’s p-table with virtualScroll handles row recycling and lazy windows; pair with sticky headers and keyboard navigation.

Server-side windowing with cursors

Match the viewport’s start/end index with a backend cursor (range queries, Firestore startAfter/limit). Avoid sending full arrays.

Signals + SignalStore for view slices

Keep a reactive, deterministic window of data using Signals; load new pages when the viewport crosses thresholds.

Template optimizations

  • ChangeDetectionStrategy.OnPush

  • trackBy identity

  • Avoid pipes/formatters in hot paths

  • Prefer pure functions and const templates

CDK Virtual Scroll: Markup + Signals Store

import { ChangeDetectionStrategy, Component, computed, effect, inject, signal } from '@angular/core';
import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';
import { map, switchMap } from 'rxjs/operators';

interface Row { id: string; name: string; value: number; }

class WindowService {
  private http = inject(HttpClient);
  fetchWindow(start: number, size: number) {
    // Server returns a window (not the whole dataset)
    return this.http.get<Row[]>(`/api/rows?start=${start}&size=${size}`);
  }
}

@Component({
  selector: 'aux-cdk-table',
  templateUrl: './table.html',
  styleUrls: ['./table.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CdkTableComponent {
  private win = inject(WindowService);

  readonly itemSize = 40; // sync with density token
  readonly buffer = 10;

  // Signals
  total = signal(100_000);
  start = signal(0); // viewport start index
  size = signal(100); // window size

  rows = signal<Row[]>([]);

  // Effect: fetch whenever start/size change
  readonly loadEffect = effect(() => {
    const s = this.start();
    const n = this.size();
    this.win.fetchWindow(s, n)
      .pipe(takeUntilDestroyed())
      .subscribe(data => this.rows.set(data));
  });

  trackById = (_: number, r: Row) => r.id;

  onScrolledIndexChange(i: number) {
    // prefetch when near the end
    if (i + this.buffer > this.start() + this.size()) {
      this.start.set(Math.min(i, this.total() - this.size()));
    }
  }
}
<!-- table.html -->
<cdk-virtual-scroll-viewport
  class="table-viewport"
  [itemSize]="itemSize"
  (scrolledIndexChange)="onScrolledIndexChange($event)"
  role="table"
  [attr.aria-rowcount]="total()">
  <div class="header" role="rowgroup">
    <div class="row header-row" role="row">
      <div class="cell" role="columnheader">Name</div>
      <div class="cell" role="columnheader">Value</div>
    </div>
  </div>

  <div
    *cdkVirtualFor="let r of rows(); trackBy: trackById"
    class="row"
    role="row"
    [attr.aria-rowindex]="r?.index">
    <div class="cell" role="cell">{{ r.name }}</div>
    <div class="cell" role="cell">{{ r.value | number:'1.0-0' }}</div>
  </div>
</cdk-virtual-scroll-viewport>
// table.scss
.table-viewport { height: 70vh; width: 100%; }
.row { display: grid; grid-template-columns: 1fr 120px; align-items: center; height: var(--row-height); }
.header { position: sticky; top: 0; z-index: 2; background: var(--aux-surface-1); color: var(--aux-ink-1); }
.cell { padding: 0 var(--space-12); }
:root {
  /* AngularUX palette & tokens */
  --aux-ink-1: #e6e8ee;
  --aux-ink-2: #b9becb;
  --aux-surface-1: #11131a;
  --aux-accent: #5de4c7;
  --font-size-14: 0.875rem;
  --space-12: 12px;
  /* Density */
  --row-height: 40px;
}
.density--comfortable { --row-height: 48px; }
.density--compact { --row-height: 32px; --font-size-14: 0.8125rem; }
.row, .cell { font-size: var(--font-size-14); }

Signals-backed window store

I often start with pure Signals, and use SignalStore when I want devtools and DI ergonomics. The idea is the same: keep a small, typed slice.

Template with accessibility and trackBy

Keep roles and aria-rowcount accurate. Sticky headers must remain outside the recycled row container to be announced correctly.

PrimeNG VirtualScroll and TurboTable Setup

<p-table
  [value]="rows()"
  [virtualScroll]="true"
  [virtualScrollItemSize]="rowHeight"
  [rows]="100"
  [scrollable]="true"
  styleClass="density--compact"
  [lazy]="true"
  (onLazyLoad)="onLazyLoad($event)"
  [rowTrackBy]="trackById"
  ariaLabel="Orders table"
>
  <ng-template pTemplate="header">
    <tr>
      <th>Name</th>
      <th class="num">Value</th>
    </tr>
  </ng-template>
  <ng-template pTemplate="body" let-r>
    <tr>
      <td>{{ r.name }}</td>
      <td class="num">{{ r.value | number:'1.0-0' }}</td>
    </tr>
  </ng-template>
</p-table>
// Component excerpt
rowHeight = 40;
rows = signal<Row[]>([]);

onLazyLoad(ev: { first: number; rows: number }) {
  const start = ev.first;
  const size = ev.rows;
  this.data.fetchWindow(start, size)
    .pipe(takeUntilDestroyed())
    .subscribe(win => this.rows.set(win));
}

trackById(index: number, r: Row) { return r.id; }

p-table with lazy virtual windows

PrimeNG’s virtualScroll + lazy event pairs well with Signals to update the slice. Keep the cell templates lean and use rowTrackBy.

Sticky headers and keyboard access

Use p-table’s scrollable + frozen columns if you need fixed ops columns; verify tab order when rows recycle.

Memory Optimization and Leak Prevention in Angular 20+

import { fromEvent, animationFrameScheduler } from 'rxjs';
import { auditTime, map } from 'rxjs/operators';

const viewport = document.querySelector('.table-viewport')!;
const scrolled$ = fromEvent(viewport, 'scroll').pipe(
  auditTime(0, animationFrameScheduler),
  map(() => (viewport as any).getOffsetToRenderedContentStart?.() ?? 0)
);

In real dashboards (an insurance technology company telematics), we also pool DOM for sparkline canvases and reuse ImageBitmaps to avoid churn when rows recycle.

Destroy patterns and GC-friendly code

  • takeUntilDestroyed for subscriptions

  • Avoid closures capturing large arrays

  • Reuse render buffers or typed arrays for canvas cells

OffscreenCanvas for heavy cells

If you render sparklines or heatmaps per row, pay the cost off the main thread. Highcharts Boost or Canvas mode, D3 with downsampling, or custom OffscreenCanvas in a worker can prevent main-thread jank.

Viewport event adapters

Convert scroll/resize events to Signals via computed/effect to avoid over-subscribing with RxJS. Throttle with requestAnimationFrame.

Scroll Performance with UX Polish: Accessibility, Typography, Density, and Color

:root {
  --aux-surface-0: #0b0d17; // app background
  --aux-surface-1: #11131a; // cards and table headers
  --aux-ink-1: #e6e8ee;     // primary text
  --aux-ink-dim: #9aa3b2;   // secondary text
  --aux-accent: #5de4c7;    // accent / selection
  --row-height: 40px;
  --line-height: 1.3;
}
.table-viewport .row:nth-child(even) { background: color-mix(in srgb, var(--aux-surface-1), #ffffff 2%); }
.cell { font-variant-numeric: tabular-nums; line-height: var(--line-height); }
.density--cozy { --row-height: 44px; }
.density--compact { --row-height: 32px; --line-height: 1.25; }

Measured with Lighthouse, these tokens cut reflow while keeping AA contrast. We’ve used this scheme across AngularUX products and role‑based dashboards.

Accessibility (AA) that survives virtualization

  • role=table/row/columnheader/cell semantics

  • aria-rowcount and aria-rowindex set accurately

  • Sticky headers outside recycled container

  • Keyboard focus persistence across recycled rows

Typography and density tokens

Use density classes to satisfy screen real estate constraints without killing readability. Tune line-height, row-height, and numeric tabular-nums fonts to reduce reflow.

AngularUX color palette

High-contrast palette with subtle an enterprise IoT hardware company striping improves scan speed. Use tokens to keep theme compute cheap.

End-to-End Example: Virtual Table Synced with Highcharts

// When the viewport window changes, update the linked chart with decimated points
const windowStart = signal(0);
const windowSize = signal(200);
const points = signal<number[]>([]); // full series from server (not bound to DOM!)

const windowedPoints = computed(() => {
  const s = windowStart();
  const e = s + windowSize();
  const slice = points().slice(s, e);
  // LTTB or simple decimation to <= 400 points for 60fps
  return decimate(slice, 400);
});

effect(() => {
  highchartsSeries.setData(windowedPoints(), false);
  highchartsChart.redraw();
});

This pattern also works with D3 or Canvas. For 200k+ points, consider Highcharts Boost or a Canvas/Three.js layer. Data virtualization isn’t just for rows—charts need it too.

Decimate chart data

  • Highcharts boost/dataGrouping

  • D3 downsampling (LTTB)

  • Three.js/Canvas for 100k+ points

Keep updates typed

In United’s airport kiosks we used exponential retries and typed event schemas to keep real‑time UIs stable—even offline. The same discipline applies to dashboards.

  • Typed event schemas over WebSocket

  • Exponential backoff + jitter on reconnect

When to Hire an Angular Developer for Legacy Rescue

See how we stabilize chaotic code and modernize safely at gitPlumbers—my code rescue platform with 99.98% uptime and a 70% velocity lift for teams mid‑upgrade.

Signs you need help

  • Scroll FPS < 45 on 10k rows

  • INP > 200ms after table interactions

  • Heap grows unbounded during continuous scroll

  • A11Y breaks when rows recycle

What I do in week one

If you need an Angular expert who’s done this at a global entertainment company, Charter, a broadcast media network, and an insurance technology company, I’m a remote Angular consultant available for targeted rescue or full rebuilds.

  • Profile with Angular DevTools + Chrome Performance

  • Replace pagination with viewport windowing

  • Introduce Signals/SignalStore slices and trackBy

  • Guard leaks; set CI performance budgets

Closing Takeaways and Next Steps

  • Virtualize both DOM and data; never bind the full array.
  • Signals or SignalStore make the slice deterministic and testable.
  • PrimeNG and CDK both reach 60fps; pick based on features and your design system.
  • Keep AA accessibility, typography, density, and color tokens first‑class.
  • Measure FPS, INP, and heap in CI; regressions should fail builds.

If you’re planning a 2025 dashboard or need a quick rescue, let’s review your build, pick the right virtualization path, and ship a smooth, accessible table that scales to 100k+ rows.

FAQs: Data Virtualization, Hiring, and Timelines

Related Resources

Key takeaways

  • Virtualize both DOM and data: window rows in the viewport and fetch server-side windows via cursors or range queries.
  • Use Signals/SignalStore to drive a deterministic view slice and reduce change detection churn.
  • Stick to AA accessibility: role semantics, sticky headers, and keyboard focus that survives recycled rows.
  • Tune density and typography tokens to reduce layout cost without sacrificing readability.
  • Measure rigorously: FPS, JS heap growth, GC pauses, and interaction to next paint (INP) via Angular DevTools + Lighthouse.
  • PrimeNG and Angular CDK both deliver 60fps—choose based on your design system and table features.

Implementation checklist

  • Adopt windowed rendering (CDK Virtual Scroll or PrimeNG VirtualScroller).
  • Implement server-side windowing (cursor/range) that matches viewport indices.
  • Use Signals or SignalStore for the current window slice; OnPush and trackBy everywhere.
  • Guard against leaks: destroy patterns, takeUntilDestroyed, and object pooling for cells.
  • Instrument scroll and memory with Angular DevTools, Chrome Performance, and Lighthouse.
  • Respect AA: roles, aria-rowcount, sticky header/footer semantics, and keyboard focus persistence.
  • Apply density controls and typography tokens to cut reflow without hurting legibility.
  • Decimate chart data (Highcharts/D3/Canvas) when synchronized with table scroll.

Questions we hear from teams

How long does it take to virtualize a large Angular table?
A focused engagement is 1–2 weeks: day 1–2 profiling, day 3–5 implementing windowed rendering (CDK or PrimeNG) and server cursors, week 2 for AA/UX polish, tests, and CI budgets. Complex charts add 2–3 days.
Angular Material or PrimeNG for virtualization?
Both hit 60fps. CDK Virtual Scroll is minimal and flexible; PrimeNG p-table adds sticky columns, selection, and filtering. Choose based on your design system and requirements, not performance alone.
Will virtualization break accessibility?
It doesn’t have to. Keep role semantics, aria-rowcount/index, and focus management outside recycled nodes. Test with screen readers and keyboard only. Sticky headers should not be recycled.
Can Signals replace my NgRx setup for tables?
For viewport slices, Signals or SignalStore are ideal—deterministic and low overhead. Keep NgRx for domain state if you already use it. Use typed adapters between streams and Signals for clarity.
What’s a typical Angular engagement and cost?
Discovery call within 48 hours. Assessment delivered in 5 business days. Rescue/implementation takes 1–4 weeks depending on scope. Pricing varies by complexity; fixed-scope pilots are available for virtualization work.

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 code rescue results at gitPlumbers (70% velocity, 99.98% uptime)

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