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