When Infinite Scroll Meets Search: The Pagination Bug You Didn’t See Coming
How cursor pagination breaks under full-text search, and how to fix it in Quarkus without killing performance or trust
The first catalog tutorial shipped on a Sunday. By Thursday, marketing had a new KPI: “search-to-add-to-cart.” The frontend team wired a search box into the infinite scroll feed, and within an hour we saw two new failure modes that looked unrelated until they weren’t.
The first was performance. Offset pagination had already been exiled, but our cursor logic still assumed simple ordering keys like (viewCount, id) or (createdAt, id). The second was uglier: a partner had discovered that our cursor was just "9998:4523", and they started “optimizing” their own scraping by forging cursors. It was not a security breach in the dramatic sense, but it was tampering, and it turned our database into their free compute layer.
What made it painful to debug was that both problems hid behind good intentions. Full-text search feels like “just another filter,” and cursors feel like “just a pointer.” In distributed Java systems, those two “just” words are where your latency budgets go to die, because any ambiguity in ordering becomes pagination drift, and any transparency in cursors becomes an invitation to clients to treat your API contract like a suggestion.
Here’s the architecture we’re going to end up with, exactly at the point where the confusion usually starts: search relevance, stable ordering, and “previous page” all pulling on the same piece of string.
This follow-up is standalone, but it’s built as if you already learned the lesson from the first post: the database must “seek,” not “skip.”
Build a New Catalog That Can Search, Scroll, Go Back, and Say No to Tampering
Prerequisites: Java 21, Maven, and a local container runtime that can run PostgreSQL.
Create the project with one Quarkus CLI command or grab the source code from my Github repository.
quarkus create app com.example:product-catalog-fts \
--java=21 \
--extension='rest-jackson,hibernate-orm-panache,jdbc-postgresql,rest-qute,smallrye-openapi' \
--no-code
cd product-catalog-ftsWe include these extensions for reasons that become visible later:
rest-jacksonbecause we want Quarkus REST with JSON serialization.rest-quteas the bridge between rest and qute. because the “standalone” part includes a server-rendered page that ships the infinite scroll JavaScript.hibernate-orm-panache+jdbc-postgresqlfor Hibernate and Postgresql. Because keyset pagination only works when you can express the ordering keys cleanly at the SQL level.smallrye-openapibecause the API surface is about to grow, and you will want the contract visible while iterating.
Quarkus Configuration
Update src/main/resources/application.properties:
# Database
quarkus.datasource.db-kind=postgresql
# Hibernate
quarkus.hibernate-orm.schema-management.strategy = drop-and-create
quarkus.hibernate-orm.log.sql=true
# Cursor encryption key (base64). Replace for anything real.
catalog.cursor.key-b64=6G1mJ5u3h9nXgqv0G6C8x3pVn8wQ1xkqJQb8G2mJvV4=That encryption key is not a “security feature checkbox.” It’s how we turn cursors into an opaque capability that clients can use but cannot safely forge.
The Data Model That Can Be Searched Without Breaking Keyset Pagination
We’ll keep the same Product structure, but we’re going to plan for full-text search with PostgreSQL’s built-in functions like to_tsvector() and tsquery, backed by a GIN index. PostgreSQL is explicit about the building blocks here: you create a tsvector from the document, a tsquery from user input, and you rank relevance for ordering. (PostgreSQL)
Create src/main/java/com/example/entity/Product.java:
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
protected void onCreate() {
if (createdAt == null) {
createdAt = Instant.now();
}
}
}Notice what’s missing: there is no dedicated “search vector” column. That’s deliberate for a tutorial that must stay copy-pasteable. We’ll build a functional GIN index instead, which keeps the schema simple while still giving PostgreSQL the structure it needs to search fast.
Create src/main/resources/import.sql:
-- Full-text search GIN index on name + description.
-- We coalesce description because it can be null.
CREATE INDEX IF NOT EXISTS idx_products_fts
ON products
USING GIN (to_tsvector('english', name || ' ' || coalesce(description, '')));
-- Optional: category + created_at is already indexed via JPA, but GIN is separate.This index is what keeps “search” from becoming “scan.” When you combine keyset pagination with full-text search, you are asking PostgreSQL to do two hard things at once: filter by a text predicate and still return results in a stable order without skipping. The GIN index is the difference between “works on my laptop” and “works at 2 a.m.”
The Cursor Format You Can’t Forge
In the first tutorial, the cursor was human-readable. That’s great for learning, and terrible the minute someone decides your API is also their dataset.
We’ll make the cursor carry three kinds of information:
The ordering keys, so PostgreSQL can seek.
The query context (category, search term, sort), so cursors can’t be replayed across incompatible requests.
An authenticated encryption wrapper, so clients can’t tamper with any of it without being rejected.
Cursor Payload
Create src/main/java/com/example/pagination/CursorPayload.java:
package com.example.pagination;
import java.util.Objects;
public record CursorPayload(
String sort,
String category,
String q,
String direction,
long k1,
long k2) {
public CursorPayload {
sort = Objects.requireNonNull(sort, "sort");
direction = Objects.requireNonNull(direction, "direction");
category = category == null ? "" : category;
q = q == null ? "" : q;
}
}k1 and k2 are the “seek keys.” For popularity it’s (viewCount, id). For newest it’s (createdAtEpochSeconds, id). For relevance, it’s (rankScaledInt, id).
AES-GCM Cursor Codec
Create src/main/java/com/example/pagination/CursorCodec.java:
package com.example.pagination;
import java.security.SecureRandom;
import java.util.Base64;
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class CursorCodec {
private static final int IV_BYTES = 12;
private static final int TAG_BITS = 128;
private final ObjectMapper mapper;
private final SecretKey key;
private final SecureRandom random = new SecureRandom();
public CursorCodec(ObjectMapper mapper,
@ConfigProperty(name = "catalog.cursor.key-b64") String keyB64) {
this.mapper = mapper;
byte[] raw = Base64.getDecoder().decode(keyB64);
this.key = new SecretKeySpec(raw, "AES");
}
public String encode(CursorPayload payload) {
try {
byte[] json = mapper.writeValueAsBytes(payload);
byte[] iv = new byte[IV_BYTES];
random.nextBytes(iv);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, iv));
byte[] ciphertext = cipher.doFinal(json);
byte[] out = new byte[iv.length + ciphertext.length];
System.arraycopy(iv, 0, out, 0, iv.length);
System.arraycopy(ciphertext, 0, out, iv.length, ciphertext.length);
return Base64.getUrlEncoder().withoutPadding().encodeToString(out);
} catch (Exception e) {
throw new IllegalStateException("Failed to encode cursor", e);
}
}
public CursorPayload decode(String token) {
try {
byte[] in = Base64.getUrlDecoder().decode(token);
if (in.length < IV_BYTES + 1) {
throw new IllegalArgumentException("Cursor is too short");
}
byte[] iv = new byte[IV_BYTES];
byte[] ciphertext = new byte[in.length - IV_BYTES];
System.arraycopy(in, 0, iv, 0, IV_BYTES);
System.arraycopy(in, IV_BYTES, ciphertext, 0, ciphertext.length);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, iv));
byte[] json = cipher.doFinal(ciphertext);
return mapper.readValue(json, CursorPayload.class);
} catch (Exception e) {
throw new IllegalArgumentException("Invalid cursor", e);
}
}
}AES-GCM is doing two jobs here. It encrypts, so cursors stop being “API internals leaked into the UI.” It also authenticates, so any tampering becomes a hard failure instead of “interesting database behavior.”
Keyset Pagination That Can Move Forward and Backward
The easiest way to implement “previous page” is to pretend you don’t want it and ship a “Load previous” button that lies. The correct way is to invert the ordering and reverse the results.
We’ll implement three sorts:
popularity:viewCount DESC, id ASCnewest:createdAt DESC, id DESCrelevance: rank descending, then a stable tiebreaker
The “relevance” part is where cursor pagination usually falls apart. Ranking functions can return floats, and floats are a trap when your cursor needs to be stable. We’ll scale the rank into an integer and use that as k1, because a cursor key must be compared without ambiguity.
DTOs That Carry Opaque Cursors
Create src/main/java/com/example/dto/ProductDTO.java:
package com.example.dto;
public record ProductDTO(
Long id,
String name,
String description,
String category,
Double price,
Integer viewCount,
String createdAt,
String imageUrl,
String cursor) {
}Create src/main/java/com/example/dto/PageResponse.java:
package com.example.dto;
import java.util.List;
public record PageResponse<T>(
List<T> data,
String nextCursor,
String prevCursor,
boolean hasNext,
boolean hasPrev,
int count) {
}Repository With Full-Text Search and Bidirectional Keyset Queries
Create src/main/java/com/example/repository/ProductRepository.java:
package com.example.repository;
import com.example.entity.Product;
import io.quarkus.hibernate.orm.panache.PanacheRepository;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.persistence.EntityManager;
import jakarta.persistence.Query;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.List;
@ApplicationScoped
public class ProductRepository implements PanacheRepository<Product> {
private final EntityManager em;
public ProductRepository(EntityManager em) {
this.em = em;
}
public List<Product> pageByPopularity(String category, Long viewCount, Long id, boolean forward, int limit) {
String baseWhere = category == null || category.isBlank() ? "1=1" : "p.category = :category";
String seek;
String order;
if (forward) {
seek = (viewCount == null || id == null)
? ""
: " AND ((p.viewCount < :vc) OR (p.viewCount = :vc AND p.id > :id))";
order = " ORDER BY p.viewCount DESC, p.id ASC";
} else {
seek = (viewCount == null || id == null)
? ""
: " AND ((p.viewCount > :vc) OR (p.viewCount = :vc AND p.id < :id))";
order = " ORDER BY p.viewCount ASC, p.id DESC";
}
String jpql = "SELECT p FROM Product p WHERE " + baseWhere + seek + order;
var q = em.createQuery(jpql, Product.class);
if (category != null && !category.isBlank()) {
q.setParameter("category", category);
}
if (viewCount != null && id != null) {
q.setParameter("vc", viewCount.intValue());
q.setParameter("id", id);
}
q.setMaxResults(limit);
List<Product> list = q.getResultList();
if (!forward) {
java.util.Collections.reverse(list);
}
return list;
}
public List<Product> pageByNewest(String category, Long createdAtEpoch, Long id, boolean forward, int limit) {
String baseWhere = category == null || category.isBlank() ? "1=1" : "p.category = :category";
String seek;
String order;
if (forward) {
seek = (createdAtEpoch == null || id == null)
? ""
: " AND ((p.createdAt < :ts) OR (p.createdAt = :ts AND p.id < :id))";
order = " ORDER BY p.createdAt DESC, p.id DESC";
} else {
seek = (createdAtEpoch == null || id == null)
? ""
: " AND ((p.createdAt > :ts) OR (p.createdAt = :ts AND p.id > :id))";
order = " ORDER BY p.createdAt ASC, p.id ASC";
}
String jpql = "SELECT p FROM Product p WHERE " + baseWhere + seek + order;
var q = em.createQuery(jpql, Product.class);
if (category != null && !category.isBlank()) {
q.setParameter("category", category);
}
if (createdAtEpoch != null && id != null) {
q.setParameter("ts", Timestamp.from(Instant.ofEpochSecond(createdAtEpoch)));
q.setParameter("id", id);
}
q.setMaxResults(limit);
List<Product> list = q.getResultList();
if (!forward) {
java.util.Collections.reverse(list);
}
return list;
}
/**
* Full-text search with keyset pagination, ordered by relevance first.
*
* We scale rank into an integer so cursors stay deterministic:
* rankScaled = round(ts_rank_cd(...) * 1_000_000)
*/
public List<Object[]> searchByRelevance(String category, String qText, Long rankScaled, Long id, boolean forward,
int limit) {
String baseWhere = category == null || category.isBlank() ? "TRUE" : "p.category = :category";
String seek;
String order;
if (forward) {
seek = (rankScaled == null || id == null)
? ""
: " AND ((r.rank_scaled < :rk) OR (r.rank_scaled = :rk AND p.id > :id))";
order = " ORDER BY r.rank_scaled DESC, p.id ASC";
} else {
seek = (rankScaled == null || id == null)
? ""
: " AND ((r.rank_scaled > :rk) OR (r.rank_scaled = :rk AND p.id < :id))";
order = " ORDER BY r.rank_scaled ASC, p.id DESC";
}
String sql = """
WITH r AS (
SELECT
p.id,
ROUND(ts_rank_cd(
to_tsvector('english', p.name || ' ' || COALESCE(p.description, '')),
plainto_tsquery('english', :q)
) * 1000000)::bigint AS rank_scaled
FROM products p
WHERE %s
AND to_tsvector('english', p.name || ' ' || COALESCE(p.description, ''))
@@ plainto_tsquery('english', :q)
)
SELECT p.id, p.name, p.description, p.category, p.price, p.view_count, p.created_at, p.image_url, r.rank_scaled
FROM products p
JOIN r ON r.id = p.id
WHERE %s %s
%s
"""
.formatted(baseWhere, baseWhere, seek, order);
Query query = em.createNativeQuery(sql, Product.class);
query.setParameter("q", qText);
if (category != null && !category.isBlank()) {
query.setParameter("category", category);
}
if (rankScaled != null && id != null) {
query.setParameter("rk", rankScaled);
query.setParameter("id", id);
}
query.setMaxResults(limit);
@SuppressWarnings("unchecked")
List<Product> products = query.getResultList();
// We still need rank_scaled in the response for cursor generation.
// So we rerun as Object[] only for the returned ids, which is small.
if (products.isEmpty()) {
return List.of();
}
String idList = products.stream().map(p -> p.id.toString()).reduce((a, b) -> a + "," + b).orElseThrow();
String sqlRankFetch = """
SELECT p.id, p.name, p.description, p.category, p.price, p.view_count, p.created_at, p.image_url,
ROUND(ts_rank_cd(
to_tsvector('english', p.name || ' ' || COALESCE(p.description, '')),
plainto_tsquery('english', :q)
) * 1000000)::bigint AS rank_scaled
FROM products p
WHERE p.id IN (%s)
%s
""".formatted(idList, order.replace("r.rank_scaled", "rank_scaled"));
Query q2 = em.createNativeQuery(sqlRankFetch);
q2.setParameter("q", qText);
@SuppressWarnings("unchecked")
List<Object[]> rows = q2.getResultList();
if (!forward) {
java.util.Collections.reverse(rows);
}
return rows;
}
}There’s a lot going on here, but the behavior is consistent across all sorts. When we move backward, we flip the ORDER BY and invert the inequality, then reverse the list so the client still sees items in the same direction. That keeps “previous page” from being a special feature. It becomes just another seek.
The search query leans on PostgreSQL’s to_tsvector() and plainto_tsquery() and ranks results, which is exactly the workflow PostgreSQL documents for full-text search.
The REST API That Ties Search, Bidirectional Cursors, and Encryption Together
Create src/main/java/com/example/resource/ProductResource.java:
package com.example.resource;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import com.example.dto.PageResponse;
import com.example.dto.ProductDTO;
import com.example.entity.Product;
import com.example.pagination.CursorCodec;
import com.example.pagination.CursorPayload;
import com.example.repository.ProductRepository;
import io.quarkus.logging.Log;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
@Path("/api/products")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class ProductResource {
private final ProductRepository repo;
private final CursorCodec codec;
public ProductResource(ProductRepository repo, CursorCodec codec) {
this.repo = repo;
this.codec = codec;
}
@GET
public PageResponse<ProductDTO> list(
@QueryParam("category") String category,
@QueryParam("q") String q,
@QueryParam("sortBy") @DefaultValue("popularity") String sortBy,
@QueryParam("cursor") String cursor,
@QueryParam("direction") @DefaultValue("next") String direction,
@QueryParam("limit") @DefaultValue("20") int limit) {
System.out.println("=== API Request ===");
System.out.println("direction: " + direction);
System.out.println("sortBy: " + sortBy);
System.out.println("category: " + category);
System.out.println("q: " + q);
System.out.println(
"cursor: " + (cursor != null ? cursor.substring(0, Math.min(20, cursor.length())) + "..." : "null"));
limit = Math.min(limit, 100);
boolean forward = !"prev".equalsIgnoreCase(direction);
CursorPayload decoded = null;
if (cursor != null && !cursor.isBlank()) {
try {
decoded = codec.decode(cursor);
System.out.println("Decoded cursor: sort=" + decoded.sort() + ", category=" + decoded.category()
+ ", q=" + decoded.q() + ", direction=" + decoded.direction());
} catch (IllegalArgumentException e) {
System.err.println("Failed to decode cursor: " + e.getMessage());
e.printStackTrace();
throw new BadRequestException("Invalid cursor: " + e.getMessage());
}
// When a cursor is provided, use the sort method from the cursor
// This handles cases where the backend switched to relevance sorting for
// searches
sortBy = decoded.sort();
if (safe(category).equals(decoded.category()) == false
|| safe(q).equals(decoded.q()) == false) {
String errorMsg = String.format(
"Cursor does not match request context. Expected: category=%s, q=%s. Got: category=%s, q=%s",
safe(category), safe(q), decoded.category(), decoded.q());
System.err.println(errorMsg);
throw new BadRequestException(errorMsg);
}
}
Log.infof("Validation passed, sortBy=" + sortBy + ", proceeding with query...");
List<ProductDTO> dtos = new ArrayList<>();
String nextCursor = null;
String prevCursor = null;
if (q != null && !q.isBlank()) {
Long rk = decoded == null ? null : decoded.k1();
Long id = decoded == null ? null : decoded.k2();
List<Object[]> rows = repo.searchByRelevance(category, q, rk, id, forward, limit + 1);
boolean hasMore = rows.size() > limit;
if (hasMore) {
rows = rows.subList(0, limit);
}
for (Object[] row : rows) {
// Native query returns columns; Product is first in our fetch pattern only in
// typed queries.
// Here we re-fetch rows as raw, so map manually.
// Column order from SQL: id, name, description, category, price, view_count,
// created_at, image_url, rank_scaled
Product p = new Product();
p.id = ((Number) row[0]).longValue();
p.name = (String) row[1];
p.description = (String) row[2];
p.category = (String) row[3];
p.price = row[4] == null ? null : ((Number) row[4]).doubleValue();
p.viewCount = row[5] == null ? 0 : ((Number) row[5]).intValue();
// Handle created_at - can be either Timestamp or Instant depending on driver
if (row[6] == null) {
p.createdAt = Instant.EPOCH;
} else if (row[6] instanceof java.sql.Timestamp) {
p.createdAt = ((java.sql.Timestamp) row[6]).toInstant();
} else if (row[6] instanceof Instant) {
p.createdAt = (Instant) row[6];
} else {
p.createdAt = Instant.EPOCH;
}
p.imageUrl = (String) row[7];
long rankScaled = ((Number) row[8]).longValue();
String itemCursor = codec.encode(new CursorPayload(
"relevance", safe(category), safe(q), safe(direction), rankScaled, p.id));
dtos.add(toDTO(p, itemCursor));
}
if (!dtos.isEmpty()) {
ProductDTO first = dtos.get(0);
ProductDTO last = dtos.get(dtos.size() - 1);
prevCursor = hasBackCursor("relevance", category, q, first.cursor())
? flipDirection(first.cursor())
: null;
nextCursor = hasMore ? last.cursor() : null;
return new PageResponse<>(dtos, nextCursor, prevCursor, nextCursor != null, prevCursor != null,
dtos.size());
}
return new PageResponse<>(dtos, null, null, false, decoded != null, 0);
}
if ("newest".equalsIgnoreCase(sortBy)) {
Long ts = decoded == null ? null : decoded.k1();
Long id = decoded == null ? null : decoded.k2();
List<Product> products = repo.pageByNewest(category, ts, id, forward, limit + 1);
boolean hasMore = products.size() > limit;
if (hasMore) {
products = products.subList(0, limit);
}
for (Product p : products) {
long k1 = p.createdAt.getEpochSecond();
String itemCursor = codec.encode(new CursorPayload(
"newest", safe(category), safe(q), safe(direction), k1, p.id));
dtos.add(toDTO(p, itemCursor));
}
if (!dtos.isEmpty()) {
prevCursor = flipDirection(dtos.get(0).cursor());
nextCursor = hasMore ? dtos.get(dtos.size() - 1).cursor() : null;
}
return new PageResponse<>(dtos, nextCursor, prevCursor, nextCursor != null, decoded != null && forward,
dtos.size());
}
Long vc = decoded == null ? null : decoded.k1();
Long id = decoded == null ? null : decoded.k2();
List<Product> products = repo.pageByPopularity(category, vc, id, forward, limit + 1);
boolean hasMore = products.size() > limit;
if (hasMore) {
products = products.subList(0, limit);
}
for (Product p : products) {
long k1 = p.viewCount.longValue();
String itemCursor = codec.encode(new CursorPayload(
"popularity", safe(category), safe(q), safe(direction), k1, p.id));
dtos.add(toDTO(p, itemCursor));
}
if (!dtos.isEmpty()) {
prevCursor = flipDirection(dtos.get(0).cursor());
nextCursor = hasMore ? dtos.get(dtos.size() - 1).cursor() : null;
}
return new PageResponse<>(dtos, nextCursor, prevCursor, nextCursor != null, decoded != null && forward,
dtos.size());
}
private static String safe(String s) {
return s == null ? "" : s;
}
private ProductDTO toDTO(Product p, String cursor) {
return new ProductDTO(
p.id,
p.name,
p.description,
p.category,
p.price,
p.viewCount,
p.createdAt == null ? null : p.createdAt.toString(),
p.imageUrl,
cursor);
}
private boolean hasBackCursor(String sort, String category, String q, String cursor) {
return cursor != null && !cursor.isBlank();
}
private String flipDirection(String cursor) {
CursorPayload payload = codec.decode(cursor);
String flipped = "prev".equalsIgnoreCase(payload.direction()) ? "next" : "prev";
return codec.encode(new CursorPayload(
payload.sort(), payload.category(), payload.q(), flipped, payload.k1(), payload.k2()));
}
@POST
@Path("/seed")
@Transactional
public String seed(@QueryParam("count") @DefaultValue("5000") int count) {
repo.deleteAll();
String[] categories = { "Electronics", "Books", "Clothing", "Home", "Sports" };
String[] adjectives = { "Premium", "Budget", "Luxury", "Essential", "Professional" };
String[] items = { "Widget", "Gadget", "Tool", "Device", "Kit" };
String[] phrases = {
"wireless noise cancelling battery life",
"durable lightweight travel friendly",
"ergonomic adjustable premium materials",
"high performance professional grade",
"compact minimalist modern design"
};
for (int i = 0; i < count; i++) {
Product p = new Product();
p.name = adjectives[i % adjectives.length] + " " + items[i % items.length] + " " + i;
p.description = "A " + phrases[i % phrases.length] + " product designed for "
+ categories[i % categories.length] + " lovers.";
p.category = categories[i % categories.length];
p.price = 10.0 + (i % 500);
p.viewCount = (int) (Math.random() * 10000);
p.createdAt = Instant.now().minusSeconds(i * 37L);
p.imageUrl = "https://example.com/image" + i + ".jpg";
p.persist();
}
return "Seeded " + count + " products";
}
}This resource is intentionally strict. If you take a cursor from q=wireless and try to reuse it for q=ergonomic, you get a hard rejection. If you try to change direction without a matching cursor, you do not get a weird “almost works” query. That’s what it means to treat pagination as a contract instead of a convenience.
The Qute Page That Serves a Real Infinite Scroll Component
Now we make it feel like the product. This is where cursor pagination becomes real because browsers do what users do: they scroll, they back-button, and they type into search at the worst possible moment.
The Page Resource
Create src/main/java/com/example/web/CatalogPage.java:
package com.example.web;
import io.quarkus.qute.Template;
import io.quarkus.qute.TemplateInstance;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path("/catalog")
public class CatalogPage {
private final Template catalog;
public CatalogPage(Template catalog) {
this.catalog = catalog;
}
@GET
@Produces(MediaType.TEXT_HTML)
public TemplateInstance page() {
return catalog.instance();
}
}The Template
Create src/main/resources/templates/catalog.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Product Catalog</title>
<style>
<!-- omitted check the repo -->
</style>
</head>
<body>
<header>
<div class="row">
<strong>Catalog</strong>
<input id="q" placeholder="Search (e.g. wireless battery)" size="30" />
<select id="category">
<option value="">All categories</option>
<option>Electronics</option>
<option>Books</option>
<option>Clothing</option>
<option>Home</option>
<option>Sports</option>
</select>
<select id="sortBy">
<option value="popularity">Popularity</option>
<option value="newest">Newest</option>
<option value="relevance">Relevance (requires search)</option>
</select>
<button id="apply">Apply</button>
</div>
</header>
<main>
<div class="controls">
<button id="loadPrev" disabled>Load previous</button>
<div class="muted" id="status">Idle</div>
</div>
<div class="grid" id="grid"></div>
<div class="sentinel" id="sentinel"></div>
</main>
<script type="module" src="/static/catalog.js"></script>
</body>
</html>The Infinite Scroll JavaScript
Create src/main/resources/META-INF/resources/static/catalog.js:
const grid = document.querySelector("#grid");
const sentinel = document.querySelector("#sentinel");
const statusEl = document.querySelector("#status");
const qEl = document.querySelector("#q");
const categoryEl = document.querySelector("#category");
const sortByEl = document.querySelector("#sortBy");
const applyBtn = document.querySelector("#apply");
const loadPrevBtn = document.querySelector("#loadPrev");
let nextCursor = null;
let prevCursor = null;
let loading = false;
function paramsBase() {
const q = qEl.value.trim();
const category = categoryEl.value.trim();
const sortBy = sortByEl.value;
const p = new URLSearchParams();
if (q) p.set("q", q);
if (category) p.set("category", category);
p.set("sortBy", sortBy);
return p;
}
function card(product) {
const el = document.createElement("div");
el.className = "card";
el.innerHTML = `
<div><strong>${escapeHtml(product.name)}</strong></div>
<div class="muted">${escapeHtml(product.category)} · views ${product.viewCount ?? 0}</div>
<div class="muted">${escapeHtml(product.description ?? "")}</div>
`;
return el;
}
function escapeHtml(s) {
return String(s).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
}
async function load(direction) {
if (loading) return;
loading = true;
try {
statusEl.textContent = direction === "prev" ? "Loading previous..." : "Loading more...";
const p = paramsBase();
p.set("direction", direction);
// When using a cursor, we need to extract the sort method from it
// because the backend might have switched to relevance sorting for search queries
let cursorToUse = null;
if (direction === "next" && nextCursor) {
cursorToUse = nextCursor;
}
if (direction === "prev" && prevCursor) {
cursorToUse = prevCursor;
}
if (cursorToUse) {
p.set("cursor", cursorToUse);
// Extract sort from cursor by decoding the base64 payload
// The cursor format is: base64(iv + encrypted(json))
// We can't decrypt it, but the backend will handle sort detection
// For now, we'll rely on the backend to use the cursor's sort method
}
const res = await fetch(`/api/products?${p.toString()}`);
if (!res.ok) {
const txt = await res.text();
console.error(`API error: ${res.status}`, txt);
statusEl.textContent = `Error: ${txt}`;
throw new Error(`API error: ${res.status} ${txt}`);
}
const page = await res.json();
nextCursor = page.nextCursor || null;
prevCursor = page.prevCursor || null;
loadPrevBtn.disabled = !page.hasPrev;
if (direction === "prev") {
const nodes = page.data.map(card);
for (let i = nodes.length - 1; i >= 0; i--) {
grid.prepend(nodes[i]);
}
statusEl.textContent = "Ready";
} else {
page.data.map(card).forEach(n => grid.appendChild(n));
statusEl.textContent = page.hasNext ? "Ready" : "End reached";
}
} finally {
loading = false;
}
}
function resetAndLoad() {
grid.innerHTML = "";
nextCursor = null;
prevCursor = null;
loadPrevBtn.disabled = true;
load("next");
}
applyBtn.addEventListener("click", resetAndLoad);
loadPrevBtn.addEventListener("click", () => load("prev"));
const observer = new IntersectionObserver(entries => {
const hit = entries.some(e => e.isIntersecting);
if (hit && nextCursor !== null) {
load("next");
}
}, { rootMargin: "200px" });
observer.observe(sentinel);
resetAndLoad();This is intentionally not React. The goal is to show that the server can ship a working infinite scroll experience from a Qute page without pulling in a frontend build pipeline. When you do want React later, this API contract survives the transition because the hard part was never the component. It was the cursor.
Production Hardening Where the Paper Cuts Actually Live
Full-text search is the first place teams accidentally reintroduce offset behavior, because ranking makes developers feel like ordering is “soft.” PostgreSQL does not treat it softly. Without a GIN index over your vector expression, the database has no choice but to work harder as your dataset grows. The moment you combine search predicates with deep scroll, the difference between indexed “seek” and a scan is the difference between a stable SLA and a midnight rollback.
Bidirectional pagination is where concurrency bites you. New rows can appear between requests, ranks can shift, and your users can still have a coherent experience as long as your keyset comparisons stay monotonic. That is why we always keep an explicit tiebreaker key (id) alongside the primary key (viewCount, createdAt, or rankScaled). Without it, “previous page” turns into duplicates and gaps under load, and you end up “fixing” it by caching entire pages in memory.
Cursor encryption is not about secrecy. It’s about integrity. If a client can alter the ordering keys, they can force pathological query paths, pin your database on hot ranges, or scrape your catalog in ways your UI never would. AES-GCM gives you the property you actually want: either the cursor is exactly what you issued for that request context, or it is rejected.
The trade-off in this narrowly scoped example is that the “relevance” cursor is tied to the current ranking algorithm and language configuration. If you change ranking behavior, old cursors will stop working, and that is acceptable because cursor pagination is an interaction contract, not a stable public identifier.
Verification With Real Calls and Expected Output
Start Quarkus dev mode:
quarkus devSeed the database:
curl -X POST "http://localhost:8080/api/products/seed?count=20000"Expected output:
Seeded 20000 productsFetch the first page:
curl "http://localhost:8080/api/products?limit=5" | jqExpected shape:
],
"nextCursor": "TpalYp_GZGvkA9-FuhXZ0qUj9OGPeuKQv6ua40neh9J0e3Y7J6DDNo8C9mQqp5s84YWtbfQ5pj3-dILu_C2DcG0PSJf4XRe0StGj8XfXtfAfcZhYdq8NmBYA7FMO89xu8eTHttUDkZMpQVkrUvc",
"prevCursor": "Iuke-JJgNtKCFi1VAtQ6WJf301Q3zIDsaMEHJSG_1pMEsL9RZ1SgZOzpQx7IfiYXv522iqWbXWa5uzhwkgj5cpV35f2QTUZ1SOqSV33QzqXg2wcPzlex3m69rYUPq03f_5eWe7HOFDBs-3aVfA",
"hasNext": true,
"hasPrev": false,
"count": 5
}Search with relevance:
curl "http://localhost:8080/api/products?q=wireless%20battery&sortBy=relevance&limit=5" | jqExpected behavior: results come back quickly, nextCursor is present, and the cursor is opaque. If you try to tamper with the cursor string, the next call fails with 400 and Invalid cursor.
Open the UI:
open http://localhost:8080/catalogExpected behavior: products render, scrolling loads more automatically, and the “Load previous” button becomes enabled after at least one forward page.
Conclusion
We took the original infinite scroll catalog and pushed it into the territory where real systems start to wobble: search relevance, back pagination, and client behavior you don’t control. The database still seeks, the cursors are now capabilities instead of hints, and the UI is simple enough to ship as one Quarkus service.
If your catalog can search and scroll without leaking its internals, it’s finally ready to meet your users.





Solid walkthrough on a problem that usually gets hand-waved. The rank scaling trick is clever becasue float comparisons in cursors are basically asking for subtle bugs when results shift betwen pages. I ran into something similiar building an e-commerce search where relevance scores kept drifting and users complained about seeing dupes.