Cassandra & JHipster

Paginating Apache Cassandra the Right Way: Paging State, Not Page Numbers

Apache Cassandra does not support OFFSET, and it never will — so page-number pagination (“give me page 7 of 20”) is an anti-pattern on Cassandra. The correct approach is cursor-based pagination using the driver's paging state: an opaque token that marks where the last page ended, which you hand back to Cassandra to fetch the next page. In Spring Boot, that means Slice + CassandraPageRequest instead of Page + PageRequest. This article shows the full production pattern — repository, REST endpoint, and UI — as implemented in my open-source JHipster blueprint, generator-jhipster-cassandra, which generates this exact code for every Cassandra entity.

Why page numbers don't work on Cassandra

Relational databases can serve “page 7” because they can count and skip rows cheaply within a single ordered structure. Cassandra can't, by design. Your rows are distributed across a cluster by partition key; there is no global row order to skip into, and no OFFSET in CQL. The only way to emulate “skip the first 120 rows” is to read those 120 rows and throw them away — on every request, with the waste growing linearly as users page deeper.

The same goes for the page-count UI itself: rendering “page 7 of 20” requires count(*), and a full-table count on Cassandra is a cluster-wide scan — expensive enough that it can time out on large tables. If your pagination design needs a total count and random page access, it is fighting the database instead of using it.

Cassandra's native answer is different: every query result can carry a paging state — an opaque byte token that encodes where the result set left off. Hand it back with your next query and Cassandra resumes exactly there, at constant cost, no matter how deep you are. It's a cursor, not a page number.

The Spring Data model: Slice, not Page

Spring Data Cassandra models this correctly out of the box — if you use the right types. Page promises a total count (getTotalElements()), which is exactly what Cassandra can't provide cheaply. Slice promises only “here are the rows, and whether there's a next slice” — which is exactly what paging state gives you. CassandraRepository.findAll(Pageable) already returns a Slice, so the repository layer needs nothing custom:

public interface PostRepository extends CassandraRepository<Post, PostId> {
    // Inherited from CassandraRepository — already Slice-based:
    // Slice<Post> findAll(Pageable pageable);
}

The service just forwards the Pageable and maps to DTOs:

public Slice<PostDTO> findAllSlice(Pageable pageable) {
    return postRepository.findAll(pageable).map(postMapper::toDto);
}

All the real work is in constructing that Pageable — and that's where CassandraPageRequest comes in.

The REST endpoint: paging state over HTTP

The paging state is a ByteBuffer, so to move it through a REST API you Base64-encode it. The pattern I ship in generator-jhipster-cassandra exposes a dedicated /slice endpoint: the client sends no token for the first page, and echoes back the X-Paging-State response header to get each subsequent page.

@GetMapping("/slice")
public ResponseEntity<List<PostDTO>> getAllPostsSlice(
        @RequestParam(name = "pagingState", required = false) String pagingState,
        @RequestParam(name = "size", defaultValue = "20") int size) {

    Pageable pageRequest;
    if (pagingState == null || pagingState.isEmpty()) {
        pageRequest = CassandraPageRequest.first(size);
    } else {
        ByteBuffer state = ByteBuffer.wrap(Base64.getUrlDecoder().decode(pagingState));
        pageRequest = CassandraPageRequest.of(PageRequest.of(0, size), state);
    }

    Slice<PostDTO> slice = postService.findAllSlice(pageRequest);

    HttpHeaders headers = new HttpHeaders();
    headers.add("X-Has-Next-Page", String.valueOf(slice.hasNext()));

    if (slice.hasNext() && slice.nextPageable() instanceof CassandraPageRequest next
            && next.getPagingState() != null) {
        ByteBuffer state = next.getPagingState();
        byte[] bytes = new byte[state.remaining()];
        state.duplicate().get(bytes);
        headers.add("X-Paging-State", Base64.getUrlEncoder().encodeToString(bytes));
    }
    headers.add("Access-Control-Expose-Headers", "X-Has-Next-Page, X-Paging-State");

    return ResponseEntity.ok().headers(headers).body(slice.getContent());
}

A few details in there are the difference between a demo and production code:

  • CassandraPageRequest.first(size) starts the cursor. For subsequent pages, CassandraPageRequest.of(PageRequest.of(0, size), state) resumes it — note the page number is always 0; it's meaningless with a cursor and only the token matters.
  • URL-safe Base64 (getUrlEncoder/getUrlDecoder): standard Base64 uses + and /, which get mangled in query strings. In the blueprint I also fall back to the standard decoder for tolerance, but URL-safe should be the wire format.
  • duplicate() before reading the buffer: extracting bytes moves the ByteBuffer position; duplicating first keeps the original token intact.
  • Access-Control-Expose-Headers: without it, browsers silently hide your custom headers from cross-origin JavaScript, and the client can never see the token. This one costs people hours.
  • X-Has-Next-Page from slice.hasNext(): the client needs an explicit signal to stop — checking “did I get fewer rows than I asked for” is not reliable, because Cassandra may return a short (or even empty) page with a next page still to come.

The UI consequence: “Load More”, not numbered pages

Cursor pagination changes what you can honestly offer users. There is no jumping to page 7, and no “page 7 of 20” — you'd need the count Cassandra can't cheaply give. The UI that matches the database is a Load More button (or infinite scroll): render the first slice, keep the latest token, append the next slice on demand, and hide the button when X-Has-Next-Page comes back false.

In the Angular frontend the blueprint generates, I deliberately chose a Load More button over automatic infinite scroll: it gives users control, plays better with footers and accessibility, and avoids the classic bug where an empty-but-not-last page retriggers the scroll handler forever. That last one is worth repeating: always gate the next fetch on the has-next signal, never on whether the last response was empty.

Gotchas worth knowing

  • The token is opaque and ephemeral. Treat paging state as a resume token, not a bookmark: it's tied to the query that produced it and isn't guaranteed valid across schema changes or indefinitely across time. Don't persist it, don't put it in permalinks. (This is also why the endpoint above falls back to first(size) when a token fails to decode.)
  • Don't add count() endpoints for convenience. I removed the generated count queries from the blueprint's Cassandra REST templates entirely — an endpoint that scans the cluster so a UI can print “of 20” is a production incident on a timer.
  • Page size is a hint, not a contract. Cassandra may return slightly fewer rows per page in some conditions; design the client around the token and the has-next flag, not around exact page sizes.
  • Filtering and paging compose fine as long as the filter is a proper partition/clustering-key query. Paging state works per-query — changing the filter means starting a new cursor from first().

Get all of this generated for free

Everything above ships as generated code in generator-jhipster-cassandra, my open-source JHipster 9 blueprint for Apache Cassandra: the /slice endpoint, the token handling, the Load More UI, plus composite primary keys, SET/MAP collections, and Cassandra 5.0 SAI/ANN vector search. It's the same blueprint that generates the Cassandra microservices in Saathratri, so the patterns here are running in production, not just in a README. Install it with npm install -g generator-jhipster-cassandra, or read the source on GitHub.

FAQ

Why doesn't Apache Cassandra support OFFSET?

Because rows are distributed across the cluster by partition key, there is no single ordered list to skip into. Supporting OFFSET would force Cassandra to read and discard all skipped rows on every request, which contradicts its constant-time, scale-out design. Cursor-based paging state is the deliberate alternative, not a missing feature.

What is a Cassandra paging state token?

An opaque byte sequence the driver returns with a query result, encoding where the result set stopped. Passing it back with the same query makes Cassandra resume from that exact position. Over HTTP it's typically Base64-encoded; in Spring Data Cassandra it's carried by CassandraPageRequest and exposed via Slice.nextPageable().

What's the difference between Page and Slice in Spring Data?

Page extends Slice and adds total-count information (getTotalElements(), getTotalPages()), which requires a count query. Slice only knows its content and whether a next slice exists — a perfect match for Cassandra, where counting is expensive but “is there more?” is free. On Cassandra, always prefer Slice.

Can I show users the total number of results?

Not cheaply from Cassandra itself. If a total truly matters, maintain a counter column or a summary table updated on write, or accept an approximate/deferred count from an analytics path. For most list UIs, “Load More” with a has-next signal serves users just as well — and never times out.