LWC Virtualized Lists
Rendering 10,000 DOM nodes crashes mobile and lags desktop. This skill covers
the two paths that get you to a working long list: lightning-datatable with
infinite loading, and an IntersectionObserver sentinel for non-tabular
content. Hand-built windowed rendering — computing a visible slice from scroll
offset, spacer sizing, height measurement — is lwc/lwc-virtualized-lists.
The distinction is not stylistic. Infinite loading bounds the network work
by fetching in pages; loaded rows stay in the DOM, so memory grows monotonically.
Windowing bounds the DOM and is a harder build. Most Salesforce lists need only
the first, plus a stated ceiling.
The Published Budget
From Improve Datatable
Performance:
| Guidance |
Value |
| Best performance |
1,000 rows and 5 columns |
| Past 250 rows |
fewer than 20 columns |
| Per request |
maximum 50 rows via LIMIT |
| Inline editing |
critical fields only — it costs performance |
Columns are a budget line, not a requirement. Row count is what teams manage;
column count is what they add without noticing.
The Platform Ceiling That Shapes The Query
OFFSET cannot skip more than 2,000 records
(OFFSET).
Page-number pagination therefore has a hard ceiling at 2000 / pageSize, and
degrades before reaching it because the database scans and discards every skipped
row. Use keyset pagination — carry the last row's sort key as a cursor — with
a unique tiebreak:
WHERE CreatedDate < :cursorDate
OR (CreatedDate = :cursorDate AND Id < :cursorId)
ORDER BY CreatedDate DESC, Id DESC
Without the tiebreak, rows sharing a timestamp are dropped or duplicated at page
boundaries — intermittently, which reads as a data bug.
Adoption Signals
Lists beyond 500 rows or tables beyond 1,000. Not for short lists, and not
before measuring.
Recommended Workflow
- Measure first. Chrome DevTools Performance with 4× CPU throttling. Under
~100 ms first render and smooth scrolling there, stop — the complexity costs a
class of bugs a plain list does not have.
- Datatable path:
enable-infinite-loading + onloadmore, inside a
container with an explicit height — the table needs it to know where its
bottom is, and the documentation ties it to preventing infinite looping. Raise
load-more-offset above its 20px default so fetching starts before the user
reaches blank space.
- Guard
loadmore. It fires repeatedly while the trigger zone is visible,
so an in-flight boolean is required — and set enableInfiniteLoading = false
on an empty page, or the table asks forever at the bottom of a complete list.
- Custom path: one
IntersectionObserver, created once (not per
renderedCallback), with root set to the scrolling element via lwc:ref —
the default root: null observes the viewport and fires continuously for a
list scrolling inside its own container. disconnect() in
disconnectedCallback.
- Filter on the server, debounced (~300 ms), and reset the cursor on every
filter or sort change. A client-side filter over a paginated list cannot see
unloaded rows — a correctness bug that presents as missing data.
- State a ceiling with an escape hatch. Past ~1,000 client rows, "showing
the first 1,000 — narrow your filter or export" is a better product than a
list that degrades silently.
Key Considerations
aria-setsize="-1" means "size unknown" and is the honest value for a
progressively-loaded list. Reporting the loaded count as the total tells a
screen-reader user the list is finished when it is not.
- Announce loading and completion in a polite live region. For
lightning-datatable, do not hand-roll aria-rowcount — the base component
owns its grid semantics.
- A keyboard user must never have to tab through 4,000 items to reach the footer.
- Variable row heights are not a problem for infinite loading (nothing is
removed) and are the central problem for windowing.
- Custom cell types multiply their cost by every rendered row — minimise
setTimeout(), promises, and DOM nesting inside them.
Worked Examples (see references/examples.md)
- 10k-row audit log — the wrong version and the right one, with the in-flight
guard, the fixed-height container, and a stated ceiling.
- Activity feed with
IntersectionObserver — correct root, single creation,
teardown.
- Keyset pagination in Apex — cursor with a unique tiebreak, and the tradeoff
table against
OFFSET.
- Debounced server-side filtering — including the cursor reset everyone omits.
Common Gotchas (see references/gotchas.md)
- No fixed-height container —
enable-infinite-loading cannot function.
loadmore fires repeatedly — one scroll gesture, four identical requests.
OFFSET caps at 2,000 — and degrades before it.
- Observer with the default
root — fires continuously inside a scrolling
container.
Top LLM Anti-Patterns (full list in references/llm-anti-patterns.md)
OFFSET pagination past the 2,000-record platform ceiling.
- Keyset pagination with no unique tiebreak.
- Client-side filtering over a paginated list — a correctness bug, not a
performance choice.
aria-setsize set to the loaded count, telling a screen reader the list is
complete.
Official Sources Used
1---2name: virtualized-lists3description: Render long lists (1k+ rows) efficiently in Salesforce LWC using lightning-datatable infinite loading or an IntersectionObserver sentinel, with keyset pagination and accessibility guidance.4---56# LWC Virtualized Lists78Rendering 10,000 DOM nodes crashes mobile and lags desktop. This skill covers9the two paths that get you to a working long list: `lightning-datatable` with10infinite loading, and an `IntersectionObserver` sentinel for non-tabular11content. Hand-built windowed rendering — computing a visible slice from scroll12offset, spacer sizing, height measurement — is `lwc/lwc-virtualized-lists`.1314**The distinction is not stylistic.** Infinite loading bounds the *network* work15by fetching in pages; loaded rows stay in the DOM, so memory grows monotonically.16Windowing bounds the *DOM* and is a harder build. Most Salesforce lists need only17the first, plus a stated ceiling.1819## The Published Budget2021From [Improve Datatable22Performance](https://developer.salesforce.com/docs/platform/lwc/guide/data-table-performance.html):2324| Guidance | Value |25|---|---|26| Best performance | **1,000 rows and 5 columns** |27| Past 250 rows | fewer than **20 columns** |28| Per request | maximum **50 rows** via `LIMIT` |29| Inline editing | critical fields only — it costs performance |3031Columns are a budget line, not a requirement. Row count is what teams manage;32column count is what they add without noticing.3334## The Platform Ceiling That Shapes The Query3536**`OFFSET` cannot skip more than 2,000 records**37([OFFSET](https://developer.salesforce.com/docs/atlas.en-us.soql_sosl.meta/soql_sosl/sforce_api_calls_soql_select_offset.htm)).38Page-number pagination therefore has a hard ceiling at `2000 / pageSize`, and39degrades before reaching it because the database scans and discards every skipped40row. Use **keyset pagination** — carry the last row's sort key as a cursor — with41a unique tiebreak:4243```apex44WHERE CreatedDate < :cursorDate45 OR (CreatedDate = :cursorDate AND Id < :cursorId)46ORDER BY CreatedDate DESC, Id DESC47```4849Without the tiebreak, rows sharing a timestamp are dropped or duplicated at page50boundaries — intermittently, which reads as a data bug.5152## Adoption Signals5354Lists beyond 500 rows or tables beyond 1,000. Not for short lists, and not55before measuring.5657## Recommended Workflow58591. **Measure first.** Chrome DevTools Performance with 4× CPU throttling. Under60 ~100 ms first render and smooth scrolling there, stop — the complexity costs a61 class of bugs a plain list does not have.622. **Datatable path:** `enable-infinite-loading` + `onloadmore`, inside a63 container with an **explicit height** — the table needs it to know where its64 bottom is, and the documentation ties it to preventing infinite looping. Raise65 `load-more-offset` above its 20px default so fetching starts before the user66 reaches blank space.673. **Guard `loadmore`.** It fires repeatedly while the trigger zone is visible,68 so an in-flight boolean is required — and set `enableInfiniteLoading = false`69 on an empty page, or the table asks forever at the bottom of a complete list.704. **Custom path:** one `IntersectionObserver`, created once (not per71 `renderedCallback`), with `root` set to the scrolling element via `lwc:ref` —72 the default `root: null` observes the viewport and fires continuously for a73 list scrolling inside its own container. `disconnect()` in74 `disconnectedCallback`.755. **Filter on the server, debounced (~300 ms), and reset the cursor** on every76 filter or sort change. A client-side filter over a paginated list cannot see77 unloaded rows — a correctness bug that presents as missing data.786. **State a ceiling with an escape hatch.** Past ~1,000 client rows, "showing79 the first 1,000 — narrow your filter or export" is a better product than a80 list that degrades silently.8182## Key Considerations8384- `aria-setsize="-1"` means "size unknown" and is the honest value for a85 progressively-loaded list. Reporting the loaded count as the total tells a86 screen-reader user the list is finished when it is not.87- Announce loading and completion in a polite live region. For88 `lightning-datatable`, do not hand-roll `aria-rowcount` — the base component89 owns its grid semantics.90- A keyboard user must never have to tab through 4,000 items to reach the footer.91- Variable row heights are not a problem for infinite loading (nothing is92 removed) and are the central problem for windowing.93- Custom cell types multiply their cost by every rendered row — minimise94 `setTimeout()`, promises, and DOM nesting inside them.9596## Worked Examples (see `references/examples.md`)9798- *10k-row audit log* — the wrong version and the right one, with the in-flight99 guard, the fixed-height container, and a stated ceiling.100- *Activity feed with `IntersectionObserver`* — correct `root`, single creation,101 teardown.102- *Keyset pagination in Apex* — cursor with a unique tiebreak, and the tradeoff103 table against `OFFSET`.104- *Debounced server-side filtering* — including the cursor reset everyone omits.105106## Common Gotchas (see `references/gotchas.md`)107108- **No fixed-height container** — `enable-infinite-loading` cannot function.109- **`loadmore` fires repeatedly** — one scroll gesture, four identical requests.110- **`OFFSET` caps at 2,000** — and degrades before it.111- **Observer with the default `root`** — fires continuously inside a scrolling112 container.113114## Top LLM Anti-Patterns (full list in `references/llm-anti-patterns.md`)115116- `OFFSET` pagination past the 2,000-record platform ceiling.117- Keyset pagination with no unique tiebreak.118- Client-side filtering over a paginated list — a correctness bug, not a119 performance choice.120- `aria-setsize` set to the loaded count, telling a screen reader the list is121 complete.122123## Official Sources Used124125- Improve Datatable Performance — https://developer.salesforce.com/docs/platform/lwc/guide/data-table-performance.html126- lightning-datatable (Component Reference) — https://developer.salesforce.com/docs/platform/lightning-component-reference/guide/lightning-datatable.html127- OFFSET (SOQL and SOSL Reference) — https://developer.salesforce.com/docs/atlas.en-us.soql_sosl.meta/soql_sosl/sforce_api_calls_soql_select_offset.htm128- Access Elements the Component Owns (`lwc:ref`) — https://developer.salesforce.com/docs/platform/lwc/guide/create-components-dom-work.html129- LWC Recipes — https://github.com/trailheadapps/lwc-recipes