Spring ’26 Apex Cursors: Fast UI Paging + Large Data Processing (Without OFFSET or Batch Apex)

🚀 Spring ’26 quietly introduced one of the most useful Apex features in years: Apex Cursors
If you’ve ever built a custom Salesforce “inbox” experience (Cases, Orders, Tickets…) you’ve probably hit this wall:
LIMIT/OFFSETpaging gets slow as pages growInfinite scroll becomes inconsistent
You end up writing awkward “keyset pagination” logic
Or worst case… you switch to Batch Apex even though you just needed paging
✅ Spring ’26 finally introduces a first-class solution: Apex Cursors, including PaginationCursor for UI paging.
In this article, we’ll build a working project that demonstrates:
UI paging with
Database.PaginationCursorBind-safe query creation using
Database.getPaginationCursorWithBinds()Large dataset processing using
Database.Cursor+ Queueable chaining
🧩 Problem Statement
Salesforce has always supported retrieving data with SOQL, but paging through large result sets has historically been tricky.
Why traditional approaches don’t scale
| Approach | Problem at scale |
LIMIT/OFFSET | slow at high offsets; weak for infinite scrolling |
| manual ID tracking | complex; edge cases when data changes |
| Batch Apex | powerful but too heavy for many UI and utility cases |
The bigger your org (more records, more users, more automation), the worse these patterns feel.
💡 The Solution: Apex Cursors (Spring ’26)
Spring ’26 introduces Apex Cursors — Apex Cursors let Salesforce maintain a server-side cursor over a query result set, enabling efficient traversal without relying on OFFSET-style pagination. Unlike OFFSET pagination, cursors keep the query traversal efficient even when the user navigates deep into the dataset.
There are two cursor types you need to understand:
| Cursor Type | Best For | Key Methods |
Database.PaginationCursor | UI paging (Next/Previous, Load More) | fetchPage(start, size) |
Database.Cursor | large async processing | fetch(position, size) |
This project demonstrates both, using real code you can deploy to a training org.
⚙️ Prerequisites
Before starting:
Salesforce org on Spring ’26 or later
API 66.0+
Salesforce CLI (
sf)VS Code + Salesforce Extensions (recommended)
Permissions:
Run tests
Deploy Apex/LWC
View Cases
Edit Contacts (for archive demo)
⚠️ Note: Apex Cursors / PaginationCursor are introduced and evolving in Spring ’26. Treat it as new platform functionality and test in sandbox first.
🔍 Deep Dive: How It Works
✅ Part A — UI Paging with Database.PaginationCursor (Case Inbox)
This is the most practical cursor use case: paging in a UI.
We’ll build a “My Open Cases” inbox that supports paging without re-running large OFFSET queries.
Step 1 — Return a paging model
We return a wrapper object from Apex containing:
records: the current page of CasescurrentPage,totalPages,totalRecordscursorJson: serialized cursor state (so LWC can send it back)
public class PaginationResult {
@AuraEnabled public String cursorJson { get; set; } // PaginationCursor serialized
@AuraEnabled public List<Case> records { get; set; }
@AuraEnabled public Integer currentPage { get; set; }
@AuraEnabled public Integer totalPages { get; set; }
@AuraEnabled public Integer pageSize { get; set; }
@AuraEnabled public Integer totalRecords { get; set; }
}
Why cursorJson?
Passing the cursor object directly can lead to serialization/runtime issues in UI contexts. JSON serialization keeps it stable and reusable across calls.
Step 2 — Create the PaginationCursor using binds
This is the key Spring ’26 method:
✅ Database.getPaginationCursorWithBinds(soql, binds, AccessLevel.USER_MODE)
Database.PaginationCursor cursor;
Map<String, Object> binds = new Map<String, Object>{
'ownerId' => UserInfo.getUserId()
};
String soql =
'SELECT Id, CaseNumber, Subject, Status, Priority, CreatedDate ' +
'FROM Case ' +
'WHERE OwnerId = :ownerId ' +
'AND IsClosed = false ' +
'ORDER BY CreatedDate DESC';
cursor = Database.getPaginationCursorWithBinds(
soql,
binds,
AccessLevel.USER_MODE
);
✅ Using binds:
avoids unsafe string concatenation
keeps code readable
enables user-based filtering cleanly
Step 3 — Fetch the page
Paging is done by converting a page number into a start index:
Integer start = (page - 1) * size;
Database.CursorFetchResult fr = cursor.fetchPage(start, size);
Then we cast to Case records:
List<Case> cases = new List<Case>();
for (SObject sob : fr.getRecords()) {
cases.add((Case)sob);
}
Step 4 — Apply defense-in-depth security
Even with sharing enabled, field visibility matters.
SObjectAccessDecision dec = Security.stripInaccessible(AccessType.READABLE, cases);
result.records = (List<Case>) dec.getRecords();
Step 5 — Return cursor state for the next page
result.cursorJson = JSON.serialize(cursor);
This enables LWC paging like:
call page 1 with
cursorJson = nullreceive cursorJson back
call page 2 using the returned cursorJson
✅ Complete Controller (UI paging)
public with sharing class CaseInboxCursorController {
public class PaginationResult {
@AuraEnabled public String cursorJson { get; set; }
@AuraEnabled public List<Case> records { get; set; }
@AuraEnabled public Integer currentPage { get; set; }
@AuraEnabled public Integer totalPages { get; set; }
@AuraEnabled public Integer pageSize { get; set; }
@AuraEnabled public Integer totalRecords { get; set; }
}
@AuraEnabled(cacheable=false)
public static PaginationResult getPage(String cursorJson, Integer page, Integer size) {
if (page == null || page < 1) page = 1;
if (size == null || size < 1) size = 5;
if (size > 8) size = 8;
Database.PaginationCursor cursor;
Map<String, Object> binds = new Map<String, Object>{ 'ownerId' => UserInfo.getUserId() };
if (String.isBlank(cursorJson)) {
String soql =
'SELECT Id, CaseNumber, Subject, Status, Priority, CreatedDate ' +
'FROM Case ' +
'WHERE OwnerId = :ownerId ' +
'AND IsClosed = false ' +
'ORDER BY CreatedDate DESC';
cursor = Database.getPaginationCursorWithBinds(soql, binds, AccessLevel.USER_MODE);
} else {
cursor = (Database.PaginationCursor) JSON.deserialize(cursorJson, Database.PaginationCursor.class);
}
Integer start = (page - 1) * size;
Database.CursorFetchResult fr = cursor.fetchPage(start, size);
List<Case> cases = new List<Case>();
for (SObject sob : fr.getRecords()) cases.add((Case)sob);
SObjectAccessDecision dec = Security.stripInaccessible(AccessType.READABLE, cases);
Integer total = cursor.getNumRecords();
Integer pages = (Integer) Math.ceil((Decimal) total / size);
PaginationResult result = new PaginationResult();
result.records = (List<Case>) dec.getRecords();
result.currentPage = page;
result.pageSize = size;
result.totalRecords = total;
result.totalPages = pages;
result.cursorJson = JSON.serialize(cursor);
return result;
}
}
Step 6 — Display results cleanly (LWC Datatable)
For students, raw output looks messy — the best Trailhead approach is lightning-datatable.
Your LWC can:
call
getPage()show Cases in a datatable
support Next/Previous navigation
Why this matters: it connects the Apex feature directly to a real UI use case (what learners care about).
🔁 Part B — Apex Cursor + Queueable (Large Dataset Processing)
UI paging is only half the story.
The other scalable approach is using Database.Cursor for large async jobs where Batch Apex used to be the default.
Real use case: Archive Contacts in chunks
We’ll chain Queueables to:
fetch 10/200 records at a time
update safely
enqueue next job until complete
Step 1 — Launch the job (creates cursor once)
public with sharing class ContactArchiveJobLauncher {
public static Id start(Integer daysOld, Integer chunkSize) {
if (daysOld == null || daysOld <= 0) daysOld = 400;
if (chunkSize == null || chunkSize <= 0) chunkSize = 200;
if (chunkSize > 2000) chunkSize = 2000;
String soql =
'SELECT Id, Description, LastModifiedDate ' +
'FROM Contact ' +
'WHERE LastModifiedDate < LAST_N_DAYS :daysOld ' +
'ORDER BY LastModifiedDate ASC';
Map<String, Object> binds = new Map<String, Object>{ 'daysOld' => daysOld };
Database.Cursor cursor = Database.getCursorWithBinds(soql, binds, AccessLevel.USER_MODE);
return System.enqueueJob(new ContactArchiveCursorJob(cursor, 0, chunkSize));
}
}
⚠️
AccessLevel.USER_MODEensures the query enforces user permissions (CRUD/FLS) even in async processing.
Step 2 — Process chunks and chain Queueables
public with sharing class ContactArchiveCursorJob implements Queueable {
private Database.Cursor cursor;
private Integer position;
private Integer chunkSize;
public ContactArchiveCursorJob(Database.Cursor cursor, Integer position, Integer chunkSize) {
this.cursor = cursor;
this.position = (position == null || position < 0) ? 0 : position;
this.chunkSize = (chunkSize == null || chunkSize <= 0) ? 200 : chunkSize;
}
public void execute(QueueableContext ctx) {
List<SObject> scopeSObjects = cursor.fetch(position, chunkSize);
if (scopeSObjects.isEmpty()) return;
List<Contact> scope = new List<Contact>();
for (SObject sOb : scopeSObjects) scope.add((Contact) sOb);
for (Contact con : scope) {
String existing = (con.Description == null) ? '' : con.Description;
if (!existing.contains('[ARCHIVED]')) {
con.Description = (existing + ' [ARCHIVED]').trim();
}
}
update scope;
position += scope.size();
if (position < cursor.getNumRecords()) {
System.enqueueJob(new ContactArchiveCursorJob(cursor, position, chunkSize));
}
}
}
This is the exact pattern most architects want:
stable chunk processing
limit-safe updates
scalable to huge datasets
🏗 Architecture Overview
Apex Cursors Demo Repo
├── UI Paging (PaginationCursor)
│ ├── CaseInboxCursorController.cls
│ ├── CaseInboxCursorControllerTest.cls
│ └── LWC (Case Inbox datatable)
│
└── Async Processing (Cursor)
├── ContactArchiveJobLauncher.cls
├── ContactArchiveCursorJob.cls
└── ContactArchiveCursorJobTest.cls
Key Components
| Component | Role |
CaseInboxCursorController | UI paging endpoint (PaginationCursor) |
cursorJson | cursor state passed between requests |
| LWC datatable | clean paging UI for learners |
ContactArchiveJobLauncher | creates Cursor once and launches Queueable |
ContactArchiveCursorJob | chunk processing + chaining |
✅ Which cursor should I use?
| Use Case | Recommended Cursor |
| Next/Previous buttons | Database.PaginationCursor |
| Infinite scroll | Database.PaginationCursor |
| Export / background processing | Database.Cursor |
| Chunked updates | Database.Cursor |
🌍 Real-World Applications
Where this helps immediately
✅ Enterprise orgs
huge Case/Task/Order volumes
UI paging performance matters
✅ Admin tools
“list records + load more”
clean UI without standard list views
✅ CI/CD & DevOps
safer scalable jobs without Batch complexity
easy to test and reason about
✅ Hotfixes
less time building paging workarounds
can ship cleaner utilities faster
✅ Key Takeaways
Spring ’26 introduces Apex Cursors, including
PaginationCursorfor UI paging.Use
Database.PaginationCursorfor Next/Previous / Load More UI patterns.Use
getPaginationCursorWithBinds()to build secure, maintainable cursor queries.Use
Database.Cursor+ Queueable chaining to process large datasets without Batch Apex.Serialize cursor state (
cursorJson) to avoid UI serialization issues and keep paging stable.
📚 Resources
💻 GitHub Repo: https://github.com/Mgbams/salesforce-spring-26/tree/main/topics/apex-cursors-and-paginationcursors/main/default
Salesforce Docs — Apex Cursors:
https://developer.salesforce.com/docs/atlas.en-us.260.0.apexcode.meta/apexcode/apex_cursors.htm
📣 Call to Action
If you had to pick one:
Would you rather build UI paging manually with OFFSET, or rely on PaginationCursor + cursor state?
👇 Drop your experience in the comments — especially if you’ve had to solve paging at enterprise scale.




