Vaadin Flow @Push: Enabling Live UI Updates from Background Tasks
Learn how Vaadin Flow's @Push annotation opens a persistent connection for server‑initiated UI updates, why background threads must use UI.access(), and what to verify before pushing to production.
14 Aug 2025, 16:19 UTC

A typing indicator that never refreshes
Suppose you add a small "User X is typing..." label beneath a chat input. The first implementation polls the server every second to see if anyone else has started typing. With a handful of users the extra requests are harmless, but as the chat room grows the network fills with repetitive GETs that return "no change" most of the time. Each poll consumes a thread, adds latency, and wastes bandwidth.
Vaadin Flow offers a way to flip the interaction: instead of the browser repeatedly asking, the server pushes the change the moment it happens. The @Push annotation opens a persistent channel (WebSocket by default, with a fallback to long‑polling) so the server can send partial UI updates instantly.
How @Push establishes the channel
Annotating the application shell (or any @Route component) tells Vaadin to create a push endpoint at /VAADIN/push. On the first UI request the framework upgrades the connection to a WebSocket if the client and any intermediate proxies support the Upgrade and Connection headers; otherwise it falls back to a series of long‑polling XHR requests. The transport mode can be forced with @Push(transport = Transport.LONG_POLLING) or @Push(transport = Transport.WEBSOCKET).
Only one rule makes push reliable: any UI mutation that originates outside the Vaadin request thread must be wrapped in UI.access(() -> { … }). This queues the change on the UI’s own thread and triggers the push over the open channel. Forgetting the wrapper can corrupt component state or silently drop updates.
Worked example: a shared counter
The following view shows a number that is incremented every five seconds by a background scheduler. All users see the same value without manual refresh.
@Route("")
@Push // enable push for this view (could also be on the shell)
public class SharedCounter extends VerticalLayout {
private final Span value = new Span("0");
private final UI ui;
public SharedCounter() {
// Capture the UI instance that belongs to this request
ui = UI.getCurrent();
add(new H1("Shared counter"), value);
ScheduledExecutorService scheduler =
Executors.newSingleThreadScheduledExecutor();
scheduler.scheduleAtFixedRate(() -> {
int next = Integer.parseInt(value.getText()) + 1;
// UI access is required because we are in a background thread
ui.access(() -> value.setText(String.valueOf(next)));
}, 5, 5, TimeUnit.SECONDS);
// Clean up when the user leaves the view
addDetachListener(e -> scheduler.shutdownNow());
}
}
Two points deserve emphasis:
- The
UIreference is obtained once in the constructor; callingUI.getCurrent()inside the scheduled task would return null because the request thread is gone. - The mutation
value.setText(...)runs insideui.access(), guaranteeing thread‑safe component updates and triggering the push.
Trade‑offs and practical limits
- Connection budget. Each open tab holds one persistent socket. A server that supports 10 000 HTTP threads may still run out of file descriptors if thousands of users keep a push tab open.
- Proxy and firewall sensitivity. Corporate proxies sometimes block the WebSocket upgrade handshake. When that happens Vaadin automatically switches to long‑polling, which works but generates more requests and slightly higher latency.
- Update frequency. Pushing on every tiny change can overwhelm the client. Batch updates or throttle them (e.g., only push after 200 ms of inactivity) keeps the channel usable.
- Security surface. The push endpoint participates in the normal Vaadin request pipeline; it does not bypass authentication or CSRF protection. Secure your views as you would any other route.
Verifying push before you ship
- Start the application (
mvn spring-boot:runor your preferred launcher) and open the browser. - Open DevTools → Network, filter to
WS. You should see a single connection to/VAADIN/pushthat stays open. - Watch the counter increment every five seconds without any new XHR or document requests appearing.
- To test the fallback, temporarily add
@Push(transport = Transport.LONG_POLLING), restart, and confirm the Network panel now shows repeatedGET /VAADIN/push?...requests instead of a WS entry. - Open the same view in two tabs or two browsers; both counters should stay in sync, and the server logs should show two concurrent push connections.
Where to begin
Add @Push to your application shell (or the specific view that needs live updates), capture the UI instance on the request thread, and route every background mutation through UI.access(). Then run the verification steps above – a stable WS entry and a counter that moves without a single poll confirms that push is working as intended.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.