Lazy-Loading a Vaadin Flow Grid with a Callback DataProvider
Wire a Vaadin Flow Grid to DataProvider.fromCallbacks so only visible rows are fetched, with sorting, filtering, verification steps, and fixes for stale data and count mismatches.
07 Jan 2026, 02:22 UTC

Why the default Grid setup breaks down on large tables
Calling grid.setItems(repository.findAll()) loads every row into server memory and serializes the whole collection to the component state. On a table with hundreds of thousands of rows this means slow page loads, high heap usage, and eventually an OutOfMemoryError. The fix is a callback-based DataProvider: the Grid asks your backend only for the rows currently visible in the viewport, plus a total count, and fetches more as the user scrolls.
This guide wires a Grid to a paged repository using DataProvider.fromCallbacks, including sorting, filtering, and the checks that confirm lazy loading is actually happening. Examples assume Vaadin Flow 24.x with Spring Data JPA; the DataProvider API is stable across recent LTS versions, but confirm details against your project's version.
Prerequisites
- A Vaadin Flow project (24.x LTS assumed) with a Grid bound to a bean type.
- A repository exposing paged queries, e.g. a Spring Data
JpaRepository<Customer, Long>which providesfindAll(Pageable)andcount()out of the box. - Basic familiarity with
Pageable/PageRequestor an equivalent offset/limit mechanism in your persistence layer.
Wire the fetch and count callbacks
DataProvider.fromCallbacks takes two functions. The fetch callback receives a Query carrying offset, limit, and any requested sort orders; the count callback returns the total number of matching rows. Run this in your view class (a Spring-managed @Route component, so the repository can be injected):
grid.setColumns("name", "email", "city");
DataProvider<Customer, Void> dataProvider = DataProvider.fromCallbacks(
query -> {
int page = query.getOffset() / query.getLimit();
Sort sort = query.getSortOrders().stream()
.map(o -> new Sort.Order(
o.getDirection() == SortDirection.ASCENDING
? Sort.Direction.ASC : Sort.Direction.DESC,
o.getSorted()))
.collect(Collectors.collectingAndThen(
Collectors.toList(),
list -> list.isEmpty() ? Sort.unsorted() : Sort.by(list)));
return customerRepository
.findAll(PageRequest.of(page, query.getLimit(), sort))
.stream();
},
query -> (int) customerRepository.count());
grid.setDataProvider(dataProvider);Two details matter here. First, the Query gives you an offset and limit, while Spring Data wants a page number, so divide — the Grid always requests aligned pages, so the division is exact. Second, the fetch callback must return a Stream; using the stream of the returned Page keeps the query itself paged at the database level.
For sorting to work, each column needs a key matching a sortable property: grid.addColumn(Customer::getName).setHeader("Name").setSortable(true).setKey("name"), or use the string-based addColumn("name") variant which sets the key automatically.
Add filtering that re-runs both callbacks
A filter only works correctly if it affects both the fetch and the count. Wrap the provider with withConfigurableFilter and push the filter into your query (here via a Spring Data derived method):
DataProvider<Customer, String> filterable = DataProvider
.<Customer, String>fromFilteringCallbacks(
query -> customerRepository
.findByNameContainingIgnoreCase(
query.getFilter().orElse(""),
PageRequest.of(query.getOffset() / query.getLimit(),
query.getLimit()))
.stream(),
query -> (int) customerRepository
.countByNameContainingIgnoreCase(query.getFilter().orElse("")))
.withConfigurableFilter();
grid.setDataProvider(filterable);
TextField filterField = new TextField("Filter by name");
filterField.setValueChangeMode(ValueChangeMode.LAZY);
filterField.addValueChangeListener(e -> filterable.setFilter(e.getValue()));setFilter invalidates the provider, so the Grid re-runs both callbacks with the new value. ValueChangeMode.LAZY debounces keystrokes so you do not fire a query per character.
Confirm lazy loading is real
Do not trust the UI alone — a Grid looks identical whether it holds 50 rows or 500,000.
- Enable SQL logging (for Spring Data JPA:
spring.jpa.show-sql=trueor loggerorg.hibernate.SQLat DEBUG) and scroll the Grid to the bottom. You should see repeatedselect ... limit ? offset ?statements, never a full-table select. - Click a sortable column header and confirm the next query includes an
order byclause. - Type in the filter field and confirm both the row query and a
select count(...)include the filter condition, and that the scrollbar shrinks to match the filtered total. - Check the server log or browser network tab: expect roughly one fetch request per scroll page, not a burst per row.
Common failure modes and recovery
Missing rows or a scrollbar that jumps. Almost always an inaccurate count callback — for example, a count that ignores the filter while the fetch applies it. The Grid sizes its virtual scrollbar from the count, so a mismatch produces phantom empty space or truncated data. Fix by making both callbacks use identical filter logic.
Scrolling stalls or shows nothing. Check whether the count callback is throwing or returning 0. A zero count tells the Grid there is no data, so it never calls fetch. Look at server-side logs for exceptions swallowed in the callback.
Stale data after backend changes. The DataProvider caches fetched items. After inserting, updating, or deleting rows outside the Grid, call dataProvider.refreshAll() (or refreshItem(item) for a single known change) to force a re-fetch. Verify the Grid updates without a browser reload.
Slow scrolling on high-latency networks. Each viewport page is a server round-trip. Raising the page size with grid.setPageSize(100) (default is 50) reduces request frequency at the cost of larger individual responses.
Limitations to plan around
Exact count(*) on very large or heavily joined tables can be the slowest query in this setup. If an exact total is not essential, consider caching the count or using an approximate value, accepting that the scrollbar size may be slightly off. Also, never "cheat" by loading all entities in the fetch callback and sublisting in memory — that reproduces the original memory problem while adding callback overhead. Keep the offset and limit inside the SQL query itself.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.