LWC Virtualized Lists
Salesforce's own performance guidance puts the soft ceiling at 1,000 rows
× 5 columns for lightning-datatable. Above that, the experience degrades
— and the platform has no first-party virtual-list base component, so the
team has to hand-build windowing. This skill is the playbook for that
custom build, including the shadow-DOM pitfalls that make off-the-shelf
browser virtualization libraries unreliable inside Lightning.
What this skill is NOT. If lightning-datatable with enable-infinite-loading
meets the requirement, use that — it's the platform's first-party answer.
This skill is for cases where datatable's column model, edit semantics, or
performance ceiling don't fit. lightning-tree-grid for hierarchical data
is also out of scope.
Before Starting
- Try
lightning-datatablefirst. Addenable-infinite-loadingand anonloadmorehandler. Most "I need a virtual list" requests resolve here. - Profile the actual bottleneck. Is it row count (DOM nodes), column count (cell complexity), or per-row component complexity (custom data types, nested templates)? Each has a different fix.
- Decide where the data lives. Already-in-memory client list (filter / sort happens client-side) vs server-paged list (each window fetches a page from Apex / GraphQL). Server-paged adds latency per scroll-window shift; in-memory hits the JS heap on large datasets.
- Confirm the row count is real. "We might have a million rows" is often false in practice; the actual production max is in the 10K–50K range. The right design at 50K is different from at 1M.
Core Concepts
The four mechanics of a hand-built virtual list
- Window state — JS state holds only the visible-plus-buffer slice, e.g. rows 0..49 of a 50,000-row dataset. The template renders that slice via
for:each, NOT the full dataset. - Scroll sentinel + IntersectionObserver — empty marker
<div>at the bottom of the rendered slice. When the sentinel intersects the viewport, the observer callback fires; you shift / extend the window. - Stable item keys — every rendered item needs a
key={item.id}that doesn't change across window shifts. Stable keys let LWC reuse DOM nodes across re-renders rather than tearing down and rebuilding. - Server pagination contract — for server-backed data, define how a window-shift translates to a fetch. Usually LIMIT/OFFSET on Apex or cursor-based on GraphQL. Cap at 50 rows per page per Salesforce performance guidance.
Two flavors of virtualization, very different work
| Flavor | What you render | When to use |
|---|---|---|
| Append-only / infinite scroll | All loaded rows stay in the DOM; you grow the rendered slice as the user scrolls | Small-ish total (~5,000 rows), simple rows, rarely scroll back up |
| True windowing (slide the window) | DOM only ever holds the visible-plus-buffer slice; rows above and below scroll position are removed from the DOM | Large totals (>10,000 rows), or DOM weight is the bottleneck |
Append-only is much simpler. True windowing requires absolute positioning (or a spacer-div trick) to keep the scrollbar's geometry consistent as DOM nodes come and go. Most LWC virtual lists are append-only with a sensible upper cap (drop oldest pages when you exceed N pages in memory).
Shadow DOM and IntersectionObserver
The browser-standard IntersectionObserver works inside Lightning's
shadow DOM with two caveats:
- The
rootoption must be the scrollable ancestor that lives in the same shadow root as the sentinel. If you pass an element from a different shadow tree (or the document), the observer never fires. - The sentinel element must be rendered before you create the observer.
Calling
new IntersectionObserver(...)inconnectedCallbackagainst a sentinel that hasn't been queried yet returns silently — wire it up inrenderedCallbackafterthis.template.querySelector(...)finds the node.
Off-the-shelf virtualization libraries (react-virtualized, vanilla virtual-list packages) often assume a document-level scroll context. Inside a Lightning record page they don't reach the right shadow root and fail silently — which is why the Salesforce team's official guidance caveat says "don't support creation of infinite list items" and stops short of a recipe.
Common Patterns
Pattern A — lightning-datatable with enable-infinite-loading (try this first)
// list.js
import { LightningElement, wire } from 'lwc';
import getRows from '@salesforce/apex/RowService.getRows';
export default class List extends LightningElement {
rows = [];
columns = [/* … */];
pageSize = 50;
enableInfiniteLoading = true;
connectedCallback() {
this.loadMore();
}
async handleLoadMore(event) {
event.target.isLoading = true;
await this.loadMore();
event.target.isLoading = false;
}
async loadMore() {
const more = await getRows({ offset: this.rows.length, limit: this.pageSize });
this.rows = this.rows.concat(more);
}
}
<lightning-datatable
key-field="Id"
data={rows}
columns={columns}
enable-infinite-loading={enableInfiniteLoading}
</lightning-datatable>
This handles 1,000–10,000 rows comfortably. Above that, datatable's DOM weight starts to bite — that's when you go custom.
Pattern B — Append-only custom virtual list
When to use. Datatable's column model doesn't fit (custom card layout, non-tabular rows). Up to ~10,000 rows total in the DOM is acceptable.
<template>
<ul class="virtual-list" lwc:dom="manual">
<template for:each={visible} for:item="item">
<li key={item.id}>
<c-row-card data={item}></c-row-card>
</li>
</template>
<li class="sentinel" data-sentinel="true"></li>
</ul>
</template>
import { LightningElement } from 'lwc';
import getRows from '@salesforce/apex/RowService.getRows';
export default class VirtualList extends LightningElement {
visible = []; // rows currently rendered
pageSize = 50;
hasMore = true;
_observer;
async connectedCallback() {
await this.loadNextPage();
}
renderedCallback() {
if (this._observer || !this.hasMore) return;
const sentinel = this.template.querySelector('[data-sentinel="true"]');
if (!sentinel) return; // not yet rendered
// Root must be the scrollable ancestor IN THE SAME SHADOW ROOT.
const root = this.template.querySelector('.virtual-list');
this._observer = new IntersectionObserver(this.onIntersect.bind(this), {
root,
rootMargin: '200px',
threshold: 0,
});
this._observer.observe(sentinel);
}
onIntersect(entries) {
if (entries.some(e => e.isIntersecting)) {
this.loadNextPage();
}
}
async loadNextPage() {
const more = await getRows({ offset: this.visible.length, limit: this.pageSize });
if (more.length === 0) {
this.hasMore = false;
this._observer?.disconnect();
return;
}
this.visible = this.visible.concat(more);
}
disconnectedCallback() {
this._observer?.disconnect();
}
}
Two things this gets right that hand-rolled-from-tutorial code often misses:
- The IntersectionObserver
rootis the scrollable ancestor inside the same shadow root, notnull(which would mean "the viewport" and may not work inside Lightning's shadow tree). renderedCallback(notconnectedCallback) is where the observer is wired up, after the sentinel is actually queryable.
Pattern C — True windowing (slide-the-window)
When to use. Total dataset > 10,000 rows AND the user can scroll back up. Append-only would balloon the DOM past acceptable.
The DOM only ever holds rows [start, start + window) for a window of,
say, 100. As the user scrolls, you adjust start and re-render the slice.
Two complications:
- Scrollbar geometry. As DOM rows leave / enter, the document height
changes; the scrollbar jumps. Fix with two spacer divs above and below
the rendered slice with heights
start * rowHeightand `(total - start- window) * rowHeight`. The scrollbar now reflects total height.
- Two sentinels. One above the slice (intersection → window slides up), one below (intersection → window slides down). Both observers attach to the same scrollable root.
This pattern is significantly more code. Use only when append-only's memory ceiling is a real ceiling — most LWC use cases never reach it.
Pattern D — GraphQL cursor pagination instead of LIMIT/OFFSET
When to use. Server data, large total, frequent re-paginations. LIMIT/OFFSET pages re-evaluate the query at each page boundary; cursor pagination uses an opaque token to resume from the last record without re-evaluating the full predicate.
GraphQL UI API supports cursor pagination natively. For Apex-backed
data, implement cursor-style by using LastModifiedDate + Id as the
cursor and filtering WHERE LastModifiedDate > :cursor.timestamp OR (LastModifiedDate = :cursor.timestamp AND Id > :cursor.id).
Decision Guidance
| Situation | Approach | Reason |
|---|---|---|
| Tabular rows, 1,000–10,000 total | lightning-datatable + enable-infinite-loading | Platform-native; meets datatable's documented threshold |
| Tabular rows, >10,000 total | Custom append-only virtual list | Datatable degrades past the threshold |
| Custom card layout (non-tabular rows) | Custom append-only virtual list | Datatable's column model doesn't fit |
| User can scroll back up across many pages | True windowing (Pattern C) | Append-only's DOM grows without bound |
| Frequent server re-pagination on the same dataset | GraphQL cursor pagination | Avoids OFFSET re-evaluation cost |
| Inline edit on multiple columns | Stay with datatable, accept the perf hit | Inline edit on >2-3 columns is itself a major perf hit per Salesforce guidance — virtualization can't compensate |
| Component lives outside a Lightning page (Experience Cloud, standalone aura host) | Verify shadow-DOM scope before implementing | IntersectionObserver root must be in the same shadow root as the sentinel |
| Bottleneck is per-row complexity (custom data type with many sub-components) | Simplify the row component before virtualizing | DOM count is one input to perf; cell complexity is another |
| Total rows < 1,000 | Plain for:each rendering, no virtualization |
Below the threshold, virtualization adds complexity for no gain |
Recommended Workflow
- Try
lightning-datatable+enable-infinite-loadingfirst. Profile against the actual data volume. - If datatable is the wrong shape, choose append-only vs true windowing. Append-only unless DOM growth is a real ceiling.
- Wire IntersectionObserver in
renderedCallback, withrootset to the in-shadow-root scrollable ancestor. - Use stable
key={item.id}on every for-each item. - Cap server pages at 50 rows per Salesforce performance guidance.
- Disconnect the observer in
disconnectedCallbackto avoid leaks. - Consider cursor pagination if LIMIT/OFFSET re-evaluation cost is a profile-visible bottleneck.
Review Checklist
-
lightning-datatable + enable-infinite-loadingwas tried before going custom. - Row count + column count are profile-measured, not estimated.
- IntersectionObserver
rootoption is the in-shadow-root scrollable element, notnull. - Observer is wired in
renderedCallback, notconnectedCallback. - Every rendered item has a stable
key={item.id}. - Server pages capped at 50 rows.
- Observer is disconnected in
disconnectedCallback. - Inline edit, if present, is on ≤3 columns.
Salesforce-Specific Gotchas
- No first-party virtual-list base component in LWC. You hand-build. (See
references/gotchas.md§ 1.) - IntersectionObserver inside shadow DOM needs in-shadow-root
root. Defaultnull(viewport) often doesn't fire inside Lightning. (Seereferences/gotchas.md§ 2.) lightning-datatabledegrades past 1,000 rows × 5 columns. Documented Salesforce threshold. (Seereferences/gotchas.md§ 3.)- For tables > 250 rows, keep < 20 columns. Per Salesforce guidance. (See
references/gotchas.md§ 4.) - Inline editing on multiple columns is itself a major perf hit. Reserve for critical fields. (See
references/gotchas.md§ 5.) - Observers wired in
connectedCallbackagainst an unrendered sentinel silently fail. UserenderedCallback. (Seereferences/gotchas.md§ 6.) - OFFSET-based pagination re-evaluates the predicate at every page. GraphQL cursor or
LastModifiedDate + Idkeyset is cheaper for large datasets. (Seereferences/gotchas.md§ 7.)
Output Artifacts
| Artifact | Description |
|---|---|
| LWC bundle | .html template + .js controller + .js-meta.xml config |
| Apex / GraphQL paginator | Server-side LIMIT/OFFSET or cursor-based fetch contract |
| Performance baseline | Render time + DOM node count + JS heap at p50, p95 row counts |
| Profile evidence | Confirmation that the chosen flavor (datatable / append-only / windowing) actually fits the workload |
Related Skills
lwc/lwc-data-table— when datatable is the right shape (this skill is the escape hatch).lwc/lwc-performance-best-practices— broader LWC performance patterns (memoization,@track, render scoping).apex/apex-soql-pagination— server-side LIMIT/OFFSET vs cursor patterns referenced here.lwc/lwc-shadow-dom-patterns— shadow-DOM-aware DOM manipulation generally; IntersectionObserver is one instance.