Grails GORM Pagination: Architecture Note on Offset Limits and Alternatives
An architecture note on using GORM’s max/offset for pagination, covering requirements, minimal design, trust boundaries, operational checks, failure modes, and when to redesign.
22 Aug 2026, 00:09 UTC

Requirements
When building a Grails application that exposes list views or APIs over large domain tables, the system must:
- Return a subset of rows matching a user‑driven page number and page size.
- Provide the total number of pages (or total count) for UI pagination controls.
- Guarantee deterministic ordering so that the same logical page returns the same set of rows across requests.
- Avoid exposing raw SQL to prevent injection risks.
- Keep response latency within acceptable limits even as the table grows to hundreds of thousands of rows.
Smallest Suitable Design
The simplest design that satisfies the above uses GORM’s dynamic finder or Criteria API with the max and offset parameters, paired with an explicit orderBy clause and a separate count query. This maps directly to Hibernate’s LIMIT/OFFSET translation for the underlying dialect (e.g., PostgreSQL, MySQL).
Domain class example
class Book {
String title
String author
Date publishedDate
static constraints = {
title blank: false
author blank: false
}
}
Query implementation
def listBooks(int page, int size) {
// page is zero‑based for offset calculation
int offset = page * size
def results = Book.createCriteria().list {
// deterministic order required for stable pagination
order('publishedDate', 'desc')
maxResults size
firstResult offset
}
def total = Book.count()
return [results: results, total: total]
}
When SQL logging is enabled (log4j.logger.org.hibernate.SQL=debug), the generated statement resembles:
select this_.id as id1_0_, this_.title as title2_0_, this_.author as author3_0_, this_.published_date as published4_0_ from book this_ order by this_.published_date desc limit ? offset ?
Trust/Data Boundaries
GORM encapsulates domain logic; the query builder works with HQL/Criteria objects, not raw strings. As long as the page and size values are validated as integers before reaching the service layer, there is no direct path for SQL injection. The boundary is therefore the service method that receives HTTP parameters and casts them safely.
Operational Checks
- Enable SQL logging in
grails-app/conf/log4j.propertiesto verify that thelimitandoffsetvalues match the requested page and size. - Measure execution time for low‑offset (e.g., page 0) and high‑offset (e.g., page 5000) requests using a profiling tool or
curl -w "%{time_total}". - Confirm ordering stability by issuing two consecutive requests for the same page and comparing the returned identifiers; they must be identical.
- Validate total count against a direct
SELECT COUNT(*) FROM bookexecuted via a database client to ensure the separate count query is accurate.
Failure Modes
- Performance degradation – As offset grows, the database must scan and discard increasing numbers of rows before applying the limit, causing higher CPU and I/O usage. This is evident when execution time rises non‑linearly with page number.
- Inconsistent pages – If the query lacks an explicit
orderBy, the underlying database may return rows in varying physical order, leading to duplicate or missing items across pages. - Memory pressure – Large result sets fetched before applying
max(e.g., due to eager joins) can exhaust heap space, especially when GORM automatically fetches associations. - Cartesian product – Paginating a criteria that includes associations without proper join strategy can produce a cross‑join, inflating the row count before pagination and worsening performance.
Conditions That Would Change the Design
If any of the following are observed, the simple offset‑based approach should be reconsidered:
- Average response time for pages beyond a certain threshold exceeds the service‑level agreement (e.g., >200 ms for page > 1000).
- Database monitoring shows high
rows examinedmetrics for paginated queries, indicating excessive scanning. - The application requires frequent navigation to deep pages (e.g., users jumping to the last page of a large catalog).
- Domain models contain large collections that are eagerly fetched, causing the pagination query to load excessive data before applying limits.
In those cases, alternatives such as keyset (seek) pagination, materialized offset tables, or database‑specific features like PostgreSQL’s FETCH FIRST with a stable cursor may be more appropriate. The decision to switch should be based on measured performance data and the specific access patterns of the application.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.