Server-Side Paging for Vaadin Grid with CallbackDataProvider
When a Vaadin Grid must display thousands of rows, switching from ListDataProvider to CallbackDataProvider enables server-side paging—but the count callback and sorting logic demand careful implementation.
03 Apr 2026, 05:36 UTC

Youre building a Vaadin Grid that must display 50000 rows. Loading them all into the browser crashes tabs and drags initial page load. The takeaway: use CallbackDataProvider for server-side lazy loading, but wire the fetch and count callbacks deliberately.
How CallbackDataProvider Works
When a Grid is backed by DataProvider.fromCallbacks, it never materializes the full row set. Instead, every scroll, sort, or filter triggers the fetch callback, which receives a Query object. The callback reads query.getOffset() and query.getLimit() and returns exactly that many rows—typically by issuing a SELECT ... LIMIT ? OFFSET ? against the database.
Fetch Callback Example
// Java Vaadin Flow
DataProvider dataProvider = DataProvider.fromCallbacks(
query -> {
int offset = query.getOffset();
int limit = query.getLimit();
// Example: personRepository.findAll(PageRequest.of(offset, limit))
return personRepository.findAll(...).getContent();
},
countQuery -> {
return personRepository.countTotal();
}
);
grid.setDataProvider(dataProvider);
Count Callback Pitfall
The count callback runs on every scroll, sort, and filter. On a 10-million-row table, a SELECT COUNT(*) can freeze the UI. Cache the count, use an approximate statistic, or materialize a total column when exactness is not critical.
Sorting and Filtering Reach the Backend
Unlike ListDataProvider, sorting and filtering do not happen automatically. The fetch callback must inspect query.getSortOrders() and apply a filter parameter if you have threaded one through your constructor. Here is a minimal sorting check:
query.getSortOrders().forEach(order -> {
// translate Vaadin sort direction to SQL ORDER BY
});
Trade-offs vs ListDataProvider
- ListDataProvider: loads entire collection into server heap, sends all rows eagerly. Fine for fewer than 500 rows. Beyond that, memory pressure per concurrent UI session is severe.
- CallbackDataProvider: only the visible slice resides in memory. Initial page load is fast, scroll feels snappy, and server heap scales with concurrent users, not row count. The cost is added implementation complexity for count accuracy, sorting, and filter threading.
Verification Without Noise
- Add a log statement inside the fetch callback:
log.debug('fetch offset={}, limit={}', query.getOffset(), query.getLimit()). Scroll the Grid and confirm only small ranges (e.g., 0–24, 25–49) are requested. - Enable SQL logging (e.g.,
spring.jpa.show-sql=trueor your backend query logger). Verify that each scroll generates aSELECT ... LIMIT ? OFFSET ?plus a separateSELECT COUNT(*), not a full-table scan. - Compare initial load time and heap usage between a Grid using ListDataProvider and one with CallbackDataProvider on a dataset of 100k rows. You will see the lazy provider start instantly and keep memory flat.
When Keyset Pagination Makes Sense
If your users frequently jump to deep pages (e.g., page 500 of 1000), offset-based paging can show duplicates or skip rows if the underlying data changes between fetches. Keyset pagination—filtering by a stable last seen ID—avoids this but requires custom handling outside the standard Query object.
Vaadin Flow keeps UI state per open browser tab, so even with lazy data, memory planning must account for concurrent sessions. A rule of thumb: keep the per-row footprint low and profile your count query.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.