Live Reload for Server-Side-Rendered JSX in Spring Boot
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, Spring Boot picks it up, and you reload the browser tab by hand to see
the result.
Spring Boot DevTools ships with its own browser livereload server, but it comes
with a catch: it’s generic. It reloads on any classpath change and needs either
a browser extension or a manually embedded <script src="http://localhost:35729/livereload.js">
tag pointing at a separate port. What you actually want is narrower and simpler —
reload the browser precisely when ssr.js changes, over a connection your app is
already serving.
Here’s how to do that with plain Spring MVC and SseEmitter — no extra
dependency, no separate port.
The pieces
1. Watch the bundle file for changes
A @Scheduled bean (Spring’s own scheduling, already enabled via
@EnableScheduling) polls the ssr.js resource’s last-modified timestamp:
@Component
@Profile("dev")
public class JsBundleWatcher {
private final Resource resource;
private final JsHolder jsHolder;
private final DevReloadSSE devReloadSSE;
private long lastModified = -1;
public JsBundleWatcher(AppConfigProperties appConfigProperties,
JsHolder jsHolder,
DevReloadSSE devReloadSSE) {
this.resource = appConfigProperties.ssr().resource();
this.jsHolder = jsHolder;
this.devReloadSSE = devReloadSSE;
}
@Scheduled(fixedDelay = 500)
public void checkFile() throws Exception {
var file = resource.getFile();
if (!file.exists()) return;
long current = file.lastModified();
if (current != lastModified) {
lastModified = current;
jsHolder.initPool(); // rebuild the GraalVM engine pool
devReloadSSE.broadcastReload(); // tell every connected browser to reload
}
}
}
@Profile("dev") keeps this bean — and the SSE endpoint below — out of any
non-dev profile entirely.
2. An SSE endpoint using Spring MVC’s SseEmitter
No extra dependency needed: SseEmitter ships with spring-boot-starter-webmvc.
@RestController
@Profile("dev")
public class DevReloadSSE {
private final Set<SseEmitter> clients = ConcurrentHashMap.newKeySet();
@GetMapping("/dev-reload")
public SseEmitter stream() {
SseEmitter emitter = new SseEmitter(0L); // no timeout
clients.add(emitter);
emitter.onCompletion(() -> clients.remove(emitter));
emitter.onTimeout(() -> clients.remove(emitter));
emitter.onError(e -> clients.remove(emitter));
try {
emitter.send(SseEmitter.event().name("connected").data("ok"));
}
catch (IOException e) {
clients.remove(emitter);
}
return emitter;
}
public void broadcastReload() {
clients.forEach(emitter -> {
try {
emitter.send(SseEmitter.event().name("reload").data("now"));
}
catch (Exception e) {
emitter.complete();
clients.remove(emitter);
}
});
}
}
3. Listen for the event in the browser
Same tiny script as any SSE-based reload setup:
new EventSource("/dev-reload")
.addEventListener("reload", () => {
console.log("Reload triggered");
location.reload();
});
Wired into your layout template:
<script defer src="/js/dev.js"></script>
Retiring devtools’ own livereload
Once this is in place, Spring Boot DevTools’ built-in livereload server is
redundant — turn it off in application-dev.properties and drop the
livereload.js script tag:
# Browser reload is handled by our own SSE endpoint (DevReloadSSE), not devtools livereload:
spring.devtools.livereload.enabled=false
Keep the rest of DevTools around, though — its classpath-restart feature (JVM
reload on .java changes) is unrelated to browser refresh and still useful:
spring.devtools.restart.exclude=static/fe/**
The restart.exclude entry matters here: without it, every ssr.js rebuild
would also trigger a full Spring context restart, which is both slower and
unnecessary — JsBundleWatcher already handles that file on its own.
Why this shape
- No polling from the browser. One open SSE connection, one push per actual change.
- Scoped to dev only.
@Profile("dev")means the endpoint and the watcher don’t exist in a production build. - Rebuild and notify together.
JsBundleWatcherrebuilds the GraalVM pool and triggers the browser reload in the same step, so the browser never refreshes into a half-updated backend. - No separate infrastructure. No livereload server on another port, no browser plugin — just an HTTP endpoint your app already serves.
Where to see it
This is exactly what’s wired up in
svene/2026-03-09_hda-springboot-graalvm-jsx-demo,
a demo app that renders JSX server-side via GraalVM inside a Spring Boot
application. Look at DevReloadSSE.java and JsBundleWatcher.java under
inbound/web/infra/js, and dev.js under static/js.