Skip to main content

Command Palette

Search for a command to run...

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

Published
8 min read
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/OFFSET paging gets slow as pages grow

  • Infinite 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.PaginationCursor

  • Bind-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

ApproachProblem at scale
LIMIT/OFFSETslow at high offsets; weak for infinite scrolling
manual ID trackingcomplex; edge cases when data changes
Batch Apexpowerful 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 TypeBest ForKey Methods
Database.PaginationCursorUI paging (Next/Previous, Load More)fetchPage(start, size)
Database.Cursorlarge async processingfetch(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 Cases

  • currentPage, totalPages, totalRecords

  • cursorJson: 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 = null

  • receive 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_MODE ensures 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

ComponentRole
CaseInboxCursorControllerUI paging endpoint (PaginationCursor)
cursorJsoncursor state passed between requests
LWC datatableclean paging UI for learners
ContactArchiveJobLaunchercreates Cursor once and launches Queueable
ContactArchiveCursorJobchunk processing + chaining

✅ Which cursor should I use?

Use CaseRecommended Cursor
Next/Previous buttonsDatabase.PaginationCursor
Infinite scrollDatabase.PaginationCursor
Export / background processingDatabase.Cursor
Chunked updatesDatabase.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 PaginationCursor for UI paging.

  • Use Database.PaginationCursor for 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


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

More from this blog

Kingsley Mgbams

18 posts

This publication focuses on practical Salesforce development, sharing real-world experiences, best practices, and lessons learned along the way.