Log Entry
Virtualized LWC Lists for 100,000 Alarms
The interesting demo for virtualized LWC lists is not "can I render 5,000 rows?"
It is "can I work with 100,000 alarms without melting the browser?"
Or 100,000 locations. Or 100,000 sensor readings. Or 100,000 work orders in a dispatch queue.
That is the shape where this starts to matter.
Summer '26 introduces dynamic list components for LWC in Developer Preview: lightning-dynamic-list-container and lightning-dynamic-list-item.
The point is simple: render the rows the user can actually see, not every row that exists.
Quick snapshot
- What: Dynamic lists for LWC.
- Components:
lightning-dynamic-list-containerandlightning-dynamic-list-item. - Status: Developer Preview.
- Where: Lightning Experience, Experience Builder sites, and Salesforce mobile.
- Requirement: org must opt in to the Dev channel in Salesforce Release Manager.
- Core trick: list virtualization, plus
loadmorefor additional rows. - Why it matters: browser memory and DOM size stop being tied directly to total record count.
Salesforce's release note says "50 or 5,000 items", but I think the better mental model is operational data at awkward scale.
An alarm centre with 100,000 rows is a much more honest test.
The old anti-pattern
This is what not to do.
<template>
<div class="alarm-list">
<template for:each={alarms} for:item="alarm">
<article key={alarm.id} class="alarm-row">
<strong>{alarm.name}</strong>
<span>{alarm.severity}</span>
<span>{alarm.locationName}</span>
<span>{alarm.lastSeenLabel}</span>
</article>
</template>
</div>
</template>
That looks harmless with 50 rows.
With 100,000 rows, it is a problem:
- 100,000 row elements
- hundreds of thousands of child nodes
- too much layout work
- too much memory pressure
- slow filtering and rerendering
- terrible mobile behaviour
The fix is not "make the row smaller".
The fix is "do not render 100,000 rows".
The useful model: three different counts
For huge lists, keep these numbers separate:
Virtualization mainly solves the third number.
You still need a sane data-loading strategy for the second number.
That distinction matters. If you fetch 100,000 chunky records into JavaScript and only render 40, the DOM is fine but memory can still be ugly.
Example 1: Dynamic list shape
The preview components are designed around a container and item pair.
<template>
<section class="alarm-panel" aria-label="Active alarms">
<lightning-dynamic-list-container
onrenderlistitems={handleRenderListItems}
onloadmore={handleLoadMore}
>
<template for:each={visibleAlarms} for:item="alarm">
<lightning-dynamic-list-item
key={alarm.id}
item-id={alarm.id}
>
<article class={alarm.rowClass}>
<div class="alarm-main">
<strong>{alarm.name}</strong>
<span>{alarm.locationName}</span>
</div>
<div class="alarm-meta">
<span>{alarm.severity}</span>
<span>{alarm.lastSeenLabel}</span>
</div>
</article>
</lightning-dynamic-list-item>
</template>
</lightning-dynamic-list-container>
</section>
</template>
The important pieces:
- the container owns the scrolling behaviour
- each row is wrapped in
lightning-dynamic-list-item - each item gets a stable
item-id - the parent responds to
renderlistitems - the parent responds to
loadmorewhen more rows are needed
Because this is Developer Preview, check the Component Library for the exact event.detail shape before copying code into a real org. The architectural pattern is the bit worth learning now.
Example 2: Bounded height is not optional
The container needs a bounded height so it can calculate what should be visible.
.alarm-panel {
height: 680px;
}
.alarm-row {
display: flex;
justify-content: space-between;
gap: 12px;
margin: 0 0 6px;
padding: 8px 10px;
border: 1px solid #d8dde6;
border-radius: 4px;
background: #fff;
}
.alarm-main,
.alarm-meta {
min-width: 0;
}
.alarm-main strong,
.alarm-main span,
.alarm-meta span {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
Do not put overflow: scroll on your own wrapper. Salesforce says the dynamic list container handles scrolling.
Also keep padding away from the elements between the dynamic list container and item components. Use margin on the row content for spacing.
That sounds fussy, but virtualized lists are fussy because scroll position and row position have to agree.
Example 3: Client-side slicing for a synthetic 100,000-row demo
For a demo, you can generate 100,000 lightweight rows in memory and prove that the DOM only renders the current slice.
import { LightningElement } from 'lwc';
const TOTAL_ALARMS = 100000;
function buildAlarm(index) {
const severity = index % 17 === 0 ? 'Critical' : index % 5 === 0 ? 'Warning' : 'Info';
return {
id: `alarm-${index}`,
name: `Alarm ${index.toString().padStart(6, '0')}`,
severity,
locationName: `Location ${(index % 3200).toString().padStart(4, '0')}`,
lastSeenLabel: `${index % 59} minutes ago`,
rowClass: severity === 'Critical' ? 'alarm-row alarm-row_critical' : 'alarm-row'
};
}
export default class AlarmVirtualList extends LightningElement {
allAlarms = Array.from({ length: TOTAL_ALARMS }, (_, index) => buildAlarm(index + 1));
visibleAlarms = this.allAlarms.slice(0, 40);
handleRenderListItems(event) {
const { startIndex, endIndex } = event.detail;
this.visibleAlarms = this.allAlarms.slice(startIndex, endIndex + 1);
}
handleLoadMore() {
// Not needed for the synthetic demo because all rows already exist locally.
}
}
This is a demo pattern only.
It proves the rendering idea, but I would not ship this shape for real 100,000-record data. In production, do not eagerly load 100,000 records unless the row payload is tiny and you have measured it.
Example 4: Production pattern with server paging
For real alarms or locations, page the data.
Load summaries, not full records. Fetch details only when a user opens a row.
public with sharing class AlarmListController {
public class AlarmRow {
@AuraEnabled public Id id;
@AuraEnabled public String name;
@AuraEnabled public String severity;
@AuraEnabled public String locationName;
@AuraEnabled public Datetime lastSeenAt;
}
public class AlarmPage {
@AuraEnabled public List<AlarmRow> rows;
@AuraEnabled public Datetime nextLastSeenAt;
@AuraEnabled public Id nextId;
@AuraEnabled public Boolean hasMore;
}
@AuraEnabled(cacheable=true)
public static AlarmPage loadAlarms(Datetime afterLastSeenAt, Id afterId, Integer pageSize) {
Integer safePageSize = pageSize == null ? 500 : pageSize;
if (safePageSize < 1) safePageSize = 1;
if (safePageSize > 1000) safePageSize = 1000;
List<Alarm__c> records;
if (afterLastSeenAt == null || afterId == null) {
records = [
SELECT Id, Name, Severity__c, Location__r.Name, Last_Seen_At__c
FROM Alarm__c
WITH USER_MODE
ORDER BY Last_Seen_At__c DESC, Id DESC
LIMIT :safePageSize
];
} else {
records = [
SELECT Id, Name, Severity__c, Location__r.Name, Last_Seen_At__c
FROM Alarm__c
WHERE Last_Seen_At__c < :afterLastSeenAt
OR (Last_Seen_At__c = :afterLastSeenAt AND Id < :afterId)
WITH USER_MODE
ORDER BY Last_Seen_At__c DESC, Id DESC
LIMIT :safePageSize
];
}
AlarmPage page = new AlarmPage();
page.rows = new List<AlarmRow>();
for (Alarm__c record : records) {
AlarmRow row = new AlarmRow();
row.id = record.Id;
row.name = record.Name;
row.severity = record.Severity__c;
row.locationName = record.Location__r == null ? null : record.Location__r.Name;
row.lastSeenAt = record.Last_Seen_At__c;
page.rows.add(row);
}
page.hasMore = records.size() == safePageSize;
if (!records.isEmpty()) {
Alarm__c lastRecord = records[records.size() - 1];
page.nextLastSeenAt = lastRecord.Last_Seen_At__c;
page.nextId = lastRecord.Id;
}
return page;
}
}
That is keyset pagination. For a 100,000-row operational list, I prefer this over offset-based paging because it stays stable as you move through the result set.
The exact indexed fields matter. If your list is sorted by Last_Seen_At__c, make sure the data model and filters support that query shape.
Example 5: Client loading with loadmore
The LWC keeps a loaded buffer and asks for more when the dynamic list says it needs more.
import { LightningElement } from 'lwc';
import loadAlarms from '@salesforce/apex/AlarmListController.loadAlarms';
const PAGE_SIZE = 500;
export default class AlarmVirtualList extends LightningElement {
loadedAlarms = [];
visibleAlarms = [];
nextLastSeenAt;
nextId;
hasMore = true;
loading = false;
connectedCallback() {
this.loadNextPage();
}
async handleLoadMore() {
await this.loadNextPage();
}
handleRenderListItems(event) {
const { startIndex, endIndex } = event.detail;
this.visibleAlarms = this.loadedAlarms.slice(startIndex, endIndex + 1);
}
async loadNextPage() {
if (this.loading || !this.hasMore) {
return;
}
this.loading = true;
try {
const page = await loadAlarms({
afterLastSeenAt: this.nextLastSeenAt,
afterId: this.nextId,
pageSize: PAGE_SIZE
});
const rows = page.rows.map((row) => ({
...row,
rowClass: row.severity === 'Critical'
? 'alarm-row alarm-row_critical'
: 'alarm-row',
lastSeenLabel: new Intl.DateTimeFormat(undefined, {
dateStyle: 'short',
timeStyle: 'short'
}).format(new Date(row.lastSeenAt))
}));
this.loadedAlarms = [...this.loadedAlarms, ...rows];
this.nextLastSeenAt = page.nextLastSeenAt;
this.nextId = page.nextId;
this.hasMore = page.hasMore;
} finally {
this.loading = false;
}
}
}
Again, check the Developer Preview component docs for the final renderlistitems event detail names. The important product architecture is stable: visible slice plus paged data.
Example 6: Do not search by filtering 100,000 rows in the browser
Virtualization does not make client-side searching magically cheap.
This is the wrong instinct:
get filteredAlarms() {
return this.loadedAlarms.filter((alarm) =>
alarm.name.toLowerCase().includes(this.searchTerm.toLowerCase())
);
}
That is fine for a few hundred rows. It is not the right model for 100,000 operational rows.
For alarms and locations, search should usually go back to the server with deliberate filters.
@AuraEnabled(cacheable=true)
public static AlarmPage searchAlarms(String searchTerm, Integer pageSize) {
String term = searchTerm == null ? '' : searchTerm.trim();
String likeTerm = '%' + term + '%';
Integer safePageSize = pageSize == null ? 200 : pageSize;
if (safePageSize < 1) safePageSize = 1;
if (safePageSize > 500) safePageSize = 500;
List<Alarm__c> records = [
SELECT Id, Name, Severity__c, Location__r.Name, Last_Seen_At__c
FROM Alarm__c
WHERE Name LIKE :likeTerm
WITH USER_MODE
ORDER BY Last_Seen_At__c DESC, Id DESC
LIMIT :safePageSize
];
// Map records into AlarmPage as above.
return toPage(records, safePageSize);
}
If this query shape is not selective, fix that before you celebrate the UI. A virtualized list can keep the browser alive, but it cannot rescue a non-selective backend query.
Where this is great
Dynamic lists make sense for:
- alarm consoles
- location directories
- dispatch queues
- inventory locations
- IoT device lists
- customer activity streams
- log/event summaries
- custom lookup results with rich row layouts
The common pattern is "lots of rows, row content is custom, user scans and drills in".
That is different from a spreadsheet. If you need column resizing, inline edit, row selection, and grid semantics, lightning-datatable may still be the right tool. If you need a custom feed/list surface at huge scale, dynamic lists are the interesting new option.
What I would test
For a 100,000-row alarm demo, I would measure:
- initial render time
- DOM node count while scrolled near the top, middle, and bottom
- heap usage after 10 minutes of scrolling
- keyboard navigation with Arrow, Home, and End
- focus preservation after scrolling a focused row away and back
- mobile scrolling behaviour
- server query time for each page
- behaviour when new alarms arrive while the user is scrolling
That last one is the real product test.
Operational lists are rarely static. If new critical alarms appear at the top, decide whether to auto-prepend them, show a "new alarms" control, or hold the user's current scroll position until they choose to refresh.
Practical rule
For 100,000 alarms, the goal is not to show 100,000 rows.
The goal is to make 100,000 rows explorable.
That means:
- virtualize the DOM
- page the data
- keep row payloads small
- fetch details on demand
- filter and search on the server
- preserve focus and keyboard behaviour
- do not put custom scroll handling around the dynamic list container
That is the difference between a demo that looks fast and a tool that stays usable all day.
Source notes
Final take
I like this one because it points LWC towards a more serious class of interface.
Not a list with "a lot" of rows.
A real operational surface: 100,000 alarms, 100,000 locations, 100,000 things a user might need to scan without the browser giving up.
The Developer Preview caveat matters, but the direction is right. Render what is visible. Load what is needed. Keep the rest out of the DOM.