Real-time UI updates in Java: push changes from the server

In webforJ you get real-time UI updates in Java by changing components from the server and letting the framework carry the change to the browser. For work that runs outside the UI thread, you wrap the change in Environment.runLater(). For recurring refreshes you use Interval, and for typing you use Debouncer. You write no WebSocket or server-sent events code.
This post builds two small pieces: a progress panel fed by a background job, and a dashboard number that refreshes itself. Along the way it covers the one rule that decides whether any of this works, which is which thread may update a component.
How does a Java web app push updates to the browser?
In a webforJ app the components live on the server. When you call setText() on a Paragraph or setValue() on a ProgressBar, the browser view changes. There is no endpoint to poll and no client script that listens for messages, because you never wrote the layer between them.
That makes "push" a question about threads rather than protocols. A server-side change reaches the user as soon as it runs on the right thread. Everything below is about getting your code onto that thread at the right time.
Which thread can update components?
webforJ enforces a strict threading model. All UI operations must happen on the Environment thread, the thread bound to the user's session. There are three reasons, covered in the asynchronous updates guide: the underlying webforJ API binds to the thread that created the session, components keep state that isn't thread-safe, and UI events are processed sequentially on a single thread.
Event handlers already run there, so a click listener can update components directly. The rule bites when you start your own work. A background thread that changes a component directly fails, because it isn't the session's UI thread. Every UI update from a background thread goes through Environment.runLater(), with no exceptions.
How do you update the UI from a background job?
Environment.runLater() schedules a task on the UI thread. It comes in two forms, one taking a Runnable and one taking a Supplier, and both return a PendingResult that tracks completion and lets you cancel the task. The asynchronous updates guide covers both forms.
Here is a panel that runs a long import on an executor and reports progress as it goes:
@Route("jobs")
public class JobPanel extends Composite<Div> {
private final ExecutorService executor = Executors.newSingleThreadExecutor();
private final List<PendingResult<?>> updates = new ArrayList<>();
private final ProgressBar progress = new ProgressBar();
private final Paragraph status = new Paragraph("Idle");
private final Button start = new Button("Start import", ButtonTheme.PRIMARY);
public JobPanel() {
start.onClick(e -> {
start.setEnabled(false);
executor.submit(this::runImport);
});
getBoundComponent().add(start, progress, status);
}
private void runImport() {
for (int i = 1; i <= 100; i++) {
int percent = i;
importRecord(percent);
updates.add(Environment.runLater(() -> {
progress.setValue(percent);
status.setText("Imported " + percent + " of 100 records");
}));
}
Environment.runLater(() -> {
status.setText("Import finished");
start.setEnabled(true);
});
}
}
The executor thread is created from within the session's thread, so it inherits the Environment context and may call runLater(). Tasks submitted from a background thread are queued and run in strict first-in, first-out order, so the user never sees progress jump backwards.
What happens on threads without context?
A thread that isn't created from inside an Environment has no context. Calling runLater() from it throws an IllegalStateException. Three common sources are static initializers, java.util.Timer threads, and callbacks from external libraries such as an HTTP client's async methods. If you need results from one of those, hand them to a thread you started from the session instead.
Cleaning up when the user leaves
Queued tasks hold references to the components they update. Cancel them when the view is destroyed so a finished navigation doesn't keep the view alive:
@Override
protected void onDestroy() {
updates.forEach(u -> {
if (!u.isDone()) {
u.cancel();
}
});
executor.shutdownNow();
}
Cancellation only stops tasks that haven't started. One that is already running completes.
How do you refresh a number on a timer?
For a dashboard figure that should stay current, use Interval. It is a timer with a fixed delay in seconds that fires an event each time it elapses. It has existed since 24.02 and requires no thread management on your side.
@Route("dash")
public class Dashboard extends Composite<Div> {
private final Paragraph orders = new Paragraph("0");
private final Interval refresh;
public Dashboard() {
refresh = new Interval(5f, e -> orders.setText(String.valueOf(orderCount())));
refresh.start();
getBoundComponent().add(orders);
}
@Override
protected void onDidDestroy() {
refresh.stop();
}
}
An Interval needs start() before it fires, and stop() and restart() control it afterwards. You can change the delay with setDelay(), which takes effect after a stop or restart. Very small delays flood the app with events faster than it can handle them.
Prefer Interval over a java.util.Timer or a thread per user. A web app has many concurrent users, and per-user threads run out quickly.
