Live Reload for Server-Side-Rendered JSX in Quarkus
If you render your HTML server-side from JSX/TSX via GraalVM’s JS engine, you already
know the workflow: edit a .tsx file, esbuild bundles it into a single ssr.js,
Quarkus picks it up, and you reload the browser tab by hand to see the result.
That manual reload gets old fast. Here’s how to make the browser refresh itself the moment the bundle changes — without a browser extension, without a live-reload server on some other port, just a small SSE endpoint that Quarkus already knows how to serve.
The pieces
Three small classes and one script tag are all it takes.
1. Watch the bundle file for changes
A @Scheduled bean polls the ssr.js file’s last-modified timestamp once a second.
When it changes, it rebuilds the GraalVM JS pool and notifies the browser:
@IfBuildProfile("dev")
@ApplicationScoped
@Startup
public class JsBundleWatcher {
@Inject DevConfig devConfig;
@Inject JsBundleChangeHandler jsBundleChangeHandler;
private long lastModified = -1;
@Scheduled(every = "1s")
void checkFile() throws Exception {
Path file = Path.of(devConfig.ssr().filename());
if (!Files.exists(file)) return;
long current = Files.getLastModifiedTime(file).toMillis();
if (current != lastModified) {
lastModified = current;
jsBundleChangeHandler.run();
}
}
}
@IfBuildProfile("dev") keeps this out of production builds entirely — it only
exists when you run quarkus:dev.
2. Rebuild the pool and broadcast a reload event
@ApplicationScoped
public class JsBundleChangeHandler implements Runnable {
@Inject JsHolder jsHolder;
@Inject DevReloadSSE reload;
@Override
public void run() {
jsHolder.initPool(); // rebuild the GraalVM engine pool with fresh JS
reload.broadcastReload(); // tell every connected browser to reload
}
}
3. An SSE endpoint the browser can subscribe to
Quarkus makes this trivial with @Route from quarkus-vertx-web — no extra
dependency needed, no WebSocket handshake, just a long-lived HTTP response:
@IfBuildProfile("dev")
@ApplicationScoped
public class DevReloadSSE {
private final Set<HttpServerResponse> clients = ConcurrentHashMap.newKeySet();
@Route(path = "/dev-reload", methods = Route.HttpMethod.GET)
void stream(RoutingContext ctx) {
HttpServerResponse response = ctx.response();
response.putHeader("Content-Type", "text/event-stream");
response.putHeader("Cache-Control", "no-cache");
response.putHeader("Connection", "keep-alive");
response.setChunked(true);
clients.add(response);
response.closeHandler(v -> clients.remove(response));
response.write("event: connected\ndata: ok\n\n");
}
public void broadcastReload() {
clients.forEach(resp -> {
try {
resp.write("event: reload\ndata: now\n\n");
}
catch (Exception e) {
clients.remove(resp);
}
});
}
}
4. Listen for the event in the browser
A tiny script, loaded from your page layout, does the rest:
new EventSource("/dev-reload")
.addEventListener("reload", () => {
console.log("Reload triggered");
location.reload();
});
Include it in your layout template next to your other <script> tags:
<script defer src="/js/dev.js"></script>
Why this shape
- No polling from the browser. SSE keeps a single open connection; the server pushes exactly one event when something actually changed.
- Scoped to dev only.
@IfBuildProfile("dev")means none of this ships in a production JAR or native image — the endpoint simply doesn’t exist outside dev mode. - Decoupled from the JS reload itself.
JsBundleChangeHandlerrebuilds the GraalVM pool and notifies the browser in one place, so the two always stay in sync — the browser never reloads before the new bundle is actually being served. - No extra infrastructure. No separate livereload server, no browser plugin, no WebSocket library — just an HTTP endpoint your app already serves.
Where to see it
This is exactly what’s wired up in
svene/2026-03-15_hda-quarkus-graalvm-jsx-demo,
a demo app that renders JSX server-side via GraalVM inside a Quarkus application.
Look at DevReloadSSE.java, JsBundleWatcher.java, and JsBundleChangeHandler.java
under inbound/web/infra/js, and dev.js under META-INF/resources/js.