
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.tsWhat 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
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.
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