O cliente pede a primeira página com offset=0 e limit=2 e recebe os itens 1 e 2. Antes da segunda chamada, alguém insere um item novo no começo da lista. Aí o cliente pede offset=2 e limit=2, e a resposta volta sem erro, com os itens 2 e 3. O 2 ele já tinha recebido. O 4 ele não vai receber nunca, porque cliente de paginação não costuma voltar uma página para conferir.
Neste exemplo não tem servidor HTTP. O "200" é só uma analogia: o helper Java recebe a lista, devolve uma página e não lança exceção. E é isso que deixa o bug chato, porque nada quebra. A página 2 chega com status de sucesso e repete um item que a página 1 já tinha mostrado.
No final, você vai ter visto:
- a duplicata e o salto reproduzidos com uma
ArrayListe duas chamadas por offset; - por que offset amarra a página à posição do item e não à identidade dele;
- uma implementação de keyset (cursor pelo último id visto) em que a próxima página continua
[3, 4]depois do mesmo insert; - os casos em que offset ainda é a escolha certa.
Ambiente e versões
- JDK Temurin 25.0.4 (
25.0.4+7-LTS),maven.compiler.release=25, sem--enable-preview. - Maven 3.9.12,
maven-compiler-plugin3.14.0,maven-surefire-plugin3.6.0. junit-jupiter6.1.3, com escopo de teste.- Não tem framework web nem banco de dados. Todo o código fica no pacote
devdojo.pagination.
Se você for procurar um Pageable no JDK, não vai achar um de paginação. O Java SE 25 só tem java.awt.print.Pageable, que serve para impressão e não tem nada a ver com o do Spring Data. Por isso os dois helpers deste exemplo são escritos à mão.
O pom.xml precisa só disto:
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.release>25</maven.compiler.release>
</properties>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>6.1.3</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.14.0</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.6.0</version>
</plugin>
</plugins>
</build>
O modelo e o offset via subList
O item é só um record:
package devdojo.pagination;
public record Item(long id, String name) {}
A paginação por offset faz o mesmo que um LIMIT/OFFSET, só que sobre uma List:
package devdojo.pagination;
import java.util.List;
public final class Pages {
private Pages() {}
public static List<Item> offsetPage(List<Item> list, int offset, int limit) {
if (offset < 0 || limit < 0) {
throw new IllegalArgumentException("offset and limit must be >= 0");
}
int from = Math.min(offset, list.size());
int to = Math.min(offset + limit, list.size());
return List.copyOf(list.subList(from, to));
}
// keysetPage logo abaixo
}
O List.copyOf em volta do subList está ali de propósito. O subList não é uma cópia, é uma view da lista original. Segundo o javadoc de List.subList no Java 25), a semântica dessa view fica indefinida se a lista original sofrer modificação estrutural por outro caminho. Se você devolvesse a view sem copiar, a página que já foi entregue poderia mudar depois por causa de alterações feitas na lista de origem.
Veja o que o método consulta: offset, limit e size(). O id dos itens não aparece em lugar nenhum. A página é uma janela de índices e não tem nenhuma relação com quais itens estão dentro dela. O banco funciona igual. A documentação do PostgreSQL 18 diz que "OFFSET says to skip that many rows before beginning to return rows", e as linhas puladas são as do resultado atual, não as que o cliente viu na chamada anterior.
Reproduzindo: o 2 aparece duas vezes e o 4 desaparece
A lista começa com [1, 2, 3, 4, 5]. Entre as duas páginas, o teste insere o item 6 no índice 0:
@Test
void offset_insertAtZero_duplicatesPreviousItem() {
List<Item> list = seed();
List<Item> page0 = Pages.offsetPage(list, 0, 2);
assertEquals(List.of(1L, 2L), ids(page0));
list.add(0, new Item(6, "new-front"));
List<Item> offsetPage1 = Pages.offsetPage(list, 2, 2);
System.out.println("page0=" + ids(page0));
System.out.println("offset-page1-after-insert=" + ids(offsetPage1));
// Lista viva agora é [6,1,2,3,4,5]; offset 2/limit 2 é posicional: [2,3].
// Analogia do HTTP 200: devolveu uma lista, nenhuma exceção.
assertEquals(List.of(2L, 3L), ids(offsetPage1));
List<Long> seen = new ArrayList<>(ids(page0));
seen.addAll(ids(offsetPage1));
assertTrue(seen.size() != new HashSet<>(seen).size(), "id 2 duplicated");
assertTrue(!seen.contains(4L), "original id 4 skipped on the first two pages");
}
Os helpers seed() e ids(...) são estes:
private static List<Item> seed() {
List<Item> list = new ArrayList<>();
list.add(new Item(1, "one"));
list.add(new Item(2, "two"));
list.add(new Item(3, "three"));
list.add(new Item(4, "four"));
list.add(new Item(5, "five"));
return list;
}
private static List<Long> ids(List<Item> page) {
return page.stream().map(Item::id).toList();
}
A saída que o Surefire registrou foi:
page0=[1, 2]
offset-page1-after-insert=[2, 3]
O que aconteceu é simples. Depois do insert, a lista ficou [6, 1, 2, 3, 4, 5] e todos os itens andaram uma posição para a direita. O offset 2 continua pegando os índices 2 e 3, só que agora quem está neles são os ids 2 e 3. O OFFSET 2 não quer dizer "depois do item 2", quer dizer "pule dois, sejam eles quais forem agora". Ele conta linhas sem saber quais são.
Markus Winand explica isso direto em Use The Index, Luke: no-offset. Um insert entre as páginas faz o OFFSET devolver duplicatas, e o resumo dele é: "The idea to use the number of rows seen to skip over them later is simply wrong."
A duplicata pelo menos aparece na tela e alguém acaba reclamando. O salto passa sem ninguém perceber: o item 4 simplesmente não vem em nenhuma das duas páginas, e ninguém vai abrir um chamado por causa de um item que nunca viu.
Keyset: a próxima página começa depois do último id visto
A correção é trocar "quantos itens já vi" por "qual foi o último item que vi". O cursor é o id do último item da página anterior. O helper procura esse item na lista e devolve os que vêm depois dele:
public static List<Item> keysetPage(List<Item> list, long afterId, int limit) {
if (limit < 0) {
throw new IllegalArgumentException("limit must be >= 0");
}
int start = 0;
if (afterId != 0L) {
start = -1;
for (int i = 0; i < list.size(); i++) {
if (list.get(i).id() == afterId) {
start = i + 1;
break;
}
}
if (start < 0) {
throw new IllegalArgumentException("unknown cursor id " + afterId);
}
}
if (start >= list.size()) {
return List.of();
}
int to = Math.min(start + limit, list.size());
return List.copyOf(list.subList(start, to));
}
O helper parte de duas premissas: a ordem da lista é a ordem de paginação (como uma ordenação por inserção ou por created_at), e os ids são únicos. Um afterId == 0 significa começar do início. Se o id do cursor não estiver na lista, o helper lança exceção. A alternativa seria voltar silenciosamente para a primeira página, e a gente prefere a exceção.
O atalho que reintroduz o bug
A versão mais óbvia de keyset seria algo como "pegue os primeiros N itens com id > afterId, na ordem atual da lista". Nesse exemplo ela dá errado. O item 6 tem o id maior de todos, mas foi inserido na frente. Na lista [6, 1, 2, 3, 4, 5], um filtro id > 2 aplicado na ordem da lista pega o 6 primeiro, e o item novo vai parar na página 2. O sintoma muda, mas o problema é o mesmo.
No SQL, o seek funciona porque o predicado e o ORDER BY usam a mesma chave. Winand mostra isso em fetch-next-page, com (sale_date, sale_id) < (?, ?) e a ordenação por essas mesmas colunas. Na lista em memória, a ordem é dada pela posição e não pelo id. Por isso o keysetPage procura a linha do cursor e anda a partir dela, em vez de comparar ids.
A mesma página do Winand também avisa que a chave do cursor precisa ser única. Um cursor só por timestamp volta a duplicar ou pular itens quando dois registros têm o mesmo valor. No exemplo isso não acontece porque o id é único.
O mesmo insert com keyset
@Test
void keyset_insertAtZero_nextPageUnchanged() {
List<Item> list = seed();
List<Item> page0 = Pages.offsetPage(list, 0, 2);
long afterId = page0.getLast().id();
list.add(0, new Item(6, "new-front"));
List<Item> keysetPage1 = Pages.keysetPage(list, afterId, 2);
System.out.println("page0=" + ids(page0));
System.out.println("keyset-page1-after-insert=" + ids(keysetPage1));
assertEquals(List.of(1L, 2L), ids(page0));
assertEquals(List.of(3L, 4L), ids(keysetPage1));
List<Long> seen = new ArrayList<>(ids(page0));
seen.addAll(ids(keysetPage1));
assertEquals(List.of(1L, 2L, 3L, 4L), seen);
assertEquals(seen.size(), new HashSet<>(seen).size());
}
A saída foi:
page0=[1, 2]
keyset-page1-after-insert=[3, 4]
O item 6 entrou antes do cursor. Como o cursor aponta para o item 2 e não para a posição 2, a próxima página continua sendo o que vem depois do 2.
As quatro sequências observadas
Rodando mvn -q test no fixture, o relatório TEST-devdojo.pagination.OffsetVsKeysetTest.xml do Surefire registrou tests=5, failures=0, errors=0, skipped=0. As sequências de cada cenário foram:
| cenário | page0 | page1 |
|---|---|---|
| controle, sem mutação | [1, 2] | offset e keyset [3, 4] |
insert no índice 0 (id=6), offset 2 | [1, 2] | [2, 3] (duplica o 2, pula o 4) |
| mesmo insert, keyset com afterId=2 | [1, 2] | [3, 4] |
snapshot List.copyOf, depois altera a lista viva | [1, 2] | [3, 4] |
A primeira linha explica por que o bug passa em teste manual. Enquanto a lista não muda, posição e identidade coincidem, e offset e keyset devolvem a mesma coisa.
Quando offset ainda é a melhor escolha
Sobre um snapshot imutável, offset funciona. O problema não é o offset em si, é aplicar offset numa lista que muda entre as chamadas. O javadoc de List.copyOf) garante que, se a coleção original for modificada depois, a lista copiada não reflete essas mudanças:
@Test
void offset_onImmutableSnapshot_isStable() {
List<Item> live = seed();
List<Item> snapshot = List.copyOf(live);
List<Item> page0 = Pages.offsetPage(snapshot, 0, 2);
live.add(0, new Item(6, "new-front"));
List<Item> page1 = Pages.offsetPage(snapshot, 2, 2);
assertEquals(List.of(1L, 2L), ids(page0));
assertEquals(List.of(3L, 4L), ids(page1));
assertEquals(List.of(6L, 1L, 2L, 3L, 4L, 5L), ids(live));
}
A lista viva passou a ser [6, 1, 2, 3, 4, 5], e o snapshot continuou devolvendo [1, 2] e depois [3, 4].
Páginas numeradas precisam de offset. Uma tela de admin que mostra "página 7 de 40" e deixa o usuário pular direto para a página 7 não funciona com seek. Winand diz isso explicitamente: com keyset não dá para buscar uma página arbitrária. E o cursor também não traz o total de itens. Com keyset você descobre se existe próxima página, mas o total continua vindo de um size() ou de um COUNT:
@Test
void cursorDoesNotReplaceTotalCount() {
List<Item> list = seed();
List<Item> page = Pages.keysetPage(list, 0L, 2);
assertEquals(List.of(1L, 2L), ids(page));
int totalCount = list.size();
boolean hasMore = !Pages.keysetPage(list, page.getLast().id(), 1).isEmpty();
assertEquals(5, totalCount);
assertTrue(hasMore);
}
No caso de SQL, a documentação do PostgreSQL também lembra que as linhas puladas pelo OFFSET continuam sendo computadas, e que um OFFSET grande pode ser ineficiente. Este artigo não mediu isso, então fica só como observação.
Recomendação
A DevDojo usaria offset só em dois casos: sobre um snapshot imutável, ou numa UI que precisa mostrar "página 7 de 40", com o total vindo de um COUNT à parte. Para um feed ou uma API em que o cliente vai paginando enquanto a lista muda, a escolha é keyset, com cursor por uma chave única e monotônica.
O sinal de que é hora de abandonar o offset: você não consegue dizer qual linha é o "offset 20" sem contar tudo de novo desde o começo.
Próximo passo
Se a sua coleção pode receber itens no começo entre uma requisição e outra, pare de devolver page=2 para o cliente. Devolva o id do último item da página como cursor, ou tire um snapshot com List.copyOf e pagine em cima dele. Depois escreva o teste deste artigo para o seu caso: pegue a primeira página, insira um item no índice 0, pegue a segunda página e verifique que nenhum id apareceu duas vezes e que nenhum ficou de fora.