Asynchronous Updates
The Environment.runLater() API provides a mechanism for safely updating the UI from background threads in webforJ applications. This preview feature enables asynchronous operations while maintaining thread safety for UI modifications.
The webforj-handling-timers-and-async skill can schedule timers, debouncers, and async work safely on the UI thread. After installing the webforJ AI plugin, ask your assistant:
- "Refresh this dashboard every 30 seconds."
- "Add a search-as-you-type debouncer."
- "Run this CPU-heavy work in the background and update the progress bar."
Understanding the thread model
webforJ enforces a strict threading model where all UI operations must occur on the Environment thread. This restriction exists because:
- webforJ API constraints: The underlying webforJ API binds to the thread that created the session
- Component thread affinity: UI components maintain state that's not thread-safe
- Event dispatch: All UI events are processed sequentially on a single thread
This single-threaded model prevents race conditions and maintains a consistent state for all UI components, but creates challenges when integrating with asynchronous, long-running computation tasks.
Each user session has its own Environment and its own Environment thread. A background thread belongs to no session, so it must be told which Environment to update.
RunLater API
The Environment.runLater() API provides two methods for scheduling UI updates. Both take the target Environment as their first argument:
// Schedule a task with no return value
public static PendingResult<Void> runLater(Environment target, Runnable task)
// Schedule a task that returns a value
public static <T> PendingResult<T> runLater(Environment target, Supplier<T> supplier)
Both methods return a PendingResult that tracks task completion and provides access to the result or any exceptions that occurred.
The overloads runLater(Runnable) and runLater(Supplier) guess the target from the thread that created the calling thread. On a thread pool shared by several users, every task runs in the Environment of the user whose call created that pool thread. These overloads are deprecated since 26.10 and scheduled for removal in the next major release. Always pass the target.
Passing the target
Environment.getCurrent() returns the Environment of the current thread. On the Environment thread, which is where constructors, event handlers and lifecycle methods run, it returns the session's Environment. On a background thread it returns null.
Read the target where it's available, before the background work starts, and pass it to the work:
@Route
public class DataView extends Composite<Div> {
private final ExecutorService executor = Executors.newCachedThreadPool();
public DataView() {
// The constructor runs on the Environment thread
Environment env = Environment.getCurrent();
executor.submit(() -> {
String data = fetchRemoteData();
Environment.runLater(env, () -> {
dataLabel.setText(data);
loadingSpinner.setVisible(false);
});
});
}
}
Because the target is passed as an argument, the call works from any thread: a thread the view created, a Spring @Async thread, the common ForkJoinPool, a timer thread or a callback thread of an HTTP client.
Reading the target on the wrong thread
The one mistake to avoid is reading Environment.getCurrent() inside the background work. There it returns null, and runLater() throws a NullPointerException:
executor.submit(() -> {
String data = fetchRemoteData();
// Wrong: getCurrent() returns null on a background thread
Environment.runLater(Environment.getCurrent(), () -> dataLabel.setText(data));
});
The same applies to static initializers, java.util.Timer threads and external library callbacks. None of them has an Environment, so the target must come from a field, a parameter or a captured local that was filled on the Environment thread.
Execution behavior
The execution behavior of runLater() depends on which thread calls it:
From the UI thread
When called from the target's own Environment thread, tasks execute synchronously and immediately:
button.onClick(e -> {
Environment env = Environment.getCurrent();
System.out.println("Before: " + Thread.currentThread().getName());
PendingResult<String> result = Environment.runLater(env, () -> {
System.out.println("Inside: " + Thread.currentThread().getName());
return "completed";
});
System.out.println("After: " + result.isDone()); // true
});
With this synchronous behavior, UI updates from event handlers are applied immediately and don't incur any unnecessary queueing overhead.
From background threads
When called from any other thread, tasks are queued for asynchronous execution:
@Override
public void onDidCreate() {
Environment env = Environment.getCurrent();
CompletableFuture.runAsync(() -> {
// This runs on a ForkJoinPool thread
System.out.println("Background: " + Thread.currentThread().getName());
PendingResult<Void> result = Environment.runLater(env, () -> {
// This runs on the Environment thread
System.out.println("UI Update: " + Thread.currentThread().getName());
statusLabel.setText("Processing complete");
});
// result.isDone() would be false here
// The task is queued and will execute asynchronously
});
}
webforJ processes tasks submitted from background threads in strict FIFO order, preserving the sequence of operations even when submitted from multiple threads concurrently. With this ordering guarantee, UI updates are applied in the exact order they were submitted. So if thread A submits task 1, and then thread B submits task 2, task 1 will always execute before task 2 on the UI thread. Processing tasks in FIFO order prevents inconsistencies in the UI.
Task cancellation
The PendingResult returned by Environment.runLater() supports cancellation, allowing you to prevent queued tasks from executing. By cancelling pending tasks, you can avoid memory leaks and prevent long-running operations from updating the UI after they're no longer needed.
Basic cancellation
PendingResult<Void> result = Environment.runLater(env, () -> {
updateUI();
});
// Cancel if not yet executed
if (!result.isDone()) {
result.cancel();
}
Managing multiple updates
When performing long-running operations with frequent UI updates, track all pending results:
public class LongRunningTask {
private final List<PendingResult<?>> pendingUpdates = new ArrayList<>();
private volatile boolean isCancelled = false;
public void startTask() {
Environment env = Environment.getCurrent();
CompletableFuture.runAsync(() -> {
for (int i = 0; i <= 100; i++) {
if (isCancelled) return;
final int progress = i;
PendingResult<Void> update = Environment.runLater(env, () -> {
progressBar.setValue(progress);
});
// Track for potential cancellation
pendingUpdates.add(update);
Thread.sleep(100);
}
});
}
public void cancelTask() {
isCancelled = true;
// Cancel all pending UI updates
for (PendingResult<?> pending : pendingUpdates) {
if (!pending.isDone()) {
pending.cancel();
}
}
pendingUpdates.clear();
}
}
Component lifecycle management
When components are destroyed (e.g., during navigation), cancel all pending updates to prevent memory leaks:
@Route
public class CleanupView extends Composite<Div> {
private final List<PendingResult<?>> pendingUpdates = new ArrayList<>();
@Override
protected void onDestroy() {
super.onDestroy();
// Cancel all pending updates to prevent memory leaks
for (PendingResult<?> pending : pendingUpdates) {
if (!pending.isDone()) {
pending.cancel();
}
}
pendingUpdates.clear();
}
}
Design considerations
-
Target requirement: The target
Environmentis read withEnvironment.getCurrent()on theEnvironmentthread and passed to the background work. Reading it on a background thread returnsnull. -
Memory leak prevention: Always track and cancel
PendingResultobjects in component lifecycle methods. Queued lambdas capture references to UI components, preventing garbage collection if not cancelled. -
FIFO execution: All tasks execute in strict FIFO order regardless of importance. There's no priority system.
-
Cancellation limitations: Cancellation only prevents execution of queued tasks. Tasks already executing will complete normally.
Complete case study: LongTaskView
The following is a complete, production-ready implementation demonstrating all best practices for asynchronous UI updates:
Case study analysis
This implementation demonstrates several critical patterns:
1. Thread pool management
private final ExecutorService executor = Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "LongTaskView-Worker");
t.setDaemon(true);
return t;
});
- Uses a single thread executor to prevent resource exhaustion
- Creates daemon threads that won't prevent JVM shutdown
2. Naming the target
Environment env = Environment.getCurrent();
startLongTask() runs on the Environment thread, so it reads the target there and the background task passes it to every Environment.runLater() call. The same code works unchanged on a shared app-wide thread pool.
3. Tracking pending updates
private final List<PendingResult<?>> pendingUIUpdates = new ArrayList<>();
Every Environment.runLater() call is tracked to enable:
- Cancellation when the user clicks cancel
- Memory leak prevention in
onDestroy() - Proper cleanup during component lifecycle
4. Cooperative cancellation
private volatile boolean isCancelled = false;
The background thread checks this flag at each iteration, enabling:
- Immediate response to cancellation
- Clean exit from the loop
- Prevention of further UI updates
5. Lifecycle management
@Override
protected void onDestroy() {
super.onDestroy();
cancelTask(); // Reuses cancellation logic
currentTask = null;
executor.shutdown();
}
Critical for preventing memory leaks by:
- Cancelling all pending UI updates
- Interrupting running threads
- Shutting down the executor
6. UI responsiveness testing
testButton.onClick(e -> {
int count = clickCount.incrementAndGet();
showToast("Click #" + count + " - UI is responsive!", Theme.GRAY);
});
Demonstrates that the UI thread remains responsive during background operations.


