SE

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. JsBundleWatcher rebuilds 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.