Stopping the Memory Leak: Implementing Server-Side Pagination in Grails GORM
Stop OutOfMemoryErrors in Grails by moving pagination from the JVM to the database. Learn how to use GORM's paginate method and PagedResult for efficient data retrieval.
26 Oct 2025, 16:40 UTC

The Cost of Loading Everything
A common performance trap in Grails applications occurs when a developer calls DomainClass.list() on a table that has grown from a few hundred rows to several hundred thousand. Because GORM defaults to retrieving all matching records, the JVM attempts to instantiate thousands of Hibernate entities simultaneously. This leads to massive memory spikes, frequent Garbage Collection pauses, and eventually, the dreaded OutOfMemoryError.
The solution is server-side pagination. Instead of pulling the entire dataset into application memory and filtering it in a view, you must instruct the database to return only a specific slice of data using max and offset parameters.
How GORM Handles Slicing
GORM integrates directly with Hibernate to translate pagination parameters into native SQL LIMIT and OFFSET clauses. This ensures that the database engine handles the heavy lifting of skipping rows, and only the requested subset of records travels over the network to your application.
The PagedResult Object
When using the paginate method, Grails returns a PagedResult object rather than a simple list. This object is critical for building UI navigation because it encapsulates three essential pieces of data:
- content: The actual list of domain objects for the current page.
- totalCount: The total number of records matching the query used to calculate total pages.
- pageNumber: The current page index.
Worked Example: Dynamic Pagination in a Controller
To implement this, your controller must accept parameters from the request usually page and perPage and pass them to the GORM query. This example assumes a Product domain class and Grails 5+ using Hibernate.
class ProductController {
def index(Integer page, Integer perPage) {
Integer pageNum = page ?: 0
Integer maxRows = perPage ?: 20
def result = Product.paginate(
page: pageNum,
perPage: maxRows,
sort: 'name',
order: 'asc'
)
[
products: result.content,
totalCount: result.totalCount,
currentPage: result.pageNumber
]
}
}
Verification and Diagnostics
To verify that pagination is happening at the database level and not in the JVM, enable SQL logging in application.yml:
hibernate:
sql_show_sql: true
When you refresh the page, check the console logs. You should see a SELECT count(*) query followed by a query containing limit ? offset ?. If you see a query without a limit clause, the application is loading the full dataset into memory.
Trade-offs and Performance Limits
While offset pagination is the standard approach, it has two primary limitations that can impact high-scale systems:
The Deep Pagination Problem
As the offset value increases e.g., requesting page 10,000, the database must still scan through all previous rows to find the starting point of the requested page. This results in linear performance degradation as the user navigates deeper into the dataset.
The Shifting Data Gap
Offset-based pagination is stateless. If a record is deleted from page 1 while a user is navigating to page 2, the first item of page 2 shifts up to page 1. The user will then see a duplicate item at the top of page 2 because the index has shifted.
| Metric | Offset Pagination | Keyset Cursor Pagination |
|---|---|---|
| Implementation | Simple Built-in GORM | Complex Manual Query |
| Deep Page Speed | Slows down | Constant speed |
| Data Consistency | Prone to skips dupes | Stable |
Closing Action
For most administrative panels and internal tools, Product.paginate() is the correct choice. However, if you are building a public-facing feed with millions of rows, avoid offset and instead implement keyset pagination by filtering for IDs greater than the last seen ID on the previous page e.g., Product.list(max: 20, 'id > :lastId', [lastId: 500]).
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.