Designing a Lazy-Loading DataProvider for Vaadin Flow Grid
An architecture note on Vaadin Flow Grid lazy-loading DataProviders: the minimal design, the serialization trust boundary, operational signals, failure modes, and when to switch to keyset pagination.
23 Dec 2025, 09:39 UTC

A Vaadin Flow Grid bound to a large table will happily try to load everything into server memory if you hand it setItems(List). The fix is a lazy-loading DataProvider, but the naive version has its own traps: N+1 queries, oversized UIDL messages, and runaway page requests. This note walks through the smallest design that works, where the trust boundary sits, and the conditions that should push you to a different approach.
Requirements
Assume a typical back-office screen: a Grid over a JPA-backed table of tens of thousands of rows, with sorting and a filter field. The requirements that shape the design are:
- Memory on the server must stay flat regardless of table size.
- Scrolling must feel responsive, so each fetch should complete in well under a second.
- The browser must never see more than the rows currently near the viewport.
- Fetch failures must not kill the user's session.
The smallest suitable design
Flow's DataProvider with a backend callback is the minimal fit. The Grid sends offset/limit (plus sort orders and filter) as the user scrolls; your callback translates that into a paged query and returns a stream. With Spring Data, the mapping to Pageable is almost one-to-one:
grid.setItems(query -> {
int offset = query.getOffset();
int limit = query.getLimit();
Sort sort = VaadinSpringDataHelpers.toSpringDataSort(query);
Page<CustomerRow> page = repository.findByNameContaining(
filterText, PageRequest.of(offset / limit, limit, sort));
return page.stream();
});Two details matter here. First, return a projection (a row DTO like CustomerRow), not the full JPA entity. Entities tend to drag lazy associations and large fields into the serialization path. Second, if you use the two-callback variant (setItems(fetchCallback, countCallback)), make the count query cheap — a cached or approximate count is fine for very large tables, because the Grid calls it often to size the scrollbar.
Trust and data boundaries
The browser never talks to your database. Every row travels through the Vaadin session: the Grid's client-side connector requests a range, the server runs your callback inside the UI's lock, and the resulting beans are serialized into UIDL messages sent over the WebSocket (or long-polling fallback). That means:
- Anything reachable from the serialized bean graph can leak to the client. DTO projections are also a security boundary, not just a performance one — exclude internal fields by construction.
- Authorization belongs in the callback. A crafted request asking for page 5000 is just another query; enforce tenant scoping in the repository call, not in the UI.
- The callback executes per UI session, so shared mutable state in the DataProvider is a concurrency bug waiting to happen. Keep it stateless apart from the current filter value.
Operational checks
Instrument the fetch callback rather than the Grid itself. A timer and counter around the repository call gives you the two signals that matter:
- Fetch count vs. scroll distance. If one smooth scroll triggers dozens of fetches, the client is requesting tiny pages or your count callback keeps changing the reported size, forcing re-fetches.
- Fetch latency. Spikes usually mean an N+1 pattern (often from serializing entities with lazy associations) or a missing index on the filtered/sorted column. Log the offset/limit and sort with each slow query so you can reproduce it with
EXPLAIN.
On the unit side, test the callback in isolation: mock the repository, call the fetch with a known offset/limit, and assert the returned stream size matches the limit (except on the last page). For end-to-end confidence, a browser test that scrolls the Grid and asserts that new rows appear — and that the server log shows bounded page sizes — covers the wiring.
Failure modes
Exception in the fetch callback. The Grid shows its error state and the client receives an error response, but the Vaadin session survives. Still, an unhandled exception on every scroll is a bad experience — catch expected cases (e.g., a transient database timeout), log them, and rethrow or return an empty stream deliberately rather than by accident.
Mismatched paging parameters. If your callback ignores the requested limit and returns unbounded results, or the count callback reports a size far larger than reality, the Grid can issue repeated large requests. In the worst case this spirals into server-side memory pressure. Treat the requested limit as a hard ceiling and defensively cap it (e.g., reject anything above a few hundred rows per fetch).
Oversized messages. Serializing fat domain objects can push a single UIDL message toward the roughly 1 MiB range, at which point connections can be closed. Symptom: the Grid blanks out or the "connection lost" indicator appears only when scrolling into rows with large text or blob fields. The fix is the projection DTO, plus excluding or truncating large columns.
When to change the design
The lazy DataProvider design holds well into the hundreds of thousands of rows for scroll-and-filter workloads. Reconsider when:
- Random access dominates. If users jump to arbitrary pages constantly ("go to row 87,000"), offset-based paging gets expensive on the database. Keyset (cursor) pagination or a dedicated index/search service is a better fit.
- Serialization cost dominates. If profiling shows most fetch time in JSON/UIDL serialization, shrink the DTO further or reduce the page size before reaching for caching.
- Data changes under the user. Lazy grids show a snapshot per fetch; rows shifting between fetches produce duplicates or gaps while scrolling. If that matters, keyset pagination or an explicit refresh model is the design change, not a workaround.
The practical check after rollout: watch fetch latency percentiles and per-session fetch counts for a week. Flat memory, sub-second fetches, and no error-state grids means the boundary is holding.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.