SfSkills Domains Docs GitHub

Home / lwc / lwc-virtualized-lists

lwc-virtualized-lists

Hand-built virtual/windowed list rendering in LWC for datasets above datatable limits — IntersectionObserver sentinels. Triggers: virtual list LWC, windowed rendering, infinite scroll custom. NOT for built-in lightning-datatable infinite loading — use lwc/lwc-datatable-advanced. NOT for simple lists under ~100 rows — use lwc/virtualized-lists.

Open the skill folder on GitHub


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


Core Concepts

The four mechanics of a hand-built virtual list

  1. 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.
  2. 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.
  3. 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.
  4. 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

FlavorWhat you renderWhen to use
Append-only / infinite scrollAll loaded rows stay in the DOM; you grow the rendered slice as the user scrollsSmall-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 DOMLarge 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:

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}
    onloadmore={handleLoadMore}>
</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:

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:

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

SituationApproachReason
Tabular rows, 1,000–10,000 totallightning-datatable + enable-infinite-loadingPlatform-native; meets datatable's documented threshold
Tabular rows, >10,000 totalCustom append-only virtual listDatatable degrades past the threshold
Custom card layout (non-tabular rows)Custom append-only virtual listDatatable's column model doesn't fit
User can scroll back up across many pagesTrue windowing (Pattern C)Append-only's DOM grows without bound
Frequent server re-pagination on the same datasetGraphQL cursor paginationAvoids OFFSET re-evaluation cost
Inline edit on multiple columnsStay with datatable, accept the perf hitInline 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 implementingIntersectionObserver 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 virtualizingDOM count is one input to perf; cell complexity is another
Total rows < 1,000Plain for:each rendering, no virtualizationBelow the threshold, virtualization adds complexity for no gain

  1. Try lightning-datatable + enable-infinite-loading first. Profile against the actual data volume.
  2. If datatable is the wrong shape, choose append-only vs true windowing. Append-only unless DOM growth is a real ceiling.
  3. Wire IntersectionObserver in renderedCallback, with root set to the in-shadow-root scrollable ancestor.
  4. Use stable key={item.id} on every for-each item.
  5. Cap server pages at 50 rows per Salesforce performance guidance.
  6. Disconnect the observer in disconnectedCallback to avoid leaks.
  7. Consider cursor pagination if LIMIT/OFFSET re-evaluation cost is a profile-visible bottleneck.

Review Checklist


Salesforce-Specific Gotchas

  1. No first-party virtual-list base component in LWC. You hand-build. (See references/gotchas.md § 1.)
  2. IntersectionObserver inside shadow DOM needs in-shadow-root root. Default null (viewport) often doesn't fire inside Lightning. (See references/gotchas.md § 2.)
  3. lightning-datatable degrades past 1,000 rows × 5 columns. Documented Salesforce threshold. (See references/gotchas.md § 3.)
  4. For tables > 250 rows, keep < 20 columns. Per Salesforce guidance. (See references/gotchas.md § 4.)
  5. Inline editing on multiple columns is itself a major perf hit. Reserve for critical fields. (See references/gotchas.md § 5.)
  6. Observers wired in connectedCallback against an unrendered sentinel silently fail. Use renderedCallback. (See references/gotchas.md § 6.)
  7. OFFSET-based pagination re-evaluates the predicate at every page. GraphQL cursor or LastModifiedDate + Id keyset is cheaper for large datasets. (See references/gotchas.md § 7.)

Output Artifacts

ArtifactDescription
LWC bundle.html template + .js controller + .js-meta.xml config
Apex / GraphQL paginatorServer-side LIMIT/OFFSET or cursor-based fetch contract
Performance baselineRender time + DOM node count + JS heap at p50, p95 row counts
Profile evidenceConfirmation that the chosen flavor (datatable / append-only / windowing) actually fits the workload

Package files