Realm Results Are Already Paginated: Lazy Queries, Live Updates, and When to Freeze
Realm Results read through to the database instead of copying rows, so offset pagination is usually unnecessary — the real decision is when to freeze a snapshot.
31 Aug 2026, 10:46 UTC

You have 200,000 rows in a local Realm file. The list screen stutters, so you reach for the familiar fix: offset pagination — query 50 rows at a time with a limit and offset, append them to a list, reload the adapter. Now every write anywhere in the database triggers a full reload, and scrolling to page 40 feels worse than page 1.
The thesis here is that for local reads, offset pagination usually solves a problem Realm already solved. A Realm Results collection is not a buffer of rows — it is a query handle that reads through to the database as you touch it. The engineering decision that actually matters is not "how do I page?" but "when do I need a stable snapshot instead of a live view?"
What "live and lazy" means in practice
A Results object represents the outcome of a query, not the rows themselves. When you index into it or iterate it, Realm materializes only the objects you actually touched. Bind that collection to a list adapter and the adapter's own view recycling becomes your pagination: the user scrolls, the adapter asks for position N, and Realm reads that object.
The second half of the behaviour is that the collection is live. If another part of the app inserts a matching object, the same Results instance reflects it. Realm change listeners deliver fine-grained change sets — insertions, deletions, and modifications with their indices — which map onto adapter diff notifications, so you can animate a single row instead of reloading the list.
A worked example: binding Results to a list adapter
Assume a Kotlin Android app with an Item model that has an indexed ownerId field. Run this on the main thread, because the adapter and its change listener are UI-bound; no special permission is required beyond the app's normal access to its own Realm file.
// Illustrative shape only. API names differ across Realm SDKs and
// major versions — check yours before copying.
val realm = Realm.open(config)
val results: Results<Item> =
realm.query<Item>("ownerId == $0", ownerId).find()
val job = results.asFlow().collect { change ->
// change exposes insertions / deletions / modifications
// (field names vary by SDK). Apply them to your adapter.
adapter.applyChangeSet(change)
}Expected checks after wiring this up:
- Scroll to the bottom of a large result set and watch memory. If the whole table were being copied, memory would track row count; with lazy reads it should stay roughly flat.
- Insert a matching object from a background write transaction. The listener should report an insertion at a specific index, not a wholesale invalidation.
- Delete a visible row. The adapter should receive a deletion index, not a full reload.
Risks to watch: a change set is only valid for the version it was delivered against. If you store indices and apply them later, or if you mutate the collection while iterating it, indices shift under you and you will remove or animate the wrong row.
Freeze when you need stability, not freshness
Live objects and Results are thread-confined. You cannot hand a live collection to a background thread, which is why background pagination or export work needs either a frozen snapshot or a fresh query on that thread's own Realm instance.
Freezing gives you an immutable view: the count and contents stay fixed even as writes land. That is exactly what you want for a stable UI state or a background diff, and exactly what you do not want if the screen is supposed to reflect new data.
| Property | Live Results | Frozen snapshot |
|---|---|---|
| Reflects later writes | Yes, automatically | No, until re-queried |
| Cross-thread use | Not allowed | Allowed |
| Indices stable during iteration | No | Yes |
| Typical use | UI list bound to adapter | Background work, stable UI state |
The limitation that bites hardest in write-heavy apps is version pinning. Holding a live Results (or any live object) keeps the Realm version it was read from alive, so the file can grow until the instance is refreshed or closed. Long-lived listeners on hot data are the usual culprit.
Sync changes the boundary of the problem
If the app uses Atlas Device Sync, pagination is bounded by the subscription, not by the backend dataset. Flexible sync subscriptions decide which objects exist locally at all, so a query can only page over what the subscription has delivered. Paging past that boundary is not a query problem; it is a subscription-design problem.
One caveat worth flagging for review before you commit: Realm Java is in maintenance mode and MongoDB has deprecated Atlas Device Sync. Support status and recommended SDKs have changed over time, so confirm the current state for your platform and version against the official MongoDB documentation rather than assuming the behaviour described here is still the supported path.
What to do next
Before adding offset pagination to a local Realm screen, check whether the adapter can bind directly to a Results collection with a change listener. If it can, you have removed a page of state and a class of bugs. Then verify the three things this design depends on: that memory stays flat while scrolling a large set, that change sets arrive with the indices you expect, and that frozen snapshots keep their count while live results change. Those checks are cheap, and they tell you whether the lazy model is working for your data or quietly pinning versions behind your back.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.