Server Side OFFSET-Free Lazy Loading (Keyset Pagination) With Infinite Scroll In Salesforce LWC

Server Side OFFSET-Free Lazy Loading (Keyset Pagination) With Infinite Scroll In Salesforce LWC

Introduction

When building modern Salesforce Lightning Web Components (LWCs), one essential UX feature is lazy loading (or infinite scrolling). It helps load data incrementally as users scroll — improving performance, reducing query load, and creating a smooth user experience.

Most tutorials and sample code rely on SOQL’s OFFSET keyword. This approach introduces serious scalability issues. It also causes performance problems in production environments.

This post dives deep into:

  • Why OFFSET-based pagination fails at scale
  • How keyset pagination (OFFSET-free) solves it
  • How to add multi-field sorting and user-driven dynamic sorting with pagination reset
  • The pros and cons of each approach

⚠️ The Problem with Lazy Loading using OFFSET in LWC Pagination

A common first attempt at building pagination in Salesforce looks something like this:

SELECT Id, Name, Email 
FROM Contact 
LIMIT 50 OFFSET 100

At first glance, it seems perfect — you simply skip the first 100 records and fetch the next 50 for your next page or scroll.

However, this approach quickly runs into serious performance and scalability issues when used in Lightning Web Components (LWC) or any large dataset scenario.

If we refer to our previous blog, server side lazy loading, we used the offset approach in our apex class. However, it has the limitations listed below.

Offset Limitations
LimitationDescription
2,000 Record Cap SOQL OFFSET cannot skip more than 2,000 rows. Beyond that, Salesforce throws a LIMIT/OFFSET exceeds allowed row limit error.
Hidden Re-Query Cost Every time OFFSET is used, Salesforce retrieves all skipped rows internally. It then discards them before returning the next set. This wastes CPU, heap, and query time. Example: OFFSET 1000 forces the database to read the first 1000 rows just to skip them.
Performance Overhead Query execution time increases linearly with the offset value. The higher the offset, the slower your query becomes because Salesforce must process more skipped data.
Governor Limit Pressure Large offsets consume significant heap space, CPU time, and query rows, increasing the chance of hitting governor limits.
Inconsistent Results If data changes (inserts or deletes) between paginated queries, records may shift positions, leading to skipped or duplicated results.
Scalability Issues For LWC components displaying thousands of contacts, OFFSET pagination simply does not scale and leads to degraded user experience.

In short — OFFSET pagination breaks for large datasets and hurts performance even before that.

Due to these limitations, running the code below with a page size of 800 and using a dataset of over 3,000 Contact records causes specific offset values. These offsets occur during pagination: offsets: 0, 800, 1600, 2400.

Since our dataset exceeds 2,400 records, the final query executes with an OFFSET greater than 2,000, which violates Salesforce’s limit. As a result, the Apex class in our LWC throws the following error:

System.QueryException: Maximum SOQL offset allowed for apiName Contact is 2000

✅ The Recommended Solution: Keyset Pagination (OFFSET-Free)

Instead of relying on “skip and fetch,” we can use keyset pagination, also known as cursor-based pagination.

“Fetch the next N records where Id (or another key) is greater than the last record we already loaded.”

This approach uses indexed, unique fields which is Id field. This ensures consistent, fast, scalable queries. There is no OFFSET limit.

🔍 Example Apex Controller — OFFSET-Free Query

//LazyLoadingContactController.apex
public class LazyLoadingContactController {
@AuraEnabled
public static List<Contact> getContacts(Integer limitSize, Id lastRecordId) {
String baseQuery = 'SELECT Id, FirstName,LastName, Email FROM Contact';
if (lastRecordId != null) {
baseQuery += ' WHERE Id > :lastRecordId';
}
baseQuery += ' ORDER BY Id LIMIT :limitSize';
return Database.query(baseQuery);
}
}

✅ For the first load, lastRecordId is null → fetches the first batch.
✅ On scroll, we pass the last record’s Id → fetches the next batch.

⚡ LWC HTML Template and JavaScript — Basic Lazy Loading Implementation

/*serverSideLazyLoadingInHtmlTableWithScrollBarWithoutOffset.css*/
.scrollable {
overflow-y: auto;
border: 1px solid #d8dde6;
padding: 0.5rem;
}
.slds-table thead th {
position: sticky;
top: 0;
background-color: rgba(238, 238, 238, 1);
z-index: 1;
}
.back-to-top {
display: none;
/* Absolute positioning within the .scrollable container */
position: absolute;
bottom: 10px;
right: 10px;
padding: 0.75rem 1rem;
background: #0176d3;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
font-size: 16px;
/* Ensures it is above other content */
z-index: 10;
}
.back-to-top:hover {
background: #0160a9;
}
.loading-spinner {
text-align: center;
color: #706e6b;
}
.all-loaded {
color: #28a745;
padding: 0.5rem;
font-weight: bold;
}
<!–serverSideLazyLoadingInHtmlTableWithScrollBarWithoutOffset.html–>
<template>
<lightning-card title="Server Side Lazy Loading In HTML Table With Scroll Bar Without Offset – LWC" icon-name="standard:contacts">
<div class="container slds-m-around_medium">
<div class="slds-m-bottom_small" style="padding-left: 25px">
<p class="slds-m-bottom_small">
<lightning-badge label={contacts.length}></lightning-badge> contact loaded
</p>
</div>
<div class="scrollable" onscroll={handleScroll}>
<table class="slds-table slds-table_cell-buffer slds-no-row-hover slds-table_bordered">
<thead>
<tr class="slds-text-title_caps">
<th scope="col"></th>
<th scope="col">Id</th>
<th scope="col">First Name</th>
<th scope="col">Last Name</th>
<th scope="col">Email</th>
<th scope="col">Created Date</th>
</tr>
</thead>
<tbody>
<template for:each={contacts} for:item="contact">
<tr key={contact.Id}>
<td>{contact.serialNumber}</td>
<td>{contact.Id}</td>
<td style="word-break: break-all;white-space: pre-line;">{contact.FirstName}</td>
<td style="word-break: break-all;white-space: pre-line;">{contact.LastName}</td>
<td>{contact.Email}</td>
<td>{contact.CreatedDate}</td>
</tr>
</template>
</tbody>
</table>
</div>
<!– Loading Spinner –>
<div if:true={isLoading} class="loading-spinner slds-m-around_medium">
<lightning-spinner alternative-text="Loading" size="small"></lightning-spinner>
<p>Loading contacts…</p>
</div>
<!– Load More Button –>
<div if:false={isLoading} class="button-container" style="text-align: center;padding-top: 10px;">
<div if:true={allLoaded} class="all-loaded">
All contacts loaded
</div>
</div>
<br />
<br />
<!– Back to Top Button –>
<button class="back-to-top" onclick={scrollToTop}>↑ Top</button>
</div>
</lightning-card>
</template>
/*serverSideLazyLoadingInHtmlTableWithScrollBarWithoutOffset.js*/
// Import necessary LWC modules and decorators
import { LightningElement, track, api } from "lwc";
// Import Apex method for retrieving contact data
import getContacts from "@salesforce/apex/LazyLoadingContactController.getContacts";
// Export the class to handle server-side lazy loading of contacts in an HTML table with a scroll bar
export default class ServerSideLazyLoadingInHtmlTableWithScrollBarWithoutOffset extends LightningElement {
// Reactive property to store contact records
@track contacts = [];
// Public property to define the number of records per page
@api pageSize = 800;
// Offset for pagination, keeps track of the starting point for the next batch of records
offset = 0;
// Boolean flag to track if data is being fetched
isLoading = false;
// Boolean flag to check if there are more records available to load
hasMoreRecords = true;
// to track the last record Id
lastRecordId;
/**
* @description – connectedCallback lifecycle hook invoked when the component is inserted into the DOM.
* Calls the method to load the initial data.
*/
connectedCallback() {
// Load initial data when the component is initialized
this.loadInitialData();
}
/**
* @description – renderedCallback lifecycle hook invoked after the component has been rendered.
* Calls the method to set the scrollable height.
*/
renderedCallback() {
// set the scrollable height after the component is rendered
this.setScrollableHeight();
}
/**
* @description – loadInitialData method fetches contact data from the server.
* Prevents additional calls if no more records or already loading.
* Handles server response and updates component state.
* @param {NA} – No parameters are accepted by this method.
* @return {void} – This method does not return any value.
*/
async loadInitialData() {
// Exit early if all records are loaded or data is currently being fetched
if (!this.hasMoreRecords || this.isLoading) return;
// Mark the start of data loading
this.isLoading = true;
try {
// Simulate a delay for loading to mimic real-time API behavior
await this.loadingDelay();
// Call the Apex method to fetch contact data
const result = await getContacts({
limitSize: this.pageSize, // Number of records to fetch
lastRecordId: this.lastRecordId // Starting point for fetching
});
if (result.length > 0) {
// Process the fetched contacts and add serial numbers
const newContacts = this.processcontacts(result, this.contacts.length);
// Update the contact list with newly fetched records
this.contacts = […this.contacts, …newContacts];
// Track last record Id for next page
this.lastRecordId = result[result.length – 1].Id;
// Update the offset to fetch the next batch of records
this.offset += this.pageSize;
// Determine if there are more records to load
this.hasMoreRecords = result.length === this.pageSize;
// Adjust the scrollable container height dynamically
this.setScrollableHeight();
} else {
// No more records to fetch
this.hasMoreRecords = false;
}
} catch (error) {
// Log any errors encountered during data fetching
console.error("Error loading more contacts:", error);
} finally {
// Mark the end of data loading
this.isLoading = false;
}
}
/**
* @description – loadingDelay method simulates a delay for data fetching.
* @param {NA} – No parameters are accepted by this method.
* @return {Promise} – Resolves after 1 second
*/
loadingDelay() {
// Simulate a network delay of 1 second
// eslint-disable-next-line @lwc/lwc/no-async-operation
return new Promise((resolve) => setTimeout(resolve, 1000));
}
/**
* @description – loadMoreData method triggers loading of the next set of data.
* Simply calls loadInitialData for consistency.
* @param {NA} – No parameters are accepted by this method.
* @return {void} – This method does not return any value.
*/
async loadMoreData() {
// Call the loadInitialData method to load more data
this.loadInitialData();
}
/**
* allLoaded getter checks if all records have been loaded.
* @return {boolean} – True if all records are loaded, false otherwise.
*/
get allLoaded() {
// Returns true if there are no more records to load and at least one record is present
return !this.hasMoreRecords && this.contacts.length > 0;
}
/**
* @description – processcontacts method processes the fetched contacts.
* Adds a serial number to each contact for display purposes.
* @param {Array} contacts – Array of contact records
* @param {number} currentLength – Current number of records
* @return {Array} – Processed contacts with serial numbers
*/
processcontacts(contacts, currentLength) {
// Map each contact and add a serial number based on the current contact count
return contacts.map((contact, index) => ({
…contact,
// Calculate serial number starting from current length
serialNumber: currentLength + index + 1
}));
}
/**
* @description – handleScroll method handles scroll events on the container.
* Triggers loading of more data when near the bottom of the container.
* Shows or hides the "Back to Top" button based on scroll position.
* @param {NA} – No parameters are accepted by this method.
* @return {void} – This method does not return any value.
*/
handleScroll() {
// declare a buffer to trigger loading more data before reaching the bottom
const buffer = 10;
// declare a scrollable container
const scrollableContainer = this.template.querySelector(".scrollable");
// if scrollableContainer is not found, exit the method
if (!scrollableContainer) return;
// destructure scrollTop, scrollHeight, and clientHeight from the scrollableContainer
const { scrollTop, scrollHeight, clientHeight } = scrollableContainer;
// Check if the user has scrolled near the bottom of the container
if (scrollHeight – scrollTop – clientHeight < buffer && !this.isLoading) {
// If the user has scrolled near the bottom, load more data
this.loadMoreData();
}
// Show or hide the "Back to Top" button based on the scroll position
const backToTopButton = this.template.querySelector(".back-to-top");
// If the scrollTop is greater than 20 pixels, show the button; otherwise, hide it
if (backToTopButton) {
backToTopButton.style.display = scrollTop > 20 ? "block" : "none";
}
}
/**
* @description – scrollToTop method scrolls the container back to the top.
* Uses smooth scrolling for a better user experience.
* @param {NA} – No parameters are accepted by this method.
* @return {void} – This method does not return any value.
*/
scrollToTop() {
// Get the scrollable container element
const scrollableContainer = this.template.querySelector(".scrollable");
// If the scrollable container is found, scroll to the top smoothly
if (scrollableContainer) {
// Scroll to the top of the scrollable container
scrollableContainer.scrollTo({
top: 0,
behavior: "smooth"
});
}
}
/**
* @description – setScrollableHeight method dynamically sets the height of the scrollable container.
* Ensures consistent visual appearance based on the number of records.
* Makes sure to cap visible rows and use max-height so internal scrollbar appears.
* @param {NA} – No parameters are accepted by this method.
* @return {void} – This method does not return any value.
*/
setScrollableHeight() {
// Get the scrollable container element
const scrollableContainer = this.template.querySelector(".scrollable");
// If the scrollable container is not found, exit the method
if (scrollableContainer) {
// Find the first table row to measure its height
const firstRow = scrollableContainer.querySelector("tbody tr");
// If the first row is found, calculate the height of the scrollable container
if (firstRow) {
// Set the row height based on the page size
let rowHeight = 36;
// If the page size is less than 9, set a specific height
if (this.pageSize < 9) {
// Add a small offset to the row height for better spacing
rowHeight = firstRow.getBoundingClientRect().height + 4.8;
} else if (this.pageSize < 12) {
// If the page size is less than 12, set a different height
rowHeight = 31;
} else {
// For larger page sizes, set a standard height
rowHeight = 30;
}
// Choose how many rows to show at once (cap to avoid extremely tall container)
const maxVisibleRows = 12; // adjust if you want a different visible window
const visibleRows = Math.min(this.pageSize, maxVisibleRows);
const visibleHeight = visibleRows * rowHeight;
// Set max-height so the container can scroll internally; clear fixed height if any
scrollableContainer.style.maxHeight = `${visibleHeight}px`;
scrollableContainer.style.height = "";
scrollableContainer.style.overflow = "auto";
}
}
}
}
<?xml version="1.0" encoding="UTF-8"?>
<!–serverSideLazyLoadingInHtmlTableWithScrollBarWithoutOffset.js-meta.xml–>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata"&gt;
<apiVersion>63.0</apiVersion>
<isExposed>true</isExposed>
<masterLabel>Server Side Lazy Loading In HTML Table With Scroll Bar Without Offset</masterLabel>
<targets>
<target>lightning__AppPage</target>
<target>lightning__RecordPage</target>
<target>lightning__HomePage</target>
<target>lightningCommunity__Page</target>
<target>lightningCommunity__Default</target>
<target>lightning__Tab</target>
</targets>
</LightningComponentBundle>
How It Works
  1. Initial Load: Calls Apex with lastRecordId = null.
  2. Next Scroll: Apex runs WHERE Id > :lastRecordId.
  3. Repeat: Fetches chunks of records based on page size, until the dataset ends.

This ensures constant performance regardless of data size.

Note – If you don’t need UI based sorting by user, use this approach

⚙️ Sorting Support in Keyset Pagination

The Keyset Pagination (OFFSET-Free) approach we discussed earlier is beneficial. It helps you efficiently load more than 2,000 records. This method avoids hitting Salesforce’s OFFSET limitations.

But in many real-world use cases, such as our Contact list component, require sorting support. Examples include sorting by creation date, name, or email.

To handle this, keyset pagination must use stable, deterministic, and indexed fields in the ORDER BY clause. This ensures that every record has a predictable position in the result set. No records are skipped or duplicated when new pages are loaded.

✅ Supported Sort Orders
Sort TypeExampleBehavior
PrimaryORDER BY IdSafest single-field sort (each Id is unique)
CompositeORDER BY CreatedDate, IdStable chronological order with a unique tie-breaker
Why Composite Sorting?

When multiple records share the same sort field value (like CreatedDate), using that field alone can cause pagination errors. Some records might be skipped or repeated in subsequent pages. To solve this, we combine (CreatedDate, Id) into a composite sort key, ensuring uniqueness and deterministic ordering.

🔍 Example

Imagine the following Contact records:

IdCreatedDateName
A12024-10-01 10:00John
A22024-10-01 10:00Mary
A32024-10-01 10:05David
A42024-10-01 10:10Sarah

If you paginate using only CreatedDate and the first page ends with A2 (10:00),
your next query will be:

WHERE CreatedDate > 2024-10-01 10:00

✅ It fetches A3,
❌ but skips any other record (like A4) that also has CreatedDate = 10:00.

That’s why we add Id as a tie-breaker:

WHERE (CreatedDate > :lastCreatedDate 
   OR (CreatedDate = :lastCreatedDate AND Id > :lastId))
ORDER BY CreatedDate, Id

Now, all records are retrieved exactly once — even if multiple share the same timestamp.

🔄 ASC / DESC Sorting Support

Keyset pagination can support both ascending and descending sorts by reversing the comparison logic dynamically:

  • For ascending order:
WHERE (CreatedDate > :lastCreatedDate OR (CreatedDate = :lastCreatedDate AND Id > :lastId))
ORDER BY CreatedDate, Id
  • For descending order:

WHERE (CreatedDate < :lastCreatedDate OR (CreatedDate = :lastCreatedDate AND Id < :lastId))
ORDER BY CreatedDate DESC, Id DESC

  • Use simple ORDER BY Id if you only need stable pagination and don’t care about user-facing sort order.
  • Use composite ORDER BY CreatedDate, Id for predictable, chronological paging. This is ideal for list views, audit logs, or any time-based sorting.
  • Always align your WHERE clause with your ORDER BY fields and directions to prevent data gaps or duplicates.

⚙️ Sorting by Custom or Non-Indexed Fields

Keyset pagination depends on being able to determine a unique, ordered cursor — that is, “what comes after this record.”
To do that safely, Salesforce must have:

  1. A consistent sort order (ascending/descending), and
  2. A unique tie-breaker (usually Id).
✅ Case 1: Non-Unique but Stable Fields (e.g., LastName,Email)

You can extend keyset pagination to custom fields if you combine them with Id as a secondary key:

⚙️ Why It Works

The Apex method dynamically builds a query like this (depending on the selected sort field):

WHERE (
    (LastName > :lastLastName)
    OR (LastName = :lastLastName AND CreatedDate > :lastCreatedDate)
    OR (LastName = :lastLastName AND CreatedDate = :lastCreatedDate AND Id > :lastId)
)
ORDER BY LastName ASC, CreatedDate ASC, Id ASC
LIMIT :limitSize

So even if multiple Contacts share the same LastName, Email, or even the same CreatedDate, the Id ensures the next page starts after the exact last record of the previous batch — with no duplicates or gaps.

🔍 Example

IdLastNameCreatedDateEmail
A1Smith2024-10-01 10:00smith1@test.com
A2Smith2024-10-01 10:00smith2@test.com
A3Smith2024-10-01 10:05smith3@test.com
A4Williams2024-10-01 10:10williams@test.com

If you paginate using only LastName, and your first batch ends at A2 (Smith):

WHERE LastName > 'Smith'

✅ You’ll get A4,
❌ but skip A3 (Smith, same name but later CreatedDate).

By adding both CreatedDate and Id as tiebreakers:

WHERE (
    (LastName > :lastLastName)
    OR (LastName = :lastLastName AND CreatedDate > :lastCreatedDate)
    OR (LastName = :lastLastName AND CreatedDate = :lastCreatedDate AND Id > :lastId)
)
ORDER BY LastName, CreatedDate, Id

✅ Pagination becomes fully deterministic — no skips, no duplicates, even when multiple records share the same name or timestamp.

Example Apex Controller
//LazyLoadingContactController.apex
public class LazyLoadingContactController {
@AuraEnabled(cacheable=true)
public static List<Contact> getContactsUsingCompositeSorting(
Integer limitSize,
String sortField,
String sortDirection,
String lastSortValue,
Id lastId
) {
if (String.isBlank(sortField)) sortField = 'CreatedDate';
if (String.isBlank(sortDirection)) sortDirection = 'ASC';
if (limitSize == null) limitSize = 20;
String operator = (sortDirection == 'DESC') ? '<' : '>';
String soql = 'SELECT Id, FirstName, LastName, Email, CreatedDate FROM Contact';
// Only apply WHERE clause if we have a cursor
if (lastSortValue != null && lastId != null) {
soql += ' WHERE (' + sortField + ' ' + operator + ' :lastSortValue OR ' +
'(' + sortField + ' = :lastSortValue AND Id ' + operator + ' :lastId))';
}
// Stable sort order
soql += ' ORDER BY ' + sortField + ' ' + sortDirection + ', Id ' + sortDirection;
soql += ' LIMIT :limitSize';
System.debug('Query => ' + soql);
return Database.query(soql);
}
}
Example LWC –
/*serverSideLazyLoadingInHtmlTableWithScrollBarWithoutOffsetWithSorting.css*/
.scrollable {
overflow-y: auto;
border: 1px solid #d8dde6;
padding: 0.5rem;
}
.slds-table thead th {
position: sticky;
top: 0;
background-color: rgba(238, 238, 238, 1);
z-index: 1;
}
.back-to-top {
display: none;
/* Absolute positioning within the .scrollable container */
position: absolute;
bottom: 10px;
right: 10px;
padding: 0.75rem 1rem;
background: #0176d3;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
font-size: 16px;
/* Ensures it is above other content */
z-index: 10;
}
.back-to-top:hover {
background: #0160a9;
}
.loading-spinner {
text-align: center;
color: #706e6b;
}
.all-loaded {
color: #28a745;
padding: 0.5rem;
font-weight: bold;
}
<!–/*serverSideLazyLoadingInHtmlTableWithScrollBarWithoutOffsetWithSorting.html–>
<template>
<lightning-card title="Server Side Lazy Loading In Html Table With ScrollBar Without Offset With Sorting – LWC" icon-name="standard:contacts">
<div class="container slds-m-around_medium">
<div class="slds-m-bottom_small" style="padding-left: 25px">
<p class="slds-m-bottom_small">
<lightning-badge label={contacts.length}></lightning-badge> contact loaded
</p>
</div>
<div class="scrollable" onscroll={handleScroll}>
<table class="slds-table slds-table_cell-buffer slds-no-row-hover slds-table_bordered">
<thead>
<tr class="slds-text-title_caps">
<th scope="col">#</th>
<th scope="col" data-field="Id" onclick={handleSort}>Id</th>
<th scope="col" data-field="FirstName" onclick={handleSort}>First Name</th>
<th scope="col" data-field="LastName" onclick={handleSort}>Last Name</th>
<th scope="col" data-field="Email" onclick={handleSort}>Email</th>
<th scope="col" data-field="CreatedDate" onclick={handleSort}>Created Date</th>
</tr>
</thead>
<tbody>
<template for:each={contacts} for:item="contact">
<tr key={contact.Id}>
<td>{contact.serialNumber}</td>
<td>{contact.Id}</td>
<td style="word-break: break-all;white-space: pre-line;">{contact.FirstName}</td>
<td style="word-break: break-all;white-space: pre-line;">{contact.LastName}</td>
<td>{contact.Email}</td>
<td>{contact.CreatedDate}</td>
</tr>
</template>
</tbody>
</table>
</div>
<!– Loading Spinner –>
<div if:true={isLoading} class="loading-spinner slds-m-around_medium">
<lightning-spinner alternative-text="Loading" size="small"></lightning-spinner>
<p>Loading contacts…</p>
</div>
<!– Load More Button –>
<div if:false={isLoading} class="button-container" style="text-align: center;padding-top: 10px;">
<div if:true={allLoaded} class="all-loaded">
All contacts loaded
</div>
</div>
<br />
<br />
<!– Back to Top Button –>
<button class="back-to-top" onclick={scrollToTop}>↑ Top</button>
</div>
</lightning-card>
</template>
/*serverSideLazyLoadingInHtmlTableWithScrollBarWithoutOffsetWithSorting.js*/
// Import necessary LWC modules and decorators
import { LightningElement, track, api } from "lwc";
// Import Apex method for retrieving contact data
import getContacts from '@salesforce/apex/LazyLoadingContactController.getContactsUsingCompositeSorting';
// Export the class to handle server-side lazy loading of contacts in an HTML table with a scroll bar
export default class ServerSideLazyLoadingInHtmlTableWithScrollBarWithoutOffsetWithSorting extends LightningElement {
// Reactive property to store contact records
@track contacts = [];
// Public property to define the number of records per page
@api pageSize = 800;
// Boolean flag to track if data is being fetched
isLoading = false;
// Boolean flag to check if there are more records available to load
hasMoreRecords = true;
lastId = null;
lastSortValue = null;
sortField = 'CreatedDate';
sortDirection = 'ASC';
// Used for debounce on column sort
sortDebounceMs = 200;
sortTimeoutId;
/**
* @description – connectedCallback lifecycle hook invoked when the component is inserted into the DOM.
* Calls the method to load the initial data.
*/
connectedCallback() {
// Load initial data when the component is initialized
this.loadInitialData();
}
/**
* @description – renderedCallback lifecycle hook invoked after the component has been rendered.
* Calls the method to set the scrollable height.
*/
renderedCallback() {
// set the scrollable height after the component is rendered
this.setScrollableHeight();
}
/**
* @description – loadInitialData method fetches contact data from the server.
* Prevents additional calls if no more records or already loading.
* Handles server response and updates component state.
* @param {NA} – No parameters are accepted by this method.
* @return {void} – This method does not return any value.
*/
async loadInitialData() {
// Exit early if all records are loaded or data is currently being fetched
if (!this.hasMoreRecords || this.isLoading) return;
// Mark the start of data loading
this.isLoading = true;
try {
// Simulate a delay for loading to mimic real-time API behavior
await this.loadingDelay();
// Call the Apex method to fetch contact data
const result = await getContacts({
limitSize: this.pageSize,
lastId: this.lastId,
lastSortValue: this.lastSortValue,
sortField: this.sortField,
sortDirection: this.sortDirection
});
if (result.length > 0) {
// Process the fetched contacts and add serial numbers
const newContacts = this.processcontacts(result, this.contacts.length);
// Update the contact list with newly fetched records
this.contacts = […this.contacts, …newContacts];
// Track cursor for next page
const lastRecord = result[result.length – 1];
this.lastSortValue = lastRecord[this.sortField];
this.lastId = lastRecord.Id;
// Determine if there are more records to load
this.hasMoreRecords = result.length === this.pageSize;
// Adjust the scrollable container height dynamically
this.setScrollableHeight();
} else {
// No more records to fetch
this.hasMoreRecords = false;
}
} catch (error) {
// Log any errors encountered during data fetching
console.error("Error loading more contacts:", error);
} finally {
// Mark the end of data loading
this.isLoading = false;
}
}
/**
* @description – loadingDelay method simulates a delay for data fetching.
* @param {NA} – No parameters are accepted by this method.
* @return {Promise} – Resolves after 1 second
*/
loadingDelay() {
// Simulate a network delay of 1 second
// eslint-disable-next-line @lwc/lwc/no-async-operation
return new Promise((resolve) => setTimeout(resolve, 1000));
}
/**
* @description – loadMoreData method triggers loading of the next set of data.
* Simply calls loadInitialData for consistency.
* @param {NA} – No parameters are accepted by this method.
* @return {void} – This method does not return any value.
*/
async loadMoreData() {
// Call the loadInitialData method to load more data
this.loadInitialData();
}
/**
* allLoaded getter checks if all records have been loaded.
* @return {boolean} – True if all records are loaded, false otherwise.
*/
get allLoaded() {
// Returns true if there are no more records to load and at least one record is present
return !this.hasMoreRecords && this.contacts.length > 0;
}
/**
* @description – processcontacts method processes the fetched contacts.
* Adds a serial number to each contact for display purposes.
* @param {Array} contacts – Array of contact records
* @param {number} currentLength – Current number of records
* @return {Array} – Processed contacts with serial numbers
*/
processcontacts(contacts, currentLength) {
// Map each contact and add a serial number based on the current contact count
return contacts.map((contact, index) => ({
…contact,
// Calculate serial number starting from current length
serialNumber: currentLength + index + 1
}));
}
/**
* @description – handleScroll method handles scroll events on the container.
* Triggers loading of more data when near the bottom of the container.
* Shows or hides the "Back to Top" button based on scroll position.
* @param {NA} – No parameters are accepted by this method.
* @return {void} – This method does not return any value.
*/
handleScroll() {
// declare a buffer to trigger loading more data before reaching the bottom
const buffer = 10;
// declare a scrollable container
const scrollableContainer = this.template.querySelector(".scrollable");
// if scrollableContainer is not found, exit the method
if (!scrollableContainer) return;
// destructure scrollTop, scrollHeight, and clientHeight from the scrollableContainer
const { scrollTop, scrollHeight, clientHeight } = scrollableContainer;
// Check if the user has scrolled near the bottom of the container
if (scrollHeight – scrollTop – clientHeight < buffer && !this.isLoading) {
// If the user has scrolled near the bottom, load more data
this.loadMoreData();
}
// Show or hide the "Back to Top" button based on the scroll position
const backToTopButton = this.template.querySelector(".back-to-top");
// If the scrollTop is greater than 20 pixels, show the button; otherwise, hide it
if (backToTopButton) {
backToTopButton.style.display = scrollTop > 20 ? "block" : "none";
}
}
/**
* @description – scrollToTop method scrolls the container back to the top.
* Uses smooth scrolling for a better user experience.
* @param {NA} – No parameters are accepted by this method.
* @return {void} – This method does not return any value.
*/
scrollToTop() {
// Get the scrollable container element
const scrollableContainer = this.template.querySelector(".scrollable");
// If the scrollable container is found, scroll to the top smoothly
if (scrollableContainer) {
// Scroll to the top of the scrollable container
scrollableContainer.scrollTo({
top: 0,
behavior: "smooth"
});
}
}
/**
* @description – setScrollableHeight method dynamically sets the height of the scrollable container.
* Ensures consistent visual appearance based on the number of records.
* Makes sure to cap visible rows and use max-height so internal scrollbar appears.
* @param {NA} – No parameters are accepted by this method.
* @return {void} – This method does not return any value.
*/
setScrollableHeight() {
// Get the scrollable container element
const scrollableContainer = this.template.querySelector(".scrollable");
// If the scrollable container is not found, exit the method
if (scrollableContainer) {
// Find the first table row to measure its height
const firstRow = scrollableContainer.querySelector("tbody tr");
// If the first row is found, calculate the height of the scrollable container
if (firstRow) {
// Set the row height based on the page size
let rowHeight = 36;
// If the page size is less than 9, set a specific height
if (this.pageSize < 9) {
// Add a small offset to the row height for better spacing
rowHeight = firstRow.getBoundingClientRect().height + 4.8;
} else if (this.pageSize < 12) {
// If the page size is less than 12, set a different height
rowHeight = 31;
} else {
// For larger page sizes, set a standard height
rowHeight = 30;
}
// Choose how many rows to show at once (cap to avoid extremely tall container)
const maxVisibleRows = 12; // adjust if you want a different visible window
const visibleRows = Math.min(this.pageSize, maxVisibleRows);
const visibleHeight = visibleRows * rowHeight;
// Set max-height so the container can scroll internally; clear fixed height if any
scrollableContainer.style.maxHeight = `${visibleHeight}px`;
scrollableContainer.style.height = "";
scrollableContainer.style.overflow = "auto";
}
}
}
//Handle user sorting toggle
handleSort(event) {
const field = event.target.dataset.field;
if (this.sortField === field) {
this.sortDirection = this.sortDirection === 'ASC' ? 'DESC' : 'ASC';
} else {
this.sortField = field;
this.sortDirection = 'ASC';
}
// Reset state for fresh query
this.contacts = [];
this.lastCreatedDate = null;
this.lastId = null;
this.hasMoreRecords = true;
this.loadInitialData();
}
}
<?xml version="1.0" encoding="UTF-8"?>
<!–serverSideLazyLoadingInHtmlTableWithScrollBarWithoutOffsetWithSorting.js-meta.xml–>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata"&gt;
<apiVersion>63.0</apiVersion>
<isExposed>true</isExposed>
<masterLabel>Server Side Lazy Loading In HtmlTable With ScrollBar Without Offset WithSorting</masterLabel>
<targets>
<target>lightning__AppPage</target>
<target>lightning__RecordPage</target>
<target>lightning__HomePage</target>
<target>lightningCommunity__Page</target>
<target>lightningCommunity__Default</target>
<target>lightning__Tab</target>
</targets>
</LightningComponentBundle>
🧩 How the Code Handles Non-Unique but Stable Fields Sorting

This Apex implementation supports keyset pagination with composite sorting. It ensures records are loaded consistently. This is important even when the chosen sort field isn’t unique, such as LastName, Email, or other stable fields.

🔹 Dynamic Query Behavior

When your LWC sets the sort field dynamically:

this.sortField = event.target.dataset.field; // e.g., 'LastName' or 'Email'

the Apex method constructs a SOQL query like:

ORDER BY LastName ASC, Id ASC

If multiple records share the same LastName, the Id acts as a deterministic tie-breaker, ensuring:

✅ No duplicate or skipped records
✅ Predictable continuation for infinite scroll
✅ Consistent sort behavior even as data changes

🔹 WHERE Clause Logic

The WHERE clause is dynamically built to match the same sort chain:

WHERE (
    LastName > :lastSortValue
    OR (LastName = :lastSortValue AND Id > :lastId)
)

This ensures the next batch always starts after the last retrieved record, maintaining seamless continuity without relying on OFFSET.

🔹 Fallback for Default Sorting

If the user hasn’t selected a specific field (sortField is blank), the Apex method defaults to:

ORDER BY CreatedDate ASC, Id ASC

⚠️ When It May Not Work Reliably

Two special cases still require caution:

  1. Highly volatile fields (e.g., LastModifiedDate, calculated fields, or any value changing while scrolling).
    • Their values may shift between page loads, causing jumps or duplicates.
  2. Nullable or case-sensitive text fields
    • Salesforce may internally use case-insensitive or NULLS FIRST ordering, which can affect boundary comparisons.

For example in Contact object – LastName, Email, and CreatedDate, these risks are minimal. They’re stable and well-behaved fields for composite pagination.

⚠️ Case 2: Frequently Changing or Non-Deterministic Fields

If you want to sort by fields like LastModifiedDate, remember that calculated fields can change between requests. Also, any value changing while scrolling can alter between requests. This change can break the order consistency.
In these cases:

  • Use reset-based pagination (re-query from the beginning).
  • Use composite keyset (Id + CreatedDate as fallback).

How This Hybrid Sorting Works

ActionBehavior
Scroll downLoads next batch using keyset cursor (Id or CreatedDate, Id)
Click column to sortResets list and re-queries in new order
Continue scrollingInfinite load resumes under current sort
Change to unstable fieldFalls back to reset-based pagination

This ensures the app stays responsive while avoiding keyset edge cases.

Example LWC + Apex With Hybrid Lazy Loading (Keyset + Reset)

.scrollable-table {
height: 400px;
overflow-y: auto;
}
<template>
<lightning-card title="Smart Hybrid Lazy Loading (Keyset + Reset)" icon-name="standard:contact">
<div class="scrollable-table">
<lightning-datatable
key-field="Id"
data={accounts}
columns={columns}
sorted-by={sortBy}
sorted-direction={sortDirection}
onsort={handleSort}
enable-infinite-loading={hasMoreRecords}
onloadmore={loadMoreData}
hide-checkbox-column
show-row-number-column>
</lightning-datatable>
</div>
<div class="slds-text-align_center slds-p-around_small">
<template if:true={isLoading}>
<lightning-spinner alternative-text="Loading more contacts…"></lightning-spinner>
</template>
<template if:false={hasMoreRecords}>
<p>All contacts loaded</p>
</template>
</div>
</lightning-card>
</template>
/* eslint-disable @lwc/lwc/no-async-operation */
import { LightningElement, track } from "lwc";
import getContacts from "@salesforce/apex/SmartLazyLoadingController.getContacts";
export default class SmartLazyLoading extends LightningElement {
@track accounts = [];
pageSize = 800;
isLoading = false;
hasMoreRecords = true;
lastRecordId;
lastSortValue;
lastCreatedDate;
sortBy = "CreatedDate";
sortDirection = "ASC";
// columns with sortable fields
columns = [
{ label: "First Name", fieldName: "FirstName", type: "text", hideDefaultActions: true, sortable: true },
{ label: "Last Name", fieldName: "LastName", type: "text",hideDefaultActions: true, sortable: true },
{ label: "Created Date", fieldName: "CreatedDate", type: "date",hideDefaultActions: true, sortable: true },
{ label: "Last Modified Date", fieldName: "LastModifiedDate", type: "date",hideDefaultActions: true, sortable: true }
];
sortDebounceMs = 200;
sortTimeoutId;
currentRequestId = 0;
activeRequestId = 0;
// Volatile or non-deterministic fields for reset pagination
volatileFields = ["LastModifiedDate"];
connectedCallback() {
this.loadData();
}
/**
* Core data loading logic
*/
async loadData(reset = false) {
if (this.isLoading || (!this.hasMoreRecords && !reset)) return;
const requestId = ++this.currentRequestId;
this.activeRequestId = requestId;
this.isLoading = true;
// Reset pagination state
if (reset) {
this.accounts = [];
this.lastRecordId = null;
this.lastSortValue = null;
this.lastCreatedDate = null;
this.hasMoreRecords = true;
}
try {
// Hybrid logic: use reset pagination for volatile fields
const isVolatile = this.volatileFields.includes(this.sortBy);
const useComposite = !isVolatile; // Only use keyset (CreatedDate+Id) when stable
// Format sort value for SOQL
let formattedLastSortValue = this.lastSortValue;
if (["CreatedDate", "LastModifiedDate"].includes(this.sortBy) && this.lastSortValue) {
const date = new Date(this.lastSortValue);
formattedLastSortValue = date.toISOString();
} else if (["FirstName", "LastName"].includes(this.sortBy)) {
formattedLastSortValue = String(this.lastSortValue);
}
const result = await getContacts({
limitSize: this.pageSize,
lastRecordId: this.lastRecordId,
lastSortValue: formattedLastSortValue,
lastCreatedDate: this.lastCreatedDate,
sortBy: this.sortBy,
sortDirection: this.sortDirection,
useComposite: useComposite,
isVolatile: isVolatile
});
// Ignore if another request started
if (requestId !== this.activeRequestId) return;
if (result.length > 0) {
this.accounts = […this.accounts, …result];
const last = result[result.length – 1];
this.lastRecordId = last.Id;
this.lastSortValue = last[this.sortBy];
this.lastCreatedDate = last.CreatedDate;
this.hasMoreRecords = result.length === this.pageSize;
} else {
this.hasMoreRecords = false;
}
} catch (error) {
if (requestId === this.activeRequestId) {
console.error("Error loading contacts:", error);
}
} finally {
if (this.activeRequestId === requestId) this.isLoading = false;
}
}
loadMoreData() {
if (this.isLoading || !this.hasMoreRecords) return;
this.loadData();
}
/**
* Handles sorting with hybrid reset logic
*/
handleSort(event) {
const { fieldName, sortDirection } = event.detail;
clearTimeout(this.sortTimeoutId);
this.sortTimeoutId = setTimeout(() => {
this.sortBy = fieldName;
this.sortDirection = sortDirection;
// Always reset pagination on sort change
this.loadData(true);
}, this.sortDebounceMs);
}
}
<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata"&gt;
<apiVersion>65.0</apiVersion>
<isExposed>true</isExposed>
<targets>
<target>lightning__AppPage</target>
<target>lightning__RecordPage</target>
<target>lightning__HomePage</target>
<target>lightningCommunity__Page</target>
<target>lightningCommunity__Default</target>
<target>lightning__Tab</target>
</targets>
</LightningComponentBundle>
public with sharing class SmartLazyLoadingController {
@AuraEnabled
public static List<Contact> getContacts(
Integer limitSize,
Id lastRecordId,
String lastSortValue,
Datetime lastCreatedDate,
String sortBy,
String sortDirection,
Boolean useComposite,
Boolean isVolatile
) {
if (String.isBlank(sortBy)) sortBy = 'CreatedDate';
if (String.isBlank(sortDirection)) sortDirection = 'ASC';
if (limitSize == null) limitSize = 50;
String operator = (sortDirection == 'DESC') ? '<' : '>';
String soql = 'SELECT Id, FirstName, LastName, CreatedDate, LastModifiedDate FROM Contact';
// Skip WHERE clause if reset (start from top)
if (lastRecordId != null && lastSortValue != null && !isVolatile) {
if (useComposite && sortBy != 'CreatedDate') {
// Composite key (custom field + CreatedDate + Id)
soql += ' WHERE (' +
'(' + sortBy + ' ' + operator + ' :lastSortValue) ' +
'OR (' + sortBy + ' = :lastSortValue AND CreatedDate ' + operator + ' :lastCreatedDate) ' +
'OR (' + sortBy + ' = :lastSortValue AND CreatedDate = :lastCreatedDate AND Id ' + operator + ' :lastRecordId))';
} else {
// Default CreatedDate + Id composite
Datetime lastDateValue;
if (lastCreatedDate != null) lastDateValue = lastCreatedDate;
else lastDateValue = (Datetime)JSON.deserialize('"' + lastSortValue + '"', Datetime.class);
soql += ' WHERE (CreatedDate ' + operator + ' :lastDateValue OR ' +
'(CreatedDate = :lastDateValue AND Id ' + operator + ' :lastRecordId))';
}
}
// ORDER BY clause
if (isVolatile) {
// Reset-based pagination for unstable fields
soql += ' ORDER BY ' + sortBy + ' ' + sortDirection + ', Id ' + sortDirection;
} else if (useComposite && sortBy != 'CreatedDate') {
// Composite key for stable non-unique fields
soql += ' ORDER BY ' + sortBy + ' ' + sortDirection + ', CreatedDate ' + sortDirection + ', Id ' + sortDirection;
} else {
// Standard composite key
soql += ' ORDER BY CreatedDate ' + sortDirection + ', Id ' + sortDirection;
}
soql += ' LIMIT :limitSize';
System.debug('SOQL Query: ' + soql);
return Database.query(soql);
}
}

🧠 Rule of Thumb: Choosing Safe Sort Fields for Keyset Pagination in the Contact Object

Field Stability TypeExample Contact FieldsKeyset Pagination Safe?Recommended Usage / Notes
Unique FieldIdYesEach Contact’s Id is globally unique and immutable — ideal for simple and safe pagination.
Composite Key (Best Practice)CreatedDate + IdYesSafest and default for keyset pagination. Stable, indexed, and chronological — perfect for “recently created contacts” or donor activity lists.
Semi-Unique Stable FieldsLastName, Email, Donor_Level__c, Contact_Status__cYesSafe when combined with CreatedDate + Id. Use: ORDER BY LastName, CreatedDate, Id. Ensures no duplicates even if names or emails repeat.
Deterministic (Mostly Stable)FirstName, MailingCity, MailingState, LeadSource, Title⚠️ Mostly SafeUsually stable, but if users toggle sorting often, reset pagination to avoid confusion. Combine with CreatedDate + Id for tie-breaking.
Frequently Changing (Volatile)LastModifiedDate, AnnualRevenue__c (if extended field), Score__c⚠️ UnstableNot safe for infinite scroll. If sorted by these, always reset pagination and fetch from top. Field values may change mid-scroll.
Formula or Rollup FieldsTotal_Donations__c, Days_Since_Last_Donation__c, Lifetime_Value__cNoThese fields are derived or recalculated dynamically — may change between queries. Not safe for keyset continuation; reset pagination.
Non-Indexed / Text Formula FieldsFullName__c, Preferred_Contact__c, long text notesNoNon-indexed, often computed on the fly, and not sortable at scale. Avoid using in ORDER BY for keyset pagination.

Best Practices

  • Always use CreatedDate + Id as your composite cursor — it’s stable, indexed, and chronological.
  • For non-unique text fields (LastName, Email), append both CreatedDate and Id to maintain order consistency.
  • For dynamic or volatile fields (LastModifiedDate), do not keyset paginate. Instead, reset and reload from the top when the sort changes.
  • Keep your WHERE and ORDER BY fields aligned (same sequence and direction).

Advantages of OFFSET-Free (Keyset) Pagination

  • 🚀 No 2,000-record SOQL limit
  • ⚡ Faster queries with indexed filters
  • 🔁 Seamless infinite scrolling
  • 🔒 Governor-safe for large data sets
  • 🔍 Compatible with hybrid dynamic sorting

Known Limitations

LimitationDescription
One-directionalKeyset only supports “next page” (no back navigation)
Sort-dependentMust use stable fields like Id or CreatedDate
Dynamic sorts reset paginationNon-indexed fields reload from top
Data changes mid-scrollRecord order may shift slightly if values update during load

🏁 Final Takeaway

Salesforce’s OFFSET pagination is fine for small prototypes. However, keyset pagination is the only scalable way to handle large data sets.

🔹 Use CreatedDate, Id for infinite scroll stability.
🔹 Use reset-based pagination for user-driven sorts.
🔹 Combine both for a hybrid model that balances performance and flexibility.

This pattern delivers the best of both worlds. It provides speed and scalability on the backend. It offers flexibility in the UI. This is done while staying safely within Salesforce’s governor limits.

One response to “Server Side OFFSET-Free Lazy Loading (Keyset Pagination) With Infinite Scroll In Salesforce LWC”

  1. […] Note – In this example we used Offset in Apex Class. It will only work for smaller records up to 2000 records. For, more than 2000 records refer our another blog – Server Side OFFSET-Free Lazy Loading (Keyset Pagination) With Infinite Scroll In Salesforce LWC. […]

    Like

Leave a comment