Data Virtualization That Feels Native: 60fps Angular 20+ Tables with Material, PrimeNG, and Signals

Data Virtualization That Feels Native: 60fps Angular 20+ Tables with Material, PrimeNG, and Signals

How I ship enterprise tables that scroll smoothly across 100k+ rows—SignalStore windowing, CDK/PrimeNG virtual scroll, prefetching, and memory discipline without losing UX polish.

If it doesn’t scroll at 60fps, it’s not done. Your data can be huge—your UX should still feel effortless.
Back to all posts

I’ve shipped tables that stream ad impressions at 50k events/min, managed 200k+ devices in an IoT fleet portal, and reviewed weekly payroll changes across 120k employees. If a grid jitters, leadership notices. This is how I keep Angular 20+ tables at 60fps while preserving an accessible, branded visual language.

The Jitter Problem—and Why Virtualization Wins

A real dashboard scene

Pre-upgrade, the table stuttered when filters changed and RAM crept over 600 MB after 30 minutes. We fixed it by virtualizing the viewport, windowing data in a SignalStore, and prefetching ahead of the scroll. The result was a locked 60fps on mid-range laptops and <220 MB steady memory.

  • Telecom ads analytics: 180k rows/day

  • Role-based columns: 12–40 depending on permissions

  • WebSocket updates every 250–500ms

Why it matters for 2025 Angular roadmaps

You don’t need to compromise. With the right patterns, Angular Material or PrimeNG tables can feel native at scale. If you need an Angular expert to guide this in a multi-team Nx monorepo, I’ve done it across aviation, telecom, and insurance.

  • Hiring season will ask for verifiable UX metrics

  • Signals and built-in control flow are standard in 20+

  • Budgets are tight—polish must coexist with performance

UX Foundations: Typography, Density, and the AngularUX Color Palette

Example token setup (compatible with Material and PrimeNG skins):

:root {
  /* AngularUX palette */
  --ux-bg: #0f1115;
  --ux-surface: #161a22;
  --ux-text: #e6e9ef;
  --ux-muted: #b3b9c5;
  --ux-primary: #5bc0ff; /* 4.5:1 on surface */
  --ux-accent: #8ef6a0;

  /* Density + typography */
  --row-h: 40px;        /* comfortable */
  --cell-px: 12px;
  --font-size: 14px;
}

[data-density="compact"] {
  --row-h: 32px;
  --cell-px: 8px;
  --font-size: 13px;
}

.mat-mdc-row, .p-datatable-tbody > tr {
  height: var(--row-h);
}
.mat-mdc-cell, .p-datatable-tbody > tr > td {
  padding-inline: var(--cell-px);
  font-size: var(--font-size);
}

/* Focus rings with contrast + performance */
:focus-visible {
  outline: 2px solid color-mix(in oklab, var(--ux-primary), white 20%);
  outline-offset: 2px;
}

Tokens that render fast

Density toggles should not reflow the world. Drive row height, padding, and font ramps from CSS variables so a switch between comfortable/compact doesn’t hit layout thrash. The AngularUX palette maintains ≥4.5:1 contrast, and states rely on opacity/transform for cheap GPU work.

  • CSS variables for density/typography

  • GPU-friendly hover/focus states

  • AA/AAA contrast by default

SCSS token example

Implementation: Virtual Scroll with CDK and PrimeNG

SignalStore and component excerpt:

import { Injectable, computed, effect, signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';
import { finalize, map, takeUntil } from 'rxjs/operators';
import { Subject, of } from 'rxjs';

interface Row { id: string; ts: number; metricA: number; }
interface Page { rows: Row[]; next?: string; prev?: string; }

@Injectable({ providedIn: 'root' })
export class VirtualTableStore {
  private pageSize = 200;
  private destroy$ = new Subject<void>();

  // Cursor + cache state
  private cursor = signal<string | null>(null);
  private cache = signal<Row[]>([]);   // recycled row objects preferred IRL
  private loading = signal(false);

  // Expose a visible window
  public windowStart = signal(0);
  public windowSize = signal(400);
  public visible = computed(() => {
    const start = this.windowStart();
    const size = this.windowSize();
    const arr = this.cache();
    return arr.slice(start, start + size);
  });

  constructor(private http: HttpClient) {}

  fetchPage(cursor?: string) {
    this.loading.set(true);
    const abort$ = new Subject<void>();
    // replace with your API / Firestore query
    return this.http.get<Page>('/api/rows', { params: { cursor: cursor ?? '' } })
      .pipe(
        finalize(() => this.loading.set(false)),
        takeUntil(abort$)
      );
  }

  init(firstCursor?: string) {
    this.cursor.set(firstCursor ?? null);
    this.fetchPage(firstCursor).subscribe(page => {
      this.cache.set(page.rows);
      this.cursor.set(page.next ?? null);
      this.prefetch();
    });
  }

  onScrolledIndexChange(index: number) {
    // Move the window and prefetch when we’re near the end
    this.windowStart.set(index);
    const threshold = this.cache().length - this.pageSize;
    if (index > threshold) this.prefetch();
  }

  private prefetch() {
    const next = this.cursor();
    if (!next || this.loading()) return;
    this.fetchPage(next).subscribe(page => {
      // Recycle existing Row objects in practice to avoid GC pressure
      this.cache.set([...this.cache(), ...page.rows]);
      this.cursor.set(page.next ?? null);
    });
  }
}

Material + CDK template and component usage:

<cdk-virtual-scroll-viewport itemSize="40" class="viewport" (scrolledIndexChange)="store.onScrolledIndexChange($event)">
  <table mat-table [dataSource]="store.visible()" [trackBy]="trackById" class="mat-elevation-z1">
    <ng-container matColumnDef="id">
      <th mat-header-cell *matHeaderCellDef> ID </th>
      <td mat-cell *matCellDef="let r"> {{ r.id }} </td>
    </ng-container>
    <!-- more columns -->
    <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
    <tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
  </table>
</cdk-virtual-scroll-viewport>
@Component({
  selector: 'app-virtual-table',
  templateUrl: './virtual-table.html',
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class VirtualTableComponent {
  displayedColumns = ['id'];
  constructor(public store: VirtualTableStore) { this.store.init(); }
  trackById = (_: number, r: Row) => r.id;
}

PrimeNG virtual table configuration:

<p-table
  [value]="rows"
  [virtualScroll]="true"
  [rows]="100"
  [lazy]="true"
  (onLazyLoad)="onLazyLoad($event)"
  [rowHeight]="40"
  [style]="{ height: '600px' }">
  <!-- columns -->
</p-table>
onLazyLoad(e: { first: number; rows: number }) {
  // map first/rows to your SignalStore window + prefetch
  this.store.windowStart.set(e.first);
  if (e.first + e.rows > this.store.visible().length - 100) {
    this.store['prefetch']();
  }
}

SignalStore for windowing + prefetch

Signals keep the data path simple and reactive without over-rendering. The store maintains cursors and a small cache. When the viewport approaches the end of the window, we prefetch and recycle.

  • Window of 200–400 rows

  • Prefetch one page ahead with abort

  • Computed signal exposes visible rows

Material + CDK template

  • OnPush + trackBy to avoid churn

  • cdkFixedSizeVirtualScroll for stable row height

PrimeNG configuration

  • Use virtualScroll + lazy

  • Implement onLazyLoad with cursor tokens

Memory Optimization and Object Reuse

Avoid object churn

The biggest leak I see: recreating arrays/objects every tick. Recycle row objects and mutate fields in place when safe, or maintain a small pool per page. TrackBy must point to an invariant key.

  • Reuse row view models

  • Prefer struct-like objects

  • TrackBy on stable IDs

Detach change detection on heavy cells

For KPI sparkline cells (Highcharts/Canvas), render only visible rows. Use an IntersectionObserver per rendered cell to mount/unmount the mini chart. Three.js thumbnails? Swap to a static sprite until focused.

  • Charts/images in cells should lazy render

  • Use IntersectionObserver or CDK observers

Server Cursors, Real‑Time Updates, and Firehose Control

Typed effect sketch for streaming updates to the windowed cache:

const enqueue = signal<Row[]>([]);

// Push incoming websocket rows
function onSocket(rows: Row[]) { enqueue.set([...enqueue(), ...rows]); }

effect(() => {
  const batch = enqueue();
  if (!batch.length) return;
  requestAnimationFrame(() => {
    // cheap merge into the end of cache
    store.cache.set([...store.cache(), ...batch]);
    enqueue.set([]);
  });
});

Cursor-based APIs

Offsets are brittle at high volume. I use cursor tokens that include role-based filters so the server can advance deterministically and avoid duplicate rows on backfill.

  • Prefer cursor tokens over offsets

  • Encode role/filters into the cursor

Realtime without jitter

In telecom dashboards, we batch WebSocket updates into micro-queues and flush them on animation frames to avoid layout thrash. All events are typed; retries use exponential backoff with jitter. Firebase/Firestore streams map neatly to this model with query cursors.

  • Buffer WebSocket events

  • Typed event schemas

  • Exponential backoff

Instrumentation, Performance Budgets, and CI Guardrails

GitHub Actions excerpt running Cypress + Lighthouse:

name: table-perf
on: [pull_request]
jobs:
  perf:
    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 lighthouse http://localhost:4200 --budgets-path=budgets.json --quiet --chrome-flags="--headless=new"
      - run: npx cypress run --config-file cypress.config.ts --spec cypress/e2e/virtual-scroll.cy.ts

What to measure

Use Angular DevTools to ensure your table isn’t re-rendering full rows. In Chrome Performance, look for long GC pauses and layout events during rapid scroll. I budget ≤2 new rows/ms during flings on a mid-range laptop.

  • FPS during fast scroll

  • Row creation count/min

  • GC pause > 50ms

CI examples

We run a scripted scroll in Cypress and capture FPS + heap. Fail the PR if budgets regress. Logs stream into Firebase; GA4 records scroll Jank Rate so product can see improvements release-to-release.

  • Cypress + Lighthouse CI

  • Firebase Logs + GA4 custom metrics

Integrating Virtualized Tables with D3/Highcharts/Canvas

Sparklines that don’t stutter

In insurance telematics, our per-row speed sparkline used Canvas with precomputed scales and a shared color ramp from the AngularUX palette. Result: ~0.4ms draw per cell, no jank.

  • Canvas for tiny charts

  • Precompute scales

  • GPU-friendly color ops

Selection -> chart focus

Virtualization means selected index != absolute index. Maintain an id->absIndex map so D3 details can fetch the right series quickly.

  • One-way write to a detail chart

  • Virtual row index maps

How an Angular Consultant Approaches Table Virtualization

Assessment (days 1–3)

I profile your current grid, measure jank, and check memory for detached nodes. We agree on budgets and success metrics.

  • DevTools flame charts

  • Scroll scripts

  • Heap snapshots

Implementation (weeks 1–2)

I pair with your lead to wire the store and viewport, add prefetch with abort controllers, and keep accessibility intact.

  • SignalStore windowing

  • CDK/PrimeNG virtualScroll

  • Server cursors + abort

Hardening (week 3+)

We lock in budgets via CI, document the design tokens (density, color, typography), and hand off with examples for new tables.

  • CI budgets

  • A11y keyboard/AT tests

  • Docs + handoff

When to Hire an Angular Developer for Legacy Rescue

Signals you need help now

If your AngularJS/Angular 8–14 grid lags, I’ve done these rescues: airport kiosks with offline tables, broadcast VPS schedulers, and telecom analytics. I upgrade to Angular 20+, migrate to Signals, and stabilize without pausing delivery.

  • Table freezes on long scroll

  • Memory > 400 MB after 10 minutes

  • AT users lose focus on recycled rows

Related Resources

Key takeaways

  • Use CDK Virtual Scroll + OnPush + trackBy to keep table scroll at 60fps for 100k+ rows.
  • Window data with a SignalStore and prefetch ahead by 1–2 pages using abortable requests.
  • Recycle row view models and avoid JSON.parse/clone churn to cap memory <250 MB in long sessions.
  • Unify UX tokens (typography, density, color) with CSS variables so density/contrast changes stay GPU-friendly.
  • Instrument scroll FPS, GC pauses, and row creation counts in CI; fail builds when budgets regress.

Implementation checklist

  • Adopt CDK Virtual Scroll or PrimeNG virtualScroll for large tables.
  • Use SignalStore to manage window, cursors, and abortable prefetch.
  • Enable OnPush, trackBy, and mat-table/PrimeNG row recycling.
  • Prefetch one page ahead; throttle to animation frames.
  • Implement density tokens (comfortable/compact) with CSS variables.
  • Run Angular DevTools flame charts and Chrome Performance to check GC/fps.
  • Add CI budgets for time-to-interaction and memory snapshots.
  • Test with keyboard navigation and screen readers; maintain focus on recycled rows.

Questions we hear from teams

How much does it cost to hire an Angular developer for virtualization work?
Most engagements land between 2–4 weeks. A focused audit + implementation typically falls in the mid five figures, depending on scope (Material vs PrimeNG, real‑time streams, CI budgets). I fix the root causes and leave metrics and docs so your team can sustain it.
What’s the difference between pagination and virtualization in Angular tables?
Pagination fetches fixed pages and re-renders the entire table per page. Virtualization renders only visible rows and a small buffer, providing smooth scrolling and lower memory. You can combine both: server cursors paginate data while the viewport virtualizes rendering.
How long does an Angular upgrade plus table virtualization take?
For Angular 14→20 migrations with one complex grid, plan 4–8 weeks: 1–2 for upgrade and guardrails, 1–2 for virtualization and prefetching, and 1–2 for CI/a11y hardening. Smaller apps move faster; I provide a detailed timeline after a 1‑week assessment.
Can PrimeNG and Angular Material both achieve 60fps?
Yes. With OnPush, trackBy, virtualScroll, and a SignalStore window, both can hit 60fps on mid-range hardware. The key is stable row heights, object reuse, prefetching on animation frames, and avoiding heavy DOM in cells.
How do you ensure accessibility with virtualized rows?
Maintain focus on recycled rows, ensure row roles/ARIA are correct, and keep keyboard navigation deterministic. Test with screen readers and high-contrast modes. Density and typography tokens must preserve readable line-height and focus rings at all sizes.

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 Need a code rescue? See how I stabilize chaotic Angular apps

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