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