Offset Pagination Is Lying to You: Build Real Infinite Scroll with Quarkus
A pragmatic Java tutorial on cursor pagination, composite indexes, and API design that actually survives production traffic.
The first time I saw this system fail, it wasn’t in a load test. It was in production, late on a Friday, while marketing was proudly demoing a new “endless browsing” experience to a partner. The first few scrolls were smooth, almost boring. Then someone kept going. And going. And suddenly the API that normally answered in under 50 milliseconds started taking seconds. Database CPU spiked, connection pools backed up, and the product team blamed “Postgres being slow again”.
The root cause wasn’t Postgres. It was us. We had built infinite scroll on top of offset pagination, asking the database to skip tens of thousands of rows just to hand back the next twenty. In a distributed Java system, that pattern looks innocent at first and catastrophic later, because every deeper page forces the database to do more work than the last. This tutorial is the system we should have built from day one.
What We’re Actually Building
We’re going to build a production-grade product catalog API that supports infinite scrolling without falling over. The core is cursor-based pagination, implemented with Quarkus, Hibernate ORM with Panache, and PostgreSQL. We’ll support category filters, multiple sort orders, and stateless cursors that survive concurrent inserts without confusing the client.
This is not a demo that stops at “it works”. We’ll go all the way to database indexes, cursor composition, and real HTTP calls that mirror what a frontend infinite scroll component actually does.
Starting From a Clean Slate
You’ll need Java 17 or newer, Maven, and a running PostgreSQL instance. We’ll use Docker or Podman to keep the database disposable and boring.
We bootstrap the project with the Quarkus CLI, deliberately choosing quarkus-rest and Panache because this is exactly the stack you would reach for in a real service. And if you don’t want to follow along, feel free to check out the corresponding Github repository.
mvn io.quarkus:quarkus-maven-plugin:create \
-DprojectGroupId=com.example \
-DprojectArtifactId=product-catalog \
-DclassName="com.example.ProductResource" \
-Dpath="/api/products" \
-Dextensions="quarkus-rest-jackson,hibernate-orm-panache,jdbc-postgresql,smallrye-openapi"
cd product-catalogFor the database, we bring up PostgreSQL with Quarkus Dev Services. Just a little configuration to add to the application.properties
quarkus.datasource.db-kind=postgresql
quarkus.hibernate-orm.schema-management.strategy = drop-and-create
quarkus.hibernate-orm.log.sql=trueModeling Products for the Way We Query Them
The product entity looks straightforward until you look at the indexes. That’s where the real design lives. We’re not indexing for “find by ID”. We’re indexing for “give me the next slice after this point”.
package com.example.entity;
import java.time.Instant;
import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
@Entity
@Table(name = "products", indexes = {
@Index(name = "idx_category_views_id", columnList = "category, view_count DESC, id"),
@Index(name = "idx_category_created_id", columnList = "category, created_at DESC, id")
})
public class Product extends PanacheEntityBase {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
public Long id;
@Column(nullable = false)
public String name;
@Column(length = 1000)
public String description;
@Column(nullable = false)
public String category;
@Column(nullable = false)
public Double price;
@Column(name = "view_count", nullable = false)
public Integer viewCount = 0;
@Column(name = "created_at", nullable = false)
public Instant createdAt;
@Column(name = "image_url")
public String imageUrl;
@PrePersist
void onCreate() {
if (createdAt == null) {
createdAt = Instant.now();
}
}
}The composite indexes look odd if you’re used to offset pagination. They make perfect sense for cursor pagination because the database can seek directly to “the next row after this cursor” instead of counting and discarding rows it will never return.
Cursor Pagination Without Lying to the Database
Cursor pagination is not about inventing a clever token. It’s about writing a WHERE clause that describes a position in a sorted set.
For popularity sorting, our order is viewCount DESC, id ASC. That means the cursor must contain both values. One without the other is ambiguous.
package com.example.repository;
import java.util.List;
import com.example.entity.Product;
import io.quarkus.hibernate.orm.panache.PanacheRepository;
import io.quarkus.panache.common.Parameters;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class ProductRepository implements PanacheRepository<Product> {
public List<Product> findByPopularity(String category, String cursor, int limit) {
if (cursor == null || cursor.isBlank()) {
String base = category == null
? "ORDER BY viewCount DESC, id ASC"
: "category = :category ORDER BY viewCount DESC, id ASC";
return category == null
? find(base).page(0, limit).list()
: find(base, Parameters.with("category", category))
.page(0, limit)
.list();
}
String[] parts = cursor.split(":");
int cursorViews = Integer.parseInt(parts[0]);
long cursorId = Long.parseLong(parts[1]);
String where = category == null
? """
WHERE (viewCount < :views)
OR (viewCount = :views AND id > :id)
ORDER BY viewCount DESC, id ASC
"""
: """
WHERE category = :category
AND ((viewCount < :views)
OR (viewCount = :views AND id > :id))
ORDER BY viewCount DESC, id ASC
""";
Parameters params = Parameters
.with("views", cursorViews)
.and("id", cursorId);
if (category != null) {
params.and("category", category);
}
return find(where, params).page(0, limit).list();
}
}That OR condition is the heart of cursor pagination. It expresses “everything after this row” in a way the database understands and can optimize. There is no skipping. There is no counting. There is only seeking.
Exposing a Cursor-Friendly API
The REST layer exists to translate HTTP semantics into database intent. It also enforces limits, shapes responses for the frontend, and generates cursors for the next request. Let’s start with the DTOs.
package com.example.dto;
import java.util.List;
public record PageResponse<T>(
List<T> items,
String nextCursor,
boolean hasMore,
long count) {
}And of course the :
package com.example.dto;
public record ProductDTO(
Long id,
String name,
String category,
Double price,
Integer viewCount,
String createdAt,
String cursor) {
}Before we move on to the endpoint itself.
package com.example.resource;
import com.example.dto.PageResponse;
import com.example.dto.ProductDTO;
import com.example.entity.Product;
import com.example.repository.ProductRepository;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import java.time.Instant;
import java.util.List;
@Path("/api/products")
@Produces(MediaType.APPLICATION_JSON)
public class ProductResource {
@Inject
ProductRepository repository;
@GET
public PageResponse<ProductDTO> list(
@QueryParam("category") String category,
@QueryParam("cursor") String cursor,
@QueryParam("limit") @DefaultValue("20") int limit
) {
limit = Math.min(limit, 100);
List<Product> results =
repository.findByPopularity(category, cursor, limit + 1);
boolean hasMore = results.size() > limit;
if (hasMore) {
results = results.subList(0, limit);
}
List<ProductDTO> data = results.stream()
.map(this::toDTO)
.toList();
String nextCursor = hasMore && !results.isEmpty()
? encodeCursor(results.get(results.size() - 1))
: null;
return new PageResponse<>(data, nextCursor, hasMore, data.size());
}
private ProductDTO toDTO(Product p) {
return new ProductDTO(
p.id,
p.name,
p.category,
p.price,
p.viewCount,
p.createdAt.toString(),
encodeCursor(p)
);
}
private String encodeCursor(Product p) {
return p.viewCount + ":" + p.id;
}
@POST
@Path("/seed")
@Transactional
public String seed(@QueryParam("count") @DefaultValue("10000") int count) {
repository.deleteAll();
for (int i = 0; i < count; i++) {
Product p = new Product();
p.name = "Product " + i;
p.category = i % 2 == 0 ? "Electronics" : "Books";
p.price = 10.0 + (i % 100);
p.viewCount = (int) (Math.random() * 10_000);
p.createdAt = Instant.now().minusSeconds(i * 60);
p.persist();
}
return "Seeded " + count + " products";
}
}Notice the deliberate limit + 1 fetch. We don’t guess whether there is another page. We ask the database and trim the result.
Watching the Difference in Real Time
With Quarkus running in dev mode, we seed the database and start scrolling.
./mvnw quarkus:devWe need a ton of products so we can see the cursor in action. Let’s seed them:
curl -X POST "http://localhost:8080/api/products/seed?count=10000"The first request has no cursor.
curl "http://localhost:8080/api/products?limit=20"The response contains a cursor derived from the last item.
{
"items": [
{
"id": 6325,
"name": "Product 6324",
"category": "Electronics",
"price": 34.0,
"viewCount": 9983,
"createdAt": "2025-12-24T22:04:01.772047Z",
"cursor": "9983:6325"
}
],
"nextCursor": "9983:6325",
"hasMore": true,
"count": 20
}The next request feeds that cursor back verbatim.
curl "http://localhost:8080/api/products?limit=20&cursor=9983:6325"From the database’s perspective, both queries are cheap. It doesn’t matter whether you’re on the first page or the five-thousandth. The execution plan stays stable.
How it works
The resource sorts products by Popularity (views) descending and ID ascending (as a tie-breaker):
ORDER BY viewCount DESC, id ASCThe cursor string is simply a composite key of these two values:
viewCount:idEncoding: When a page is returned, the cursor is generated from the last item in that list.
In above example, the last product has
viewCount: 9968and
id: 1775So the
nextCursorbecomes"9968:1775"
Decoding (The Query): When you send this cursor back for the next page (
?cursor=9968:1775)the repository splits it and runs this SQL logic:
WHERE (viewCount < 9968)
OR (viewCount = 9968 AND id > 1775)
ORDER BY viewCount DESC, id ASCWhy “ID > Like This”?
This logic handles the logical “next” item:
Best case: Find products with fewer views than 9968.
Tie-breaker: If multiple products have exactly 9968 views, we need a stable way to cut the list. Since we sort IDs in ascending order, the “next” product will have an ID greater than 1775.
Your Response Data
“nextCursor”: “9968:1775”This tells your client: “To get the next page, ask for products starting strictly after the product with 9968 views and ID 1775.”
Why This Survives Production Traffic
Cursor pagination works because it aligns API semantics with database behavior. We’re not fighting the query planner anymore. We’re letting indexes do what they were designed to do.
This approach also survives concurrent writes. New products inserted at the top of the list don’t shift offsets or duplicate results. The cursor represents a position in time, not a page number.
The trade-off is real. You can’t jump to page 500, and that makes some people uncomfortable. Infinite scroll doesn’t need page 500. It needs the next twenty items, fast, every time.
Closing the Loop
Offset pagination feels simple until traffic and data volume make it expensive. Cursor pagination feels complex until you see how naturally it maps to sorted data and composite indexes. Once you’ve shipped a system like this, it’s hard to go back.
Infinite scroll doesn’t need clever frontends. It needs honest queries.



Really solid breakdown of why offset pagination falls apart at scale. The Friday production story hits home - I've seen teams chase "slow database" symptoms when the real issue was exactly this. What stood out was how the composite index approach makes the database seek insteadof scan. The limit+1 trick for hasMore is clever too, avoids a whole separate count query most implementations waste time on.