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, text/event-stream

HTTP Upgrade, then framed messages (RFC 6455)

Reconnection

Not needed

Built into EventSource, plus the htmx extension’s own back-off

The htmx extension reconnects with full-jitter back-off

Through proxies and firewalls

Trivial

Easy, it is HTTP (buffering must be off)

Needs Upgrade support in every hop

Authentication

Session cookie, CSRF token on unsafe methods

Session cookie (GET)

Session cookie at the handshake, then nothing per message

htmx

Core hx-trigger

Extension htmx-ext-sse

Extension htmx-ext-ws

Spring MVC

Any controller

SseEmitter

TextWebSocketHandler

Spring WebFlux

Any controller

Flux<ServerSentEvent>

WebSocketHandler (reactive API)

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>&lt;script&gt;alert(1)&lt;/script&gt;</strong> by <span>A &amp; 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.

sequenceDiagram participant B as Browser (htmx SSE extension) participant C as LiveController participant H as LiveEvents (emitters) participant E as SpringTemplateEngine B->>C: GET /live/events (EventSource, session cookie) C->>H: subscribe() H-->>B: 200 text/event-stream, connection stays open Note over B,H: later, any user adds a book C->>E: process("live/fragments", book-added, context) E-->>C: li and out-of-band counter, escaped C->>H: publish("book-added", html) H-->>B: event book-added, data lines with the fragment B->>B: swap into #activity, counter replaced by id H-->>B: comment keep-alive every 15 s

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 call complete or completeWithError yourself after an IOException during a send: the container reports the failure through an AsyncListener and 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 EventSource ignores) 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-timeout sets it for the application, and the SseEmitter constructor sets it per emitter (0 means 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);
    }
}

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 &lt;Herbert&gt;");
        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 &lt;Herbert&gt;</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>&lt;b&gt;Emma&lt;/b&gt;</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

hx-ext="sse"

Installs the extension on the element and its descendants (hx-ext is inherited, so it may sit on body).

sse-connect="<url>"

Opens an EventSource to the URL. Must be on the same element as hx-ext or below an element that has it. Query parameters are allowed, and the only way to pass data to the server, because an SSE connection is a GET.

sse-swap="<event name>"

Swaps the data of the named event into this element, using the element’s own hx-swap and hx-target. A comma-separated list listens to several names. An unnamed event is called message, and there is no catch-all name.

hx-trigger="sse:<event name>"

Makes an ordinary htmx request when the event arrives, instead of swapping its data.

sse-close="<event name>"

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" with hx-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 a GET /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 showed Cover cover-test.png accepted (70 bytes).

  • The element where sse-connect lives, not the list, owns the connection: if that element is removed from the page, the extension closes the EventSource.

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:

  1. After a reconnect the server sees a brand-new subscription and nothing is replayed unless you implement Last-Event-ID replay yourself.

  2. When the session has expired, the stream endpoint answers with a redirect to the login page, which EventSource treats 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 with sse-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>

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();
}

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>
  • ws-connect="<url>" (or ws-connect="<prefix>:<url>" with a ws or wss prefix) 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-send on a form (or another element, using its natural trigger or hx-trigger) sends the form’s values to the nearest enclosing WebSocket as JSON, with an extra HEADERS object 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 with hx-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 fires htmx:wsConnecting, htmx:wsOpen, htmx:wsClose, htmx:wsError, htmx:wsBeforeMessage, htmx:wsAfterMessage, htmx:wsConfigSend, htmx:wsBeforeSend and htmx: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 &lt;b&gt;room&lt;/b&gt;</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">

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-configured JsonMapper bean parses the message. The author comes from session.getPrincipal(), the user who logged in before the handshake, never from the message (the HEADERS object 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)> hi and 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. ConcurrentWebSocketSessionDecorator wraps it with a send-time limit (5 seconds) and a buffer-size limit (64 KB), and throws a SessionLimitExceededException for a client that cannot keep up; the handler then drops that session instead of blocking everybody.

  • Registry cleanup in afterConnectionClosed and handleTransportError, 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 Origin header during the handshake: by default only same-origin requests are accepted, setAllowedOrigins takes an explicit list (a * allows every origin, avoid it) and setAllowedOriginPatterns takes patterns. In the tests, a handshake with Origin: https://evil.example failed with HTTP 403.

Authentication

The session cookie authenticates the handshake because the URL is covered by anyRequest().authenticated(); an anonymous handshake is refused. session.getPrincipal() is that user for the lifetime of the socket. A session that expires or a logout does not close an open socket by itself: close sockets on logout, or re-check periodically.

CSRF

Spring Security’s CSRF protection covers unsafe HTTP methods, and a handshake is a GET, so there is no token to send and the Origin check is the cross-site defence. Keep it strict. Never let a WebSocket message change state that a form POST would protect without the same authorisation checks; for sensitive actions prefer an hx-post with the CSRF token.

Message trust

Everything in a message is user input: validate, size-limit and escape. The HEADERS object is informational only.

Sessions in the handler

HttpSessionHandshakeInterceptor copies HTTP session attributes into the WebSocket session if the handler needs them.

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("&lt;img src=x onerror=alert(1)&gt; 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>&lt;img src=x onerror=alert(1)&gt; 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.process returning a String is a blocking call (it may also read the template from disk the first time), so it runs on Schedulers.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 @RequestParam form 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>&lt;script&gt;x&lt;/script&gt;</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-timeout or 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 off on the location, or the upstream header X-Accel-Buffering: no, turns it off. WebSocket proxying needs the Upgrade and Connection headers 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 send blocks while it writes; thousands of idle viewers are a case for WebFlux or virtual threads and for tuning the AsyncTaskExecutor (the Spring documentation says the default one is not suitable for production under load when streaming reactive types). Bound the per-client buffer, as ConcurrentWebSocketSessionDecorator does, 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 th:fragment with SpringTemplateEngine.process(template, Set.of(fragment), context); th:text escapes

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 onCompletion, onTimeout and onError on an SseEmitter

The registry grows with dead emitters and sends keep failing

Register all three callbacks and remove the emitter in each

Calling complete after an IOException from send

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)

sse-swap with a name the server never sends, or ChatUpdate versus ChatroomUpdate

Nothing is swapped and no error is shown

Match the event: name exactly; an unnamed event needs sse-swap="message"

Loading hx-ext="sse" without the htmx-ext-sse script (htmx 2 removed the built-in attributes)

The attributes are silently ignored

Load htmx first, then the extension package, then enable it with hx-ext

Pinning an integrity hash for the wrong file or version

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 id of each out-of-band element or the sse-swap element present in the page

SpringTemplateEngine.process called on an event-loop thread in WebFlux

Blocks the reactor-netty thread on a cache miss

Wrap it in Mono.fromRunnable(...).subscribeOn(Schedulers.boundedElastic())

Relying on a CSRF token for the WebSocket handshake

There is none, the handshake is a GET

Keep the origin allow-list strict and do not use *

Using * in setAllowedOrigins and trusting the cookie

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 HEADERS object in a ws-send message

Impersonation

Take the user from session.getPrincipal() and ignore the rest

Sending from several threads to one standard WebSocketSession

IllegalStateException or corrupted frames

Wrap it in ConcurrentWebSocketSessionDecorator

An SSE endpoint that redirects to a login page after the session expires

EventSource reconnects in a loop

Send sse-close, or redirect the page, not the stream

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 proxy_buffering on in the reverse proxy

Events arrive in bursts or never

Disable buffering for the stream location or send X-Accel-Buffering: no

Upload form without hx-encoding="multipart/form-data"

htmx sends a form-urlencoded body and the file part is missing

Set the attribute on the form (it is inherited) and keep enctype for the no-JavaScript fallback

Version notes

  • htmx 2 moved the SSE and WebSocket support out of the core into the htmx-ext-sse and htmx-ext-ws packages. The legacy hx-sse and hx-ws attributes of htmx 1 are not available in htmx 2; the extension pages contain a migration table from hx-sse="connect:<url>" to sse-connect, and from hx-ws="connect:<url>" to ws-connect.

  • htmx 4 (npm tag next, documentation at four.htmx.org) restructures this area: its documentation lists an hx-sse extension with hx-sse:connect and hx-sse:close attributes, a Last-Event-ID header and sse.reconnect configuration keys. This page does not cover it; read the htmx 4 migration notes before upgrading, since attribute names change. htmx-spring-boot 6.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-websocket module (starter spring-boot-starter-websocket) and Jackson 3 uses the tools.jackson packages, 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.