Vaadin Grid Server-Side Pagination for Large JPA Datasets
Vaadin Grid server-side pagination with CallbackDataProvider keeps heap usage flat for large JPA datasets by fetching only visible rows.
27 Feb 2026, 11:03 UTC

The Problem: Heap Exhaustion When Grid Loads Entire Datasets
Vaadin Grid loads all items into server memory by default. If a service screen displays 200,000 customer records, the JVM must hold every entity instance in the UI session heap. Under concurrent users, this causes OutOfMemoryError and long garbage-collection pauses. The practical fix is to fetch only the rows currently visible in the viewport.
Useful Takeaway
Bind the Grid to a CallbackDataProvider backed by Spring Data JPA. The provider requests one page at a time, so memory stays flat while the database handles offset, limit, sorting, and counting.
Requirements
- Constant heap usage as the user scrolls through hundreds of thousands of rows
- Server-side pagination triggered by Grid scrolling and column sorting
- Reuse existing Spring Data JPA repositories without duplicating query logic
- Accurate row count for scrollbar position and keyboard navigation
Smallest Suitable Design
Use Vaadin 24 LTS with Spring Boot. The Grid calls setDataProvider() with a callback that converts Vaadin Query objects into Spring Data Pageable requests. The repository returns a page of entities, and a separate count method supplies the total.
Example Service and Grid Binding
Run this code in the application backend module. It requires Java 17 or later, Spring Boot 3.x, and Vaadin 24 LTS.
@Service
@Transactional(readOnly = true)
public class CustomerDataService {
private final CustomerRepository repository;
public CustomerDataService(CustomerRepository repository) {
this.repository = repository;
}
public DataProvider createDataProvider() {
return CallbackDataProvider.fromCallbacks(
this::fetchPage,
this::countTotal
);
}
private Stream fetchPage(Query query) {
int page = query.getOffset() / query.getLimit();
Pageable pageable = PageRequest.of(
page,
query.getLimit(),
extractSort(query)
);
return repository.findAll(pageable).stream();
}
private long countTotal(Query query) {
return repository.count();
}
private Sort extractSort(Query query) {
List orders = new ArrayList<>();
query.getSortOrders().forEach(order -> {
Sort.Direction direction = order.getDirection().equals(SortDirection.ASCENDING)
? Sort.Direction.ASC : Sort.Direction.DESC;
orders.add(new Sort.Order(direction, order.getSorted()));
});
return Sort.by(orders);
}
}
@Component
public class CustomerGrid extends Grid {
public CustomerGrid(CustomerDataService dataService) {
addColumn(Customer::getId).setHeader("ID");
addColumn(Customer::getName).setHeader("Name");
addColumn(Customer::getEmail).setHeader("Email");
setDataProvider(dataService.createDataProvider());
}
}
Permissions: The service runs with the application's datasource credentials. No special OS permissions are required, but the database user needs SELECT access on the customer table and any joined tables.
Expected checks: After deployment, open the browser developer tools Network tab while scrolling. Each scroll batch should produce one lightweight XHR to the Vaadin UIDL endpoint, not a single payload containing all rows.
Trust and Data Boundaries
The CallbackDataProvider is the trust boundary between UI interaction and database execution:
- Browser: Sends viewport offset, limit, and sort order. It never receives the full dataset.
- Vaadin server: Translates the query into Spring Data
Pageableand forwards it to the repository. - Database: Executes the paginated query and returns only the requested page.
Transaction boundary: Each callback runs in its own read-only transaction. The connection is returned to the pool immediately after the stream is consumed. Do not keep the stream open across UI events.
Operational Verification
Heap Stability Check
Start the application with a 1 GB heap and enable GC logging. Scroll programmatically through 100,000 rows using a test script or manual interaction. Expected result: heap usage stays within a narrow band and old-generation occupancy does not trend upward.
Pagination Parameter Check
Add debug logging to fetchPage and confirm query.getOffset() and query.getLimit() map to valid page numbers. A limit of 50 and offset of 150 should produce page 3 with size 50.
Count Query Check
Enable SQL logging (spring.jpa.show-sql=true) and verify that the count query executes once per filter change, not once per row. On large tables, ensure the count uses an indexed column or an approximate count strategy if exact counts are too slow.
Failure Modes and Mitigation
N+1 Select Issues
If Customer has lazy relationships and the UI renders them, each page can trigger extra queries per row. Mitigate with @EntityGraph or JOIN FETCH in the repository method.
Count Query Performance
repository.count() may perform a full table scan on massive tables. Mitigate by caching the count for a short window or replacing exact counts with an estimated count when exact precision is not required.
Stale Data Across Callbacks
Because each callback is a separate transaction, a row edited between fetch and count can shift scroll position. Mitigate with optimistic locking or by accepting eventual consistency for browse-only grids.
Conditions That Would Change This Design
- Frequent real-time updates: Add WebSocket push and cache invalidation so the Grid refreshes when underlying data changes.
- Multi-tenancy: Propagate tenant context through the data provider callbacks to the repository layer.
- Non-relational storage: Replace Spring Data JPA with a custom data provider that implements the same pagination contract.
- Sub-second filtering on unindexed columns: Add database indexes or move filtering to a search engine such as Elasticsearch.
Limitations
This design assumes read-dominated browsing. Heavy write workloads behind the same grid can cause pagination drift because new rows shift offsets between requests. It also assumes the database can serve count and page queries within the Vaadin request timeout.
Practical Result Check
After deployment, open the Grid and scroll to the end. If the scrollbar thumb remains proportional and memory does not grow, server-side pagination is working. If the browser hangs or the JVM throws OutOfMemoryError, the Grid is likely still bound to an in-memory list.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.