Server-Sent Events & WebSockets
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Thymeleaf 3.1.5.RELEASE (2026-04-21), Spring Boot 4.1.1 (Spring Framework 7.0.9, Spring Security 7.1.1), Java 21, htmx 2.0.11, the htmx SSE extension 2.2.4 and
the htmx WebSocket extension 2.0.4 (the latest releases of all three on 2026-10-08), checked on 2026-10-08. Every example was compiled and run on that stack in a scratch Bookshelf application:
MockMvc and StandardWebSocketClient tests, curl --no-buffer against the running server, and a headless Chromium (driven by Playwright) with two logged-in browsers. The htmx
Spring integration htmx-spring-boot 5.2.0 is used for one annotation (@HxRequest) and is not managed by the Spring Boot BOM, so its version is explicit. The page applies to Thymeleaf
with Spring MVC (SseEmitter and the servlet WebSocket API) and, for the SSE part, with Spring WebFlux (Flux<ServerSentEvent>); Thymeleaf standalone can render the fragments
in the same way, but it has no connections to push to. htmx 4.0.0 is published only under the next tag on npm (documentation at four.htmx.org), and htmx-spring-boot 6.0.0 is not
released yet; both are outside this page’s scope (see Version notes).
A normal request/response page cannot hear from the server until the user asks again. Two browser features remove that limit. Server-Sent Events (SSE) keep one ordinary HTTP response open
and let the server write text/event-stream messages to it; the browser’s EventSource reads them and reconnects by itself. WebSockets upgrade an HTTP request to a two-way message
channel. With Thymeleaf the interesting part is what travels over those channels: HTML fragments rendered by the same templates as the rest of the application, which htmx swaps into the
page. No JSON, no client-side templating, and escaping stays where it belongs, in Thymeleaf. The running scenario is the Bookshelf from the Getting Started page:
a live feed announcing newly added books, a toast when a cover upload is accepted, and a small reading-room chat.
Choosing a transport
| Aspect | Polling (hx-trigger="every 5s") |
Server-Sent Events | WebSocket |
|---|---|---|---|
Direction |
Client asks, server answers |
Server to client only |
Both ways |
Protocol |
Plain HTTP request per poll |
One long-lived HTTP response, |
HTTP Upgrade, then framed messages (RFC 6455) |
Reconnection |
Not needed |
Built into |
The htmx extension reconnects with full-jitter back-off |
Through proxies and firewalls |
Trivial |
Easy, it is HTTP (buffering must be off) |
Needs |
Authentication |
Session cookie, CSRF token on unsafe methods |
Session cookie ( |
Session cookie at the handshake, then nothing per message |
htmx |
Core |
Extension |
Extension |
Spring MVC |
Any controller |
|
|
Spring WebFlux |
Any controller |
|
|
Prefer SSE whenever the page only listens (notifications, progress, a live feed, a dashboard) and send the user’s actions with ordinary hx-post requests, which keep CSRF protection and
all the HTTP tooling. Use a WebSocket when the client really streams to the server (chat, collaborative editing, games). The htmx documentation says the same: SSE is a lightweight alternative that
works over existing HTTP connections, and it is unidirectional.
Rendering a fragment per event
Both transports carry strings. The string should be the HTML of one fragment, produced by the template engine. Fragments are normal th:fragment elements of a template (see
Fragments), and ITemplateEngine.process(String template, Set<String> templateSelectors, IContext context) renders only the elements those selectors
match. A th:fragment name is a valid selector, so a one-line renderer is all the controllers need.
@Component
public class FragmentRenderer {
private static final String TEMPLATE = "live/fragments";
private final SpringTemplateEngine engine;
public FragmentRenderer(SpringTemplateEngine engine) {
this.engine = engine;
}
/** fragment is always a constant chosen by the caller, never request input. */
public String render(String fragment, Map<String, Object> variables) {
Context context = new Context(Locale.ENGLISH, variables);
return engine.process(TEMPLATE, Set.of(fragment), context).strip();
}
}
The Context here is Thymeleaf’s own (org.thymeleaf.context.Context), not a web context. It has no request, session or security objects; whatever the fragment shows must be put into it as
variables, and that is a feature, because the same code runs on a scheduler thread or inside a WebSocket handler where no request exists. If a fragment needs #authentication or #request, render it
inside a request instead (see Fragment Rendering & AJAX).
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<!--/* Appended to #activity; the second element is an out-of-band update of the counter. */-->
<th:block th:fragment="book-added">
<li class="activity">New book <strong th:text="${title}">Title</strong> by <span th:text="${author}">Author</span></li>
<span id="book-count" hx-swap-oob="true" th:text="${count}">0</span>
</th:block>
<div th:fragment="cover-processed" class="toast">
Cover <code th:text="${name}">cover.png</code> accepted (<span th:text="${size}">0</span> bytes)
</div>
<div th:fragment="chat-message" id="chat-room" hx-swap-oob="beforeend">
<p class="msg"><strong th:text="${author}">user</strong>: <span th:text="${text}">hello</span></p>
</div>
<input th:fragment="chat-input" id="chat-input" name="chat_message" autocomplete="off" maxlength="200" hx-swap-oob="true">
</body>
</html>
Rendered with title set to <script>alert(1)</script> and author set to A & B, the engine escapes both values and the markup around them is exactly the template’s:
<li class="activity">New book <strong><script>alert(1)</script></strong> by <span>A & B</span></li>
<span id="book-count" hx-swap-oob="true">3</span>
This is the correction to a pattern in the consulted htmx book (Modern frontends with htmx, v1.0.0): its SSE and upload chapters assemble the HTML they push with a format string around text that
came from the user or from an uploaded file. A format string does no escaping, so a title such as the one above would run as markup in every connected browser, and the push channel multiplies the
damage because one request reaches all subscribers. The fragment approach shown here escapes with th:text, keeps the markup in a template that a designer can open, and can be unit tested. Never build the pushed HTML
by hand, and see Template Security & XSS for the output-context rules.
Two safety rules for this renderer. The fragment name is a constant in the calling code: a selector taken from request input would let a caller render any part of any template (see the section on view names and fragment selectors in Template Security & XSS). And the model contains only values the fragment shows, never entities with lazy associations, because rendering happens on whatever thread publishes the event, possibly outside any request and its open session.
Server-Sent Events with Spring MVC
SseEmitter (a subclass of ResponseBodyEmitter) is the servlet-stack way to hold an SSE response open. A controller returns it, keeps a reference, and any thread may call send later. The wire
format is the W3C one: an optional id, an optional event name, and one or more data lines, ended by an empty line.
The connection registry
@Component
public class LiveEvents {
private final Set<SseEmitter> emitters = ConcurrentHashMap.newKeySet();
private final AtomicLong ids = new AtomicLong();
public SseEmitter subscribe() {
SseEmitter emitter = new SseEmitter(Duration.ofMinutes(30).toMillis());
emitters.add(emitter);
Runnable forget = () -> emitters.remove(emitter);
emitter.onCompletion(forget);
emitter.onTimeout(forget);
emitter.onError(error -> forget.run());
return emitter;
}
public void publish(String eventName, String html) {
String id = Long.toString(ids.incrementAndGet());
for (SseEmitter emitter : emitters) {
try {
emitter.send(SseEmitter.event().id(id).name(eventName).data(html));
} catch (IOException | IllegalStateException e) {
emitters.remove(emitter);
}
}
}
/** A comment line keeps proxies from closing an idle stream and reveals dead clients. */
@Scheduled(fixedRate = 15_000)
void heartbeat() {
for (SseEmitter emitter : emitters) {
try {
emitter.send(SseEmitter.event().comment("keep-alive"));
} catch (IOException | IllegalStateException e) {
emitters.remove(emitter);
}
}
}
}
Three details matter in production:
-
Cleanup. The three callbacks (
onCompletion,onTimeout,onError) remove the emitter from the registry. Without them every closed tab leaves a dead emitter behind. The Spring documentation also says not to callcompleteorcompleteWithErroryourself after anIOExceptionduring a send: the container reports the failure through anAsyncListenerand Spring MVC completes the request. -
A heartbeat. The Servlet API does not notify you when a client goes away, so a write is the only way to find out. The documentation recommends sending data periodically, and a comment-only event (a line starting with a colon, which
EventSourceignores) is the cheapest form. It also keeps idle-timeout proxies from cutting the stream. The scheduled method needs@EnableScheduling(it sits on the WebSocket configuration below). The htmx SSE extension has no heartbeat of its own; it only reconnects. -
An explicit timeout. The default async timeout depends on the servlet container; the Boot property
spring.mvc.async.request-timeoutsets it for the application, and theSseEmitterconstructor sets it per emitter (0means no timeout, which is rarely a good idea). When it fires, the browser reconnects and the registry gets a fresh emitter.
The controller
@Controller
public class LiveController {
private final BookRepository books;
private final LiveEvents events;
private final FragmentRenderer renderer;
// constructor omitted
@GetMapping("/live")
public String page(Model model) {
model.addAttribute("count", books.count());
return "live/index";
}
@GetMapping(path = "/live/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter stream() {
return events.subscribe();
}
@HxRequest
@PostMapping("/live/books")
public ResponseEntity<Void> addBookHtmx(@RequestParam String title, @RequestParam String author) {
publishNewBook(title, author);
return ResponseEntity.noContent().build();
}
@PostMapping("/live/books")
public String addBook(@RequestParam String title, @RequestParam String author) {
publishNewBook(title, author);
return "redirect:/live";
}
private void publishNewBook(String title, String author) {
Book book = new Book();
book.setTitle(title);
book.setAuthor(author);
books.save(book);
String html = renderer.render("book-added",
Map.of("title", title, "author", author, "count", books.count()));
events.publish("book-added", html);
}
}
Source: htmx-spring-boot — README
The state change is a POST (so Spring Security’s CSRF check applies), the htmx variant answers 204 No Content because the page will be updated by the stream and not by this response, and the same URL still
works without JavaScript, where it redirects back to the page (see htmx with Spring Boot for @HxRequest). title and author should also be validated (@NotBlank, a maximum length)
like in any form handler; the example keeps the controller short.
Testing the stream with MockMvc
MockMvc supports asynchronous requests: the stream stays open, the test publishes through the normal endpoint, and the response body can be read as it grows.
@SpringBootTest
@AutoConfigureMockMvc
@WithMockUser
class LiveEventsTest {
@Autowired MockMvc mvc;
@Autowired LiveEvents events;
@Test
void streamOpensAsyncAndReceivesPublishedFragments() throws Exception {
int before = events.subscribers();
MvcResult result = mvc.perform(get("/live/events").accept(MediaType.TEXT_EVENT_STREAM))
.andExpect(request().asyncStarted()).andReturn();
assertThat(events.subscribers()).isEqualTo(before + 1);
mvc.perform(post("/live/books").with(csrf()).header("HX-Request", "true")
.param("title", "Dune").param("author", "Frank <Herbert>"))
.andExpect(status().isNoContent());
String body = result.getResponse().getContentAsString();
assertThat(body).contains("event:book-added").containsPattern("id:\\d+").contains("data:")
.contains("Dune").contains("Frank <Herbert>");
assertThat(result.getResponse().getContentType()).startsWith("text/event-stream");
}
@Test
void postWithoutCsrfIsRejected() throws Exception {
mvc.perform(post("/live/books").param("title", "x").param("author", "y")).andExpect(status().isForbidden());
}
}
The response body of that test, as MockMvc captured it:
id:2
event:book-added
data:
data: <li class="activity">New book <strong>Dune</strong> by <span>Frank <Herbert></span></li>
data: <span id="book-count" hx-swap-oob="true">1</span>
data:
SseEmitter splits a multi-line string into one data: line per line, and the browser joins them again with newlines, so a fragment may span lines. The id field makes the browser send Last-Event-ID when it
reconnects; the application can use it to replay what was missed, but nothing in this example does.
Watching the stream with curl
The same endpoint against the running application. curl logs in through the form (Spring Security’s CSRF token is a hidden field of the login page), opens the stream with --no-buffer in the background, and posts two books
and an upload.
JAR=cookies.txt
TOKEN=$(curl -s -c $JAR http://localhost:8080/login | grep -o 'name="_csrf"[^>]*' | grep -o 'value="[^"]*"' | cut -d'"' -f2)
curl -s -b $JAR -c $JAR -o /dev/null -d "username=reader&password=change-me&_csrf=$TOKEN" http://localhost:8080/login
curl -s --no-buffer -b $JAR -H 'Accept: text/event-stream' http://localhost:8080/live/events &
PAGE_TOKEN=$(curl -s -b $JAR -c $JAR http://localhost:8080/live | grep -o 'name="_csrf"[^>]*' | head -1 | grep -o 'value="[^"]*"' | cut -d'"' -f2)
curl -s -b $JAR -H 'HX-Request: true' -d "title=Dune&author=Frank%20Herbert&_csrf=$PAGE_TOKEN" http://localhost:8080/live/books
curl -s -b $JAR -d "title=%3Cb%3EEmma%3C/b%3E&author=Jane%20Austen&_csrf=$PAGE_TOKEN" http://localhost:8080/live/books
curl -s -b $JAR -H 'HX-Request: true' -F "cover=@cover.png;type=image/png" -F "_csrf=$PAGE_TOKEN" http://localhost:8080/live/covers
The password is the placeholder of the scratch application’s application.properties; never commit real credentials. The HTTP statuses were 204 for the htmx post, 302 for the plain post, 202 for the
upload, and 403 for a post without a CSRF token. What the stream printed, with the heartbeat after 15 seconds:
id:1
event:book-added
data:<li class="activity">New book <strong>Dune</strong> by <span>Frank Herbert</span></li>
data: <span id="book-count" hx-swap-oob="true">1</span>
id:2
event:book-added
data:<li class="activity">New book <strong><b>Emma</b></strong> by <span>Jane Austen</span></li>
data: <span id="book-count" hx-swap-oob="true">2</span>
id:3
event:cover-processed
data:<div class="toast">
data: Cover <code>cover.png</code> accepted (<span>6</span> bytes)
data:</div>
:keep-alive
The <b>Emma</b> title travelled as text, as it should.
The htmx SSE extension
Since htmx 2 the extensions are separate packages (the built-in hx-sse and hx-ws attributes of htmx 1 are gone). The SSE extension is published on npm as htmx-ext-sse, 2.2.4 at the time of writing, and
the page that uses it loads htmx core first, then the extension, then enables it with hx-ext. The integrity values below are the SHA-384 digests of exactly these files, computed with
curl <url> | openssl dgst -sha384 -binary | openssl base64 -A and identical to the ones printed on the official extension pages. For production, copy the files into the project (the webjar or npm routes
are on the htmx Fundamentals page) and do not depend on a CDN.
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.11/dist/htmx.min.js"
integrity="sha384-2OatzQy1H+Zd/IIrjr1TcuDGqLXeHhbooAyJY1KdQMKnr4LZ22k31GBLdYKHmVjg" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/htmx-ext-sse@2.2.4"
integrity="sha384-A986SAtodyH8eg8x8irJnYUk7i9inVQqYigD6qZ9evobksGNIXfeFvDwLSHcp31N" crossorigin="anonymous"></script>
The attributes of the extension:
| Attribute | Meaning |
|---|---|
|
Installs the extension on the element and its descendants ( |
|
Opens an |
|
Swaps the data of the named event into this element, using the element’s own |
|
Makes an ordinary htmx request when the event arrives, instead of swapping its data. |
|
Closes the stream gracefully when that event is received, for streams that end (a finished job). |
The Bookshelf live page puts them together. One container opens the connection; each child listens for one event name.
<section hx-ext="sse" sse-connect="/live/events">
<h1>Live shelf (<span id="book-count" th:text="${count}">0</span> books)</h1>
<p id="summary" th:hx-get="@{/live/summary}" hx-trigger="sse:book-added">Waiting for news</p>
<ul id="activity" sse-swap="book-added" hx-swap="beforeend"></ul>
<div id="toasts" sse-swap="cover-processed" hx-swap="beforeend"></div>
</section>
What each part does, as verified in two browsers:
-
sse-swap="book-added"withhx-swap="beforeend"appends each pushed<li>to the list. The event also carries the counter<span id="book-count" hx-swap-oob="true">; htmx handles the out-of-band element exactly as in a normal response, replaces the heading’s counter in place, and does not leave it in the list. -
hx-trigger="sse:book-added"on the paragraph turns the same event into aGET /live/summary; the response (plain text,1 books on the shelf) replaces the paragraph’s content. It is a useful pattern when the event is only a signal and the page should fetch fresh, authorised HTML itself. -
sse-swap="cover-processed"collects toasts. In the browser the toast showedCover cover-test.png accepted (70 bytes). -
The element where
sse-connectlives, not the list, owns the connection: if that element is removed from the page, the extension closes theEventSource.
The extension also dispatches events you can listen to for status banners or metrics: htmx:sseOpen, htmx:sseError, htmx:sseBeforeMessage (cancel it with preventDefault() to skip the swap),
htmx:sseMessage and htmx:sseClose.
Reconnection
Browsers reconnect a dropped EventSource by themselves. For the rare cases where the browser gives up, the extension adds its own exponential back-off: in version 2.2.4’s source it retries after 0.5 s,
then 1 s, 2 s and so on, doubling up to a 64-second ceiling, and resets the counter after a successful open. Because the extension creates the EventSource with credentials, the session cookie goes along.
Two consequences for the server:
-
After a reconnect the server sees a brand-new subscription and nothing is replayed unless you implement
Last-Event-IDreplay yourself. -
When the session has expired, the stream endpoint answers with a redirect to the login page, which
EventSourcetreats as a failed connection; the extension then keeps retrying with its back-off. Redirect the page itself to the login (see the session-expiry part of Spring Security Integration) or close the stream withsse-close.
Three connected tabs mean three connections. Over HTTP/1.1 a browser allows about six connections per host in total, which is a real limit for SSE and one of the reasons to serve the site over HTTP/2, where the number of concurrent streams is negotiated and defaults to 100 (MDN).
Upload with hx-encoding and hx-disabled-elt
The book’s upload chapter pairs a file input with SSE. The file travels in an ordinary multipart/form-data request, and the result reaches every viewer through the stream. htmx sends form-encoded
bodies unless told otherwise, so the form declares hx-encoding="multipart/form-data" (the attribute is inherited and may be placed on a parent). hx-disabled-elt puts the disabled attribute on the
listed elements for the duration of the request: here the form’s own button, found with find button, so a second click cannot start a second upload.
<form th:action="@{/live/covers}" method="post" enctype="multipart/form-data"
th:hx-post="@{/live/covers}" hx-encoding="multipart/form-data" hx-swap="none" hx-disabled-elt="find button">
<input type="file" name="cover" accept="image/png,image/jpeg" required>
<button type="submit">Upload cover</button>
</form>
Source: htmx — hx-encoding
th:action is what makes Thymeleaf add the hidden _csrf input to the form, and htmx submits the form’s fields with the request, so the token travels as a form field and the upload is covered by CSRF protection. The enctype and
action attributes keep the form working without JavaScript. The controller validates the upload before announcing it:
private static final Set<String> COVER_TYPES = Set.of("image/png", "image/jpeg");
@PostMapping("/live/covers")
public ResponseEntity<Void> uploadCover(@RequestParam("cover") MultipartFile cover) {
if (cover.isEmpty() || !COVER_TYPES.contains(cover.getContentType())) {
return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE).build();
}
String name = cover.getOriginalFilename() == null ? "cover" : cover.getOriginalFilename();
String html = renderer.render("cover-processed", Map.of("name", name, "size", cover.getSize()));
events.publish("cover-processed", html);
return ResponseEntity.accepted().build();
}
Source: htmx — hx-disabled-elt
The response is 202 Accepted with no body, because the visible result arrives on the stream. The file name is client-supplied data, so it only goes into the model and is displayed with th:text
(the test uploads a file named dune<1>.png and finds dune<1>.png in the stream); it is never used to build a path. The content type sent by the client is also only a claim: store real uploads under generated names and
check the content (magic bytes) before processing. Spring Boot’s defaults are spring.servlet.multipart.max-file-size=1MB and spring.servlet.multipart.max-request-size=10MB; larger covers need both raised. With
the heavy work (resizing, scanning) done on another thread, events.publish is the last step of that thread, which is where the push model pays off.
The htmx WebSocket extension
The WebSocket extension is htmx-ext-ws (2.0.4). The load order is the same as for SSE, and the attributes are two.
<script src="https://cdn.jsdelivr.net/npm/htmx-ext-ws@2.0.4"
integrity="sha384-1RwI/nvUSrMRuNj7hX1+27J8XDdCoSLf0EjEyF69nacuWyiJYoQ/j39RT1mSnd2G" crossorigin="anonymous"></script>
<section hx-ext="ws" ws-connect="/live/ws/chat">
<h2>Reading room</h2>
<div id="chat-room"></div>
<form id="chat-form" ws-send>
<input id="chat-input" name="chat_message" autocomplete="off" maxlength="200">
<button type="submit">Send</button>
</form>
</section>
Source: htmx — Web Socket extension
-
ws-connect="<url>"(orws-connect="<prefix>:<url>"with awsorwssprefix) opens the socket. Without a prefix, htmx adds the page’s scheme, host and port, so the browser sends the cookies of the page. -
ws-sendon a form (or another element, using its natural trigger orhx-trigger) sends the form’s values to the nearest enclosing WebSocket as JSON, with an extraHEADERSobject holding the headers htmx would have sent on an HTTP request. -
Every message the server sends is parsed as HTML and swapped by
id, with the same logic as an out-of-band swap, so a bare<form id="form">replaces the element with that id. To append, the message itself must say so withhx-swap-oob="beforeend"; that is why the chat fragments above carry the attribute. -
If the socket closes unexpectedly the extension reconnects with a full-jitter exponential back-off (replaceable through
htmx.config.wsReconnectDelay) and queues outgoing messages while the socket is not open. The extension fireshtmx:wsConnecting,htmx:wsOpen,htmx:wsClose,htmx:wsError,htmx:wsBeforeMessage,htmx:wsAfterMessage,htmx:wsConfigSend,htmx:wsBeforeSendandhtmx:wsAfterSend.
Out-of-band swaps for chat-like updates
hx-swap-oob lets one message update several places. Its value is true (or outerHTML: replace the element with that id), any hx-swap strategy, or a strategy followed by a colon and a CSS selector. For every strategy other than true or
outerHTML, the wrapping tag is stripped and its content is swapped in, so the wrapper must be a tag that is valid in the target (a tr needs a tbody or a template around it). The chat uses two messages:
<!-- broadcast to everybody: append to the existing #chat-room -->
<div id="chat-room" hx-swap-oob="beforeend">
<p class="msg"><strong>reader</strong>: <span>Hello <b>room</b></span></p>
</div>
<!-- sent only to the author: replace the input with an empty one -->
<input id="chat-input" name="chat_message" autocomplete="off" maxlength="200" hx-swap-oob="true">
Source: htmx — hx-swap-oob
Both are rendered from the chat-message and chat-input fragments shown earlier; that is the real output of the handler below for the text Hello <b>room</b>. The second message is the usual way to clear a form
that is not replaced by an HTTP response.
A Spring WebSocket handler
Spring MVC’s servlet WebSocket support lives in spring-websocket, brought by the starter spring-boot-starter-websocket (Boot manages the version). A handler extends TextWebSocketHandler; the registration implements
WebSocketConfigurer on a class annotated with @EnableWebSocket.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
@Configuration
@EnableWebSocket
@EnableScheduling
public class WebSocketConfig implements WebSocketConfigurer {
private final ChatHandler chatHandler;
public WebSocketConfig(ChatHandler chatHandler) {
this.chatHandler = chatHandler;
}
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(chatHandler, "/live/ws/chat")
// same-origin only is the default; list the origins explicitly when you serve the page elsewhere
.setAllowedOrigins("http://localhost:8080", "https://books.example.com");
}
}
@Component
public class ChatHandler extends TextWebSocketHandler {
private static final int MAX_LENGTH = 200;
private final Map<String, WebSocketSession> sessions = new ConcurrentHashMap<>();
private final FragmentRenderer renderer;
private final JsonMapper json;
public ChatHandler(FragmentRenderer renderer, JsonMapper json) {
this.renderer = renderer;
this.json = json;
}
@Override
public void afterConnectionEstablished(WebSocketSession session) {
// the standard session cannot send from two threads at once
sessions.put(session.getId(), new ConcurrentWebSocketSessionDecorator(session, 5_000, 64 * 1024));
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws IOException {
// htmx ws-send posts the form values as JSON, plus a HEADERS object that we ignore
JsonNode body = json.readTree(message.getPayload());
String text = body.path("chat_message").asString("").strip();
if (text.isEmpty() || text.length() > MAX_LENGTH) {
return;
}
String author = session.getPrincipal() == null ? "anonymous" : session.getPrincipal().getName();
String html = renderer.render("chat-message", Map.of("author", author, "text", text));
broadcast(html);
// the sender also gets an empty input back, swapped out-of-band
sendTo(session, renderer.render("chat-input", Map.of()));
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
sessions.remove(session.getId());
}
@Override
public void handleTransportError(WebSocketSession session, Throwable exception) throws IOException {
sessions.remove(session.getId());
session.close(CloseStatus.SERVER_ERROR);
}
private void broadcast(String html) {
sessions.forEach((id, s) -> send(id, s, html));
}
private void sendTo(WebSocketSession session, String html) {
WebSocketSession s = sessions.get(session.getId());
if (s != null) {
send(session.getId(), s, html);
}
}
private void send(String id, WebSocketSession target, String html) {
try {
target.sendMessage(new TextMessage(html));
} catch (IOException | RuntimeException e) {
sessions.remove(id);
}
}
}
Points worth reading twice:
-
JSON in, HTML out. Spring Boot 4 uses Jackson 3, whose packages start with
tools.jackson; the auto-configuredJsonMapperbean parses the message. The author comes fromsession.getPrincipal(), the user who logged in before the handshake, never from the message (theHEADERSobject and every field of the message are client-controlled). -
Escaping and limits. The text goes into the model and is rendered with
th:text(the test sends<img src=x onerror=alert(1)> hiand every receiver gets<img src=x onerror=alert(1)> hi). The handler also ignores empty and over-long messages; add a rate limit per session for anything public. -
Concurrent sends. The standard (Jakarta) WebSocket session does not allow two threads to send at once.
ConcurrentWebSocketSessionDecoratorwraps it with a send-time limit (5 seconds) and a buffer-size limit (64 KB), and throws aSessionLimitExceededExceptionfor a client that cannot keep up; the handler then drops that session instead of blocking everybody. -
Registry cleanup in
afterConnectionClosedandhandleTransportError, as with emitters.
Origin allow-list, authentication and CSRF
The handshake is an ordinary GET with an Upgrade header, and that is why three things behave differently from a form post.
| Concern | What applies |
|---|---|
Origin |
Browsers do not apply the same-origin policy to WebSockets, so any web page can try to open a socket to your server with the visitor’s cookies. Spring checks the |
Authentication |
The session cookie authenticates the handshake because the URL is covered by |
CSRF |
Spring Security’s CSRF protection covers unsafe HTTP methods, and a handshake is a |
Message trust |
Everything in a message is user input: validate, size-limit and escape. The |
Sessions in the handler |
|
Testing the handler
A Spring StandardWebSocketClient against a random-port server proves the handshake rules and the HTML that is sent. The test logs in through the form with the JDK’s HttpClient, as a browser would, and reuses the session cookie.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = { "spring.security.user.name=reader", "spring.security.user.password=change-me" })
class ChatWebSocketTest {
@LocalServerPort int port;
private WebSocketSession connect(String origin, String cookie, BlockingQueue<String> inbox) throws Exception {
WebSocketHttpHeaders headers = new WebSocketHttpHeaders();
headers.add("Origin", origin);
if (cookie != null) {
headers.add("Cookie", cookie);
}
return new StandardWebSocketClient().execute(new TextWebSocketHandler() {
@Override
protected void handleTextMessage(WebSocketSession s, TextMessage message) {
inbox.add(message.getPayload());
}
}, headers, URI.create("ws://localhost:" + port + "/live/ws/chat")).get(5, TimeUnit.SECONDS);
}
@Test
void messageIsRenderedEscapedAndBroadcastWithOobSwap() throws Exception {
String cookie = loginCookie(); // GET /login, read _csrf, POST /login, return the JSESSIONID cookie
BlockingQueue<String> alice = new LinkedBlockingQueue<>();
BlockingQueue<String> bob = new LinkedBlockingQueue<>();
WebSocketSession a = connect("http://localhost:8080", cookie, alice);
WebSocketSession b = connect("http://localhost:8080", cookie, bob);
// the JSON htmx ws-send produces: the form fields plus a HEADERS object
a.sendMessage(new TextMessage("{\"chat_message\":\"<img src=x onerror=alert(1)> hi\","
+ "\"HEADERS\":{\"HX-Request\":\"true\"}}"));
String broadcast = bob.poll(5, TimeUnit.SECONDS);
assertThat(broadcast).contains("hx-swap-oob=\"beforeend\"").contains("<strong>reader</strong>")
.contains("<img src=x onerror=alert(1)> hi").doesNotContain("<img");
assertThat(alice.poll(5, TimeUnit.SECONDS)).isEqualTo(broadcast);
assertThat(alice.poll(5, TimeUnit.SECONDS)).contains("id=\"chat-input\"").contains("hx-swap-oob=\"true\"");
assertThat(bob.poll(300, TimeUnit.MILLISECONDS)).isNull(); // the reset went to the author only
}
@Test
void foreignOriginIsRefused() throws Exception {
String cookie = loginCookie();
assertThatThrownBy(() -> connect("https://evil.example", cookie, new LinkedBlockingQueue<>()))
.hasStackTraceContaining("403");
}
@Test
void anonymousHandshakeIsRefused() {
assertThatThrownBy(() -> connect("http://localhost:8080", null, new LinkedBlockingQueue<>()))
.isNotNull();
}
}
All three tests passed. The captured messages were:
<div id="chat-room" hx-swap-oob="beforeend">
<p class="msg"><strong>reader</strong>: <span><img src=x onerror=alert(1)> hi</span></p>
</div>
<input id="chat-input" name="chat_message" autocomplete="off" maxlength="200" hx-swap-oob="true">
In a browser test with two logged-in contexts, a message typed in the first appeared in the second as escaped text (the <b> in Hello <b>room</b> produced no bold element), and the sender’s input was emptied by the second message.
Server-Sent Events with Spring WebFlux
With WebFlux the controller returns a Flux<ServerSentEvent<String>> (or a Flux<String> with produces = text/event-stream, in which case each element becomes the data of an unnamed event). The wrapper is
needed to set the event name, the id or a comment. There is no emitter registry: a Sinks.Many multicasts to all subscribers, and a subscriber disappears when its connection is cancelled. This snippet was compiled and tested in a copy of the
WebFlux scratch application (see Reactive Rendering with WebFlux for the rest of the reactive integration).
@RestController
public class LiveController {
private final SpringTemplateEngine engine;
// one multicast sink, every subscriber sees the events published after it subscribed
private final Sinks.Many<ServerSentEvent<String>> sink = Sinks.many().multicast().directBestEffort();
public LiveController(SpringTemplateEngine engine) {
this.engine = engine;
}
@GetMapping(path = "/live/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> events() {
// the first comment is written at once, so the response headers reach the client before any event
Flux<ServerSentEvent<String>> heartbeat = Flux.interval(Duration.ZERO, Duration.ofSeconds(15))
.map(tick -> ServerSentEvent.<String>builder().comment("keep-alive").build());
return Flux.merge(sink.asFlux(), heartbeat);
}
@PostMapping("/live/books")
@ResponseStatus(HttpStatus.NO_CONTENT)
public Mono<Void> addBook(NewBook form) {
return Mono.fromRunnable(() -> {
Context context = new Context(Locale.ENGLISH);
context.setVariable("title", form.title());
context.setVariable("author", form.author());
String html = engine.process("fragments", Set.of("book-added"), context).strip();
sink.tryEmitNext(ServerSentEvent.<String>builder(html).event("book-added").build());
}).subscribeOn(Schedulers.boundedElastic()).then();
}
/** Bound from the form fields (a WebFlux controller binds form data through a model attribute). */
public record NewBook(String title, String author) { }
}
Notes on this snippet:
-
SpringTemplateEngine.processreturning aStringis a blocking call (it may also read the template from disk the first time), so it runs onSchedulers.boundedElastic(), not on a Netty event-loop thread. For a whole page rendered reactively use the view resolver and the data-driver variables described on the reactive page. -
The comment emitted at subscription time matters: a Flux that emits nothing for a while does not send the response headers, so a client (or a test) would wait for the first element.
-
A WebFlux handler binds form data into a model attribute, here a record; the MVC-style
@RequestParamform binding did not bind the form fields in this setup, which is why the record is used.
The same escaping guarantee holds. The test posts the title <script>x</script> and receives:
EVENT book-added >>> <li class="activity">New book <strong><script>x</script></strong> by <span>Ursula K. Le Guin</span></li>
and curl --no-buffer against the running application printed the stream as it happened:
:keep-alive
event:book-added
data:<li class="activity">New book <strong>Solaris</strong> by <span>Stanislaw Lem</span></li>
The browser side is unchanged: the same sse-connect and sse-swap attributes work against a WebFlux server. (This scratch application has no Spring Security, so its post carried no CSRF token; a real one uses the
same security rules as the MVC variant.)
Scaling considerations
The examples keep the connection registry in the memory of one JVM. That is fine for one instance and breaks as soon as there are two, because a book added through instance A is announced only to the browsers connected to A. The topics of live chat, video and large-scale WebSocket back ends are developed in the separate chat, videoconference and WebSockets work item (#264); the points below are what matters for a Thymeleaf application.
-
Fan-out across instances. Publish events to a shared channel (a message broker, Redis pub/sub, or the database) and let every instance forward them to its own subscribers. Spring’s STOMP support has a broker relay for the same purpose. See the Messaging Reference for brokers, and compare how Vaadin Server Push and Laravel Broadcasting and Reverb approach the same problem.
-
Sticky sessions. A WebSocket or SSE connection stays on one instance for its whole life, which is fine, but the reconnect after a drop must reach an instance that can authenticate the user: use sticky routing or a shared session store.
-
Timeouts. Every hop has an idle timeout. nginx closes a proxied connection after
proxy_read_timeout, 60 seconds by default, when the upstream sends nothing; the 15-second heartbeat above exists for this. The servlet async timeout (spring.mvc.async.request-timeoutor the emitter’s own) and the WebSocket idle timeout of the server (see the Spring documentation on configuring the server) must be consistent with the heartbeat and with the reconnection delay of the extension. -
Proxy buffering and upgrade. A proxy that buffers responses delays or swallows events. For nginx,
proxy_buffering offon the location, or the upstream headerX-Accel-Buffering: no, turns it off. WebSocket proxying needs theUpgradeandConnectionheaders passed explicitly, because they are hop-by-hop headers. -
Threads and memory. On the servlet stack each open SSE response holds a connection, and the thread that calls
sendblocks while it writes; thousands of idle viewers are a case for WebFlux or virtual threads and for tuning theAsyncTaskExecutor(the Spring documentation says the default one is not suitable for production under load when streaming reactive types). Bound the per-client buffer, asConcurrentWebSocketSessionDecoratordoes, so one slow client cannot exhaust memory. -
Cleanup and shutdown. Remove emitters and sessions on completion, error and timeout (shown above), and complete the emitters on application shutdown so clients reconnect to a healthy instance instead of waiting for a timeout.
-
Per-user events. The registry above broadcasts to everyone. For private events keep a map from user to emitters and render the fragment with only that user’s data; never trust a client-supplied "channel" parameter to choose whose events to receive.
-
Browser limits. About six HTTP/1.1 connections per host are shared by all tabs, so SSE over HTTP/1.1 does not scale to many tabs; use HTTP/2.
Pitfalls
| Mistake | Effect | Fix |
|---|---|---|
Building the pushed HTML with string formatting around user or uploaded text |
Stored cross-site scripting delivered to every subscriber |
Render a |
Fragment name or template taken from a request parameter |
A caller can render arbitrary template parts |
Use constant fragment names in the code that publishes events |
Forgetting |
The registry grows with dead emitters and sends keep failing |
Register all three callbacks and remove the emitter in each |
Calling |
Double completion errors; the container already reports the failure |
Just remove the emitter and return |
No heartbeat |
Dead clients are noticed late; proxies close idle streams |
Send a comment event on a schedule (15 to 30 seconds) |
|
Nothing is swapped and no error is shown |
Match the |
Loading |
The attributes are silently ignored |
Load htmx first, then the extension package, then enable it with |
Pinning an |
The browser refuses the script |
Compute it from the exact pinned URL, or self-host the files |
Sending events without a swap target that exists |
The message is dropped by htmx |
Make the |
|
Blocks the reactor-netty thread on a cache miss |
Wrap it in |
Relying on a CSRF token for the WebSocket handshake |
There is none, the handshake is a |
Keep the origin allow-list strict and do not use |
Using |
Any website can open an authenticated socket as the visitor |
List the origins, or leave the same-origin default |
Trusting the author name or the |
Impersonation |
Take the user from |
Sending from several threads to one standard |
|
Wrap it in |
An SSE endpoint that redirects to a login page after the session expires |
|
Send |
Keeping the registry in memory with several instances |
Events reach only the browsers connected to the instance that handled the request |
Publish through a shared broker and fan out per instance |
Leaving |
Events arrive in bursts or never |
Disable buffering for the stream location or send |
Upload form without |
htmx sends a form-urlencoded body and the file part is missing |
Set the attribute on the form (it is inherited) and keep |
Version notes
-
htmx 2 moved the SSE and WebSocket support out of the core into the
htmx-ext-sseandhtmx-ext-wspackages. The legacyhx-sseandhx-wsattributes of htmx 1 are not available in htmx 2; the extension pages contain a migration table fromhx-sse="connect:<url>"tosse-connect, and fromhx-ws="connect:<url>"tows-connect. -
htmx 4 (npm tag
next, documentation at four.htmx.org) restructures this area: its documentation lists anhx-sseextension withhx-sse:connectandhx-sse:closeattributes, aLast-Event-IDheader andsse.reconnectconfiguration keys. This page does not cover it; read the htmx 4 migration notes before upgrading, since attribute names change.htmx-spring-boot6.0.0, the release aimed at htmx 4, was not published on 2026-10-08. -
Spring Boot 4 moved the WebSocket auto-configuration into the
spring-boot-websocketmodule (starterspring-boot-starter-websocket) and Jackson 3 uses thetools.jacksonpackages, as the handler above shows. -
The consulted edition of Modern frontends with htmx (Wim Deblauwe, v1.0.0, 2023) targets htmx 1.9 with the extensions loaded from a combined script, Spring Boot 3 and Spring Framework 6; the ideas carry over. Its handling of pushed text with format strings is replaced here by fragments, and its in-code credentials by placeholders.