Scaling Vaadin UIs with Server‑Side Routing and Lazy‑Loading Grid DataProvider
Learn how to pair Vaadin Flow’s @Route navigation with a callback‑based DataProvider so that only the visible rows of a large Grid are fetched, keeping memory and network usage low.
17 Dec 2025, 02:49 UTC

Problem: Slow UI when loading thousands of records
When a Vaadin Grid is bound to a collection that contains tens or hundreds of thousands of items, the initial render can freeze the browser and consume large amounts of server‑side memory. The UI feels sluggish because every row is instantiated and transferred, even though the user can only see a few dozen rows at a time.
Thesis: Use server‑side routing together with a lazy‑loading DataProvider
Vaadin Flow treats each navigable view as a plain Java class annotated with @Route. The framework manages client‑side navigation via the HTML5 History API, so URL changes update the view without a full page reload. By pairing the view’s Grid component with a CallbackDataProvider that fetches only the rows needed for the current viewport, you keep both client‑side rendering and server‑side memory usage low.
How Vaadin Flow routing works
A class annotated with @Route("customers") becomes reachable at /customers relative to the application’s base URL. When the user navigates to that URL, Flow creates an instance of the view class, attaches it to the UI, and updates the browser’s history entry. Subsequent navigation between routed views only exchanges JSON UI state, avoiding full‑round‑trip page loads.
Lazy‑loading DataProvider basics
The DataProvider interface separates the Grid from the data source. A CallbackDataProvider is built from two lambda callbacks:
- Fetch callback – receives a
Queryobject that describes the requested offset, limit, and sorting; it returns aStreamof the matching items. - Count callback – receives the same
Query(without pagination) and returns the total number of items, enabling the Grid to show scrollbars and page numbers correctly.
Because the Grid only asks for the rows intersecting the viewport, the backend is hit with small, paginated queries instead of a single massive load.
Worked example: Customer view with Spring Data repository
Assume a simple Customer entity and a Spring Data JPA repository:
public interface CustomerRepository extends PagingAndSortingRepository<Customer, Long> { }
The view class combines routing and the lazy‑loading DataProvider:
@Route("customers")
public class CustomerView extends VerticalLayout {
private final CustomerRepository repo;
public CustomerView(CustomerRepository repo) {
this.repo = repo;
setSizeFull();
add(createGrid());
}
private Grid<Customer> createGrid() {
Grid<Customer> grid = new Grid<>(Customer.class, false);
grid.addColumn(Customer::getFirstName).setHeader("First name");
grid.addColumn(Customer::getLastName).setHeader("Last name");
grid.addColumn(Customer::getEmail).setHeader("Email");
// Build a CallbackDataProvider from the repository
DataProvider<Customer, Void> dataProvider = DataProvider.fromCallbacks(
query -> {
// Convert Vaadin Query to Spring PageRequest
int offset = query.getOffset();
int limit = query.getLimit();
var sort = query.getSortOrder()
.map(order -> Sort.by(order.getDirection() == SortDirection.ASCENDING ? Sort.Direction.ASC : Sort.Direction.DESC,
order.getSorted()))
.orElse(Sort.unsorted());
return repo.findAll(PageRequest.of(offset / limit, limit, sort)).stream();
},
query -> repo.count()
);
grid.setDataProvider(dataProvider);
return grid;
}
}
When the user scrolls the Grid, the fetch callback is invoked with the appropriate offset and limit, triggering a repository call that returns only the needed page. The count callback runs once (or when the Grid invalidates its size) to provide the total row count for the scrollbar.
Where to run and what to verify
To try the example:
- Start the application – run
./mvnw spring-boot:run(or use your IDE) from the project root. No special permissions are required beyond the ability to bind to the default HTTP port (8080). - Enable repository logging – add
logging.level.org.springframework.data.jpa=DEBUGtoapplication.properties. You should see SQL statements appear only when the Grid’s viewport changes. - Observe network traffic – open Chrome DevTools → Network tab, filter for XHR, and notice that requests to the backend endpoint (e.g.,
/customersvia Vaadin’s internal endpoint) occur only when scrolling past the currently loaded rows, not on the initial load beyond the first page. - Check UI responsiveness – record a session in the Performance tab while scrolling through a large grid; confirm that the main thread stays under ~16 ms per frame and that the JavaScript heap does not grow unbounded.
These steps let you verify that lazy loading is active; they do not guarantee performance under production load, which depends on your database indexing and query efficiency.
Trade‑off and limitation
The primary advantage of this pattern is reduced data transfer and lower client‑side memory consumption. However, Vaadin Flow retains the entire UI state (including the Grid component and its internal buffers) in the server‑side HTTP session for each active user. Lazy loading mitigates data volume but does not eliminate the per‑session overhead of UI components. If you expect thousands of concurrent users, you will need to:
- Deploy the application in a clustered environment with sticky sessions or session replication, or
- Offload UI state to a distributed cache (e.g., using Vaadin Session Replication or Spring Session with Redis).
Additionally, the efficiency of lazy loading hinges on the backend’s ability to serve offset‑based pages quickly. Poorly indexed tables or expensive count queries can introduce latency that outweighs the benefits of reduced payload size. Consider keyset pagination (seek method) if your dataset supports monotonic columns and you need more predictable performance.
Actionable closing
Start by adding a @Route annotated view that contains a Grid backed by a CallbackDataProvider built from your existing Spring Data repository. Verify the behavior with the logging and network checks outlined above, then monitor session memory usage under realistic load. If memory grows beyond acceptable limits, explore session‑clustering or state‑offloading strategies while retaining the lazy‑loading Grid for optimal data transfer.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.