Skip to content
Back to skills

Lwc Virtualized Lists

ASecurity

Hand-built virtual / windowed list rendering in LWC for datasets above the 1,000-row × 5-column threshold where `lightning-datatable` degrades. Covers the four core mechanics — fixed-window state, IntersectionObserver-based scroll sentinels, stable item keys, server-side pagination caps (50 rows/page) — and the shadow-DOM gotchas that make the standard browser virtual-list libraries unreliable inside Lightning. NOT for the built-in `lightning-datatable` with `enable-infinite-loading` (use tha...

  • 15 stars
  • 0 votes
  • 0 copies
  • 4 views
  • Added June 1, 2026
ai-agentsgoreactnodespringapiperformance

Works with

  • cursor
  • cli
  • api

Security analysis

A100/100

Pro scans all 7 files and shows the line behind each finding

Scanned June 1, 2026

npx -y skills add PranavNagrecha/AwesomeSalesforceSkills --skill lwc-virtualized-lists --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Lwc Virtualized Lists?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Lwc Virtualized Lists
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/pranavnagrecha-lwc-virtualized-lists/badge)](https://www.skillsdirectory.com/skills/pranavnagrecha-lwc-virtualized-lists)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: lwc-virtualized-lists
description: "Hand-built virtual / windowed list rendering in LWC for datasets above the 1,000-row × 5-column threshold where `lightning-datatable` degrades. Covers the four core mechanics — fixed-window state, IntersectionObserver-based scroll sentinels, stable item keys, server-side pagination caps (50 rows/page) — and the shadow-DOM gotchas that make the standard browser virtual-list libraries unreliable inside Lightning. NOT for the built-in `lightning-datatable` with `enable-infinite-loading` (use that first; this skill is the answer when datatable can't keep up), NOT for `lightning-tree-grid`."
category: lwc
salesforce-version: "Spring '25+"
well-architected-pillars:
  - Operational Excellence
  - Reliability
triggers:
  - "lwc large dataset rendering performance virtualization"
  - "lwc datatable 1000 rows performance degrades"
  - "lwc infinite scroll intersection observer pattern"
  - "lwc windowed list lazy loading datatable alternative"
  - "lwc shadow dom intersection observer not working"
  - "lwc soql limit offset pagination infinite list"
  - "infinite scroll is slow"
tags:
  - virtualization
  - infinite-scroll
  - intersection-observer
  - performance
  - shadow-dom
  - pagination
inputs:
  - "Estimated row count and column width of the list"
  - "Whether the data is already in memory (client-only filter) or needs paging from the server"
  - "Whether sort / filter / inline-edit are required (each pushes you toward a different architecture)"
  - "Whether the list lives inside a Lightning page (shadow DOM) or a standalone page"
outputs:
  - "Choice between `lightning-datatable` + `enable-infinite-loading` vs hand-built virtual list"
  - "Window size, page size, and sentinel placement for the virtual implementation"
  - "Shadow-DOM-aware IntersectionObserver setup that actually fires"
  - "Server-side pagination contract (LIMIT/OFFSET or GraphQL cursor)"
dependencies: []
version: 1.0.0
author: Pranav Nagrecha
updated: 2026-05-04
---

# 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-datatable` first.** Add `enable-infinite-loading` and an
  `onloadmore` handler. 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

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

| 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 **`root`** option 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(...)` in `connectedCallback` against a
  sentinel that hasn't been queried yet returns silently — wire it up in
  `renderedCallback` after `this.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)

```js
// 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);
    }
}
```

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

```html
<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>
```

```js
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 `root` is the scrollable ancestor inside
  the same shadow root, not `null` (which would mean "the viewport"
  and may not work inside Lightning's shadow tree).
- `renderedCallback` (not `connectedCallback`) 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 * rowHeight` and `(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

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

- [ ] `lightning-datatable + enable-infinite-loading` was tried before going custom.
- [ ] Row count + column count are profile-measured, not estimated.
- [ ] IntersectionObserver `root` option is the in-shadow-root scrollable element, not `null`.
- [ ] Observer is wired in `renderedCallback`, not `connectedCallback`.
- [ ] 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

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

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

Files in this skill

  • SKILL.md15.6 KB
  • references/examples.md7.6 KB
  • references/gotchas.md6.2 KB
  • references/llm-anti-patterns.md6.1 KB
  • references/well-architected.md3.4 KB
  • scripts/check_lwc_virtualized_lists.py5.5 KB
  • templates/lwc-virtualized-lists-template.md629 B

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.