Implementing Server-Side Lazy Loading with Vaadin Grid CallbackDataProvider
Guide to implementing server-side lazy loading in Vaadin Grid using CallbackDataProvider. Covers fetch/count callbacks, repository integration, filter handling, and verification steps to avoid OutOfMemoryErrors with large datasets.
30 Jul 2026, 04:13 UTC

The Problem: Large Datasets Crash Server Memory
Loading 100,000+ rows into a Vaadin Grid using an in-memory ListDataProvider forces the entire dataset into the HTTP session. Each user session consumes heap proportional to the dataset size, leading to OutOfMemoryError under moderate concurrency. The fix is to push pagination, sorting, and filtering to the database so the grid only ever holds the visible viewport—typically 50–100 rows.
Desired Outcome
A Grid<T> backed by a CallbackDataProvider<T, Filter> that:
- Issues
LIMIT/OFFSETqueries matching the current scroll position - Pushes
ORDER BYandWHEREclauses to the database when the user sorts or filters - Reports the exact total row count so the scrollbar thumb reflects true dataset size
- Keeps per-session memory constant regardless of table size
Prerequisites
- Vaadin 23+ (Flow) or Vaadin 14+ with
vaadin-grid-flowdependency - A Spring Data JPA, JOOQ, or plain JDBC repository that can accept
limit,offset,Sort, and a filter predicate - Database indexes on every column exposed to sorting/filtering
- Application-scoped
DataProviderbeans are not thread-safe; create a new instance per grid or request scope
Procedure: Build the CallbackDataProvider
1. Define the Filter Type
Create a lightweight record that captures every filter the UI supports. Keep it serializable so it survives session replication.
public record ProductFilter(
String nameContains,
BigDecimal minPrice,
BigDecimal maxPrice,
LocalDate createdAfter
) implements Serializable {}
2. Implement the Fetch Callback
The fetch callback receives a Query<T, Filter> containing offset, limit, sortOrders, and the filter instance. Translate these into your repository call.
@Component
@Scope("prototype") // new instance per grid
public class ProductDataProvider extends CallbackDataProvider<Product, ProductFilter> {
private final ProductRepository repo;
public ProductDataProvider(ProductRepository repo) {
this.repo = repo;
// fetch callback
setFetchCallback(query -> {
ProductFilter filter = query.getFilter().orElse(new ProductFilter(null,null,null,null));
List orders = query.getSortOrders().stream()
.map(so -> new Sort.Order(
so.getDirection() == QuerySortOrder.Direction.ASC ? Sort.Direction.ASC : Sort.Direction.DESC,
so.getSorted()))
.toList();
Pageable pageable = PageRequest.of(
query.getOffset() / query.getLimit(),
query.getLimit(),
Sort.by(orders));
return repo.findByFilter(filter, pageable).stream();
});
// count callback
setCountCallback(query -> {
ProductFilter filter = query.getFilter().orElse(new ProductFilter(null,null,null,null));
return repo.countByFilter(filter);
});
}
}
3. Repository Signature
Your repository needs two methods that mirror the callbacks. Example with Spring Data JPA:
public interface ProductRepository extends JpaRepository<Product, Long> {
Page<Product> findByFilter(ProductFilter filter, Pageable pageable);
long countByFilter(ProductFilter filter);
}
Implement the query logic in a custom repository fragment or a @Query with dynamic WHERE clauses. Avoid fetching columns the grid never displays.
4. Wire the Grid
@Route("products")
public class ProductView extends VerticalLayout {
public ProductView(ProductDataProvider provider) {
Grid<Product> grid = new Grid<>(Product.class, false);
grid.setDataProvider(provider);
grid.addColumn(Product::getName).setHeader("Name").setAutoWidth(true);
grid.addColumn(Product::getPrice).setHeader("Price").setAutoWidth(true);
grid.addColumn(Product::getCreatedAt).setHeader("Created").setAutoWidth(true);
grid.setHeight("100%");
setSizeFull();
add(grid);
// optional: external filter bar
TextField nameFilter = new TextField("Name contains");
nameFilter.addValueChangeListener(e ->
provider.refreshItem(e.getValue())); // triggers new fetch with updated filter
addComponentAtIndex(0, nameFilter);
}
}
Note: refreshItem on the provider instance forces a full re-fetch with the current filter. For multiple filter fields, rebuild the ProductFilter record and call dataProvider.refreshAll().
Expected Checks
| Check | How to Verify | Pass Criteria |
|---|---|---|
Database receives LIMIT/OFFSET | Enable SQL logging (logging.level.org.hibernate.SQL=DEBUG) and scroll the grid | Each scroll jump logs a query with limit 50 offset 150 (values match viewport) |
| Total count matches scrollbar | Open browser dev-tools, inspect the grid's vertical scrollbar max attribute | max equals SELECT count(*) result for current filter |
| Sorting triggers re-fetch | Click a column header; watch SQL log | New query includes ORDER BY price DESC and same LIMIT/OFFSET |
| Filter reduces dataset | Type in filter field; observe count query | Count drops, scrollbar thumb grows, first page shows only matching rows |
| Session memory stable | Heap dump or JConsole after loading 10k rows | Session heap ~same as 50-row baseline (no full dataset retained) |
Common Pitfalls & Recovery
Count Query Latency
If countByFilter scans the whole table, the initial render stalls. Mitigations:
- Add a covering index for the filter columns
- Cache the count for 5–10 seconds with Caffeine/Guava if exact real-time count isn't required
- For extremely large tables, consider
estimatedCountviaEXPLAINor a materialized view, but accept scrollbar inaccuracy
Stale Data After Background Updates
The grid caches pages per session. If another user modifies data, the current user sees stale rows until they scroll away and back. Options:
- Push updates via WebSocket (
grid.getDataProvider().refreshAll()on a broadcast event) - Set a short TTL on the provider instance (request scope) so each navigation refreshes
- Accept eventual consistency for internal tools
Custom Renderers Blow Memory
Component renderers (grid.addComponentColumn(...)) create a Vaadin component per visible cell. For 50 rows × 10 columns that's 500 components per session—usually fine. Beyond 200 rows visible, switch to LitRenderer or TemplateRenderer which serialize HTML instead of creating server-side components.
Limitations
CallbackDataProviderdoes not support hierarchical/tree data; useTreeDataProviderwith lazy children callbacks instead- In-memory sorting/filtering (
grid.setSortComparator) is disabled automatically when a callback provider is set—do not mix - Row-level detail expand (
setDetailsGenerator) fires an additional fetch per expanded row; ensure the detail query is indexed - Vaadin 24+ introduces
GridLazyDataViewfor declarative binding; the callback approach remains supported but consider migration for new projects
Quick Verification Checklist
- Deploy to staging with production-like data volume (>= 50k rows)
- Open the view, scroll to row 10,000—confirm no full-table scan in logs
- Apply each filter, verify count query uses indexes (
EXPLAIN ANALYZE) - Sort every sortable column both directions
- Run load test: 50 concurrent users scrolling for 5 minutes; monitor heap and DB connection pool
If all checks pass, the grid will scale to millions of rows with constant per-session memory.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.