SupportWriter space ↗
← Back to the journal
Vaadin

Background tasks and server push in Vaadin Flow

Keep Vaadin UIs responsive during slow operations: run work in background threads, update the UI safely with ui.access, enable server push, and broadcast live updates.

DPutu Adi Guna Permana · 08 Oct 2026 · 6 min read

The problem with slow operations

In Vaadin Flow, event listeners run on the server while the user's session is locked. That makes UI code simple and thread-safe, but it has a consequence: if a button click starts a report that takes 30 seconds, the user's browser shows a loading indicator and the UI cannot respond until the listener finishes.

The fix is to move slow work to a background thread and update the UI when the result is ready. Doing that correctly requires two things: a way to modify the UI safely from another thread, and a way for the server to send the update to the browser without the user clicking anything. This is called server push.

The examples target Vaadin 24 or later with Spring Boot.

Enabling server push

Push is enabled once for the whole application, on the class that implements AppShellConfigurator:

import com.vaadin.flow.component.page.AppShellConfigurator;
import com.vaadin.flow.component.page.Push;
import com.vaadin.flow.theme.Theme;

@Push
@Theme("my-theme")
public class AppShell implements AppShellConfigurator {
}

By default, push uses WebSockets and falls back to long polling when WebSockets are not available. If a proxy or load balancer sits in front of your app, make sure it allows WebSocket upgrades on the application path.

Updating the UI safely with ui.access

Vaadin components are not thread-safe. Code running outside a request (in a background thread) must lock the session before touching the UI. That is exactly what UI.access does:

ui.access(() -> {
    status.setText("Report ready");
    downloadButton.setEnabled(true);
});

The lambda runs while holding the session lock, and with push enabled the changes are sent to the browser immediately. Modifying components from another thread without access can corrupt the UI state and cause hard-to-reproduce errors.

A background task with Spring's @Async

Let the service do the slow work asynchronously and return a CompletableFuture:

@Configuration
@EnableAsync
class AsyncConfig {
}

@Service
public class ReportService {

    @Async
    public CompletableFuture<Report> generateMonthlyReport(YearMonth month) {
        Report report = buildReport(month); // slow: queries, aggregation, PDF rendering
        return CompletableFuture.completedFuture(report);
    }
}

In the view, start the task, show progress, and update the UI when it completes:

@Route("reports")
@PermitAll
public class ReportView extends VerticalLayout {

    private final ProgressBar progress = new ProgressBar();
    private final Span status = new Span();
    private final Button generate = new Button("Generate report");

    public ReportView(ReportService reportService) {
        progress.setIndeterminate(true);
        progress.setVisible(false);

        generate.addClickListener(event -> {
            UI ui = event.getSource().getUI().orElseThrow();
            generate.setEnabled(false);
            progress.setVisible(true);
            status.setText("Generating...");

            reportService.generateMonthlyReport(YearMonth.now().minusMonths(1))
                    .whenComplete((report, error) -> ui.access(() -> {
                        progress.setVisible(false);
                        generate.setEnabled(true);
                        if (error != null) {
                            status.setText("Report failed. Please try again.");
                        } else {
                            status.setText("Report ready: " + report.title());
                        }
                    }));
        });

        add(generate, progress, status);
    }
}

The click listener returns immediately, so the UI stays responsive. Note that the UI reference is captured inside the listener, while the request is still active. UI.getCurrent() returns null in background threads, so never call it there.

Reporting progress

For long tasks, show real progress instead of a spinner. Pass a callback to the service:

public CompletableFuture<Integer> importCustomers(List<CustomerRow> rows, Consumer<Double> onProgress) {
    for (int i = 0; i < rows.size(); i++) {
        importRow(rows.get(i));
        if (i % 50 == 0) {
            onProgress.accept((double) i / rows.size());
        }
    }
    return CompletableFuture.completedFuture(rows.size());
}
importService.importCustomers(rows, value -> ui.access(() -> progress.setValue(value)))
        .thenAccept(count -> ui.access(() -> Notification.show(count + " customers imported")));

Throttle progress updates. Pushing an update for every one of 100,000 rows wastes bandwidth and CPU; a few updates per second are plenty.

When the user leaves

If the user closes the tab or navigates away before the task finishes, calling ui.access on a detached UI throws UIDetachedException. Cancel work that is no longer needed when the component detaches:

private CompletableFuture<Report> running;

@Override
protected void onDetach(DetachEvent detachEvent) {
    if (running != null) {
        running.cancel(true);
    }
}

Alternatively, ui.accessLater(...) creates a callback that silently ignores the update if the UI is gone, which is convenient for listeners registered with long-lived services.

Live updates for all users: a broadcaster

Push also lets one user's action update everyone's screen, for example a live order feed or a chat. A simple broadcaster keeps a list of listeners:

@Component
public class OrderBroadcaster {

    private final Set<Consumer<Order>> listeners = ConcurrentHashMap.newKeySet();
    private final Executor executor = Executors.newSingleThreadExecutor();

    public Registration register(Consumer<Order> listener) {
        listeners.add(listener);
        return () -> listeners.remove(listener);
    }

    public void broadcast(Order order) {
        listeners.forEach(listener -> executor.execute(() -> listener.accept(order)));
    }
}

Each view registers when it is attached and unregisters when it is detached, so closed tabs do not leak memory:

public class LiveOrdersView extends VerticalLayout {

    private final OrderBroadcaster broadcaster;
    private Registration registration;

    @Override
    protected void onAttach(AttachEvent attachEvent) {
        UI ui = attachEvent.getUI();
        registration = broadcaster.register(order -> ui.access(() ->
                addComponentAsFirst(new Span("New order #" + order.getId() + " from " + order.getCustomer()))));
    }

    @Override
    protected void onDetach(DetachEvent detachEvent) {
        registration.remove();
    }
}

The order service calls broadcaster.broadcast(order) after saving, and every open LiveOrdersView updates within milliseconds. This in-memory approach works on a single server; when running several instances, publish events through a message broker such as Redis, RabbitMQ, or Kafka.

Polling as an alternative

If push is not an option in your environment, polling makes the browser ask the server for changes at a fixed interval:

ui.setPollInterval(2000); // milliseconds; set to -1 to stop

Background threads still use ui.access, and the changes are delivered with the next poll. Polling is simpler to operate but adds latency and requests, so enable it only while a task is running.

Things to keep in mind

  • Use a bounded thread pool. Configure Spring's task executor (spring.task.execution.pool.*) so that a burst of users cannot create unlimited threads.
  • Security context. Spring Security's context is bound to the request thread. If the background task needs the current user, pass the user's identity as a parameter instead of reading it inside the task.
  • Keep access blocks short. Do the heavy work outside, and only touch components inside ui.access.
  • Do not block in listeners. Avoid calling future.get() or join() inside a click listener; that brings back the original problem.

Exercise

Build a CSV import view: the user uploads a file with the Upload component, the rows are imported in a background task with a real progress bar, the Cancel button stops the import, and when it finishes, every user viewing the customers grid sees a notification through a broadcaster and their grid refreshes automatically.

← Explore more notes