Reactive Rendering with WebFlux
|
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) and Java 21, checked on 2026-10-08, and the
examples were compiled and run on that stack in a scratch WebFlux Bookshelf application (Maven wrapper, JDK 26 with --release 21, Netty). The page
applies to Thymeleaf with Spring WebFlux (the reactive stack): standalone Thymeleaf and Spring MVC have no reactive view classes, and the servlet
view resolver is covered on Spring MVC Integration. The output shown is real: the timings come from
curl --no-buffer piped through a small script that prefixes every line with the seconds elapsed since the request started, the chunk sizes come from
reading the raw socket, and 19 tests (all green) back the statements about modes, properties and events.
Thymeleaf is a template engine, so the interesting question in a reactive application is not whether it can render a page but when the first
bytes leave the server. A servlet view renders after the controller has finished, and the controller has to have all its data. With WebFlux the
data may still be arriving from a database or a remote service when rendering starts, and Thymeleaf offers three ways to deal with that:
wait for everything and send one buffer (FULL), render everything and send it in bounded pieces as the client asks for them (CHUNKED), or
send the markup around a loop at once and the loop body piece by piece as the data arrives (DATA-DRIVEN). The running scenario is the
Bookshelf introduced on Getting Started, with a catalogue that is read from a slow reactive source.
The Thymeleaf 3.1 Thymeleaf + Spring tutorial has no WebFlux chapter, so the facts here come from the thymeleaf-spring6 javadoc, the Spring
Framework and Spring Boot reference, and the runs described above.
The three classes
The Spring Framework reference says that the WebFlux integration is maintained by the Thymeleaf project and that the configuration is a few bean
declarations: a SpringResourceTemplateResolver, a SpringWebFluxTemplateEngine and a ThymeleafReactiveViewResolver.
| Class | Package | What it does |
|---|---|---|
|
|
Extends |
|
|
The WebFlux |
|
|
The |
|
|
The wrapper that puts a view into DATA-DRIVEN mode (implements |
What Spring Boot configures
In a reactive web application the ThymeleafAutoConfiguration of Spring Boot 4.1.1 creates the two beans for you. A test that prints the beans and
reads the resolver back shows what the properties of the next section turn into:
@SpringBootTest
class AutoConfigTest {
@Autowired
ApplicationContext context;
@Test
void bootRegistersTheReactiveEngineAndResolverFromTheProperties() {
assertThat(context.getBean(ISpringWebFluxTemplateEngine.class)).isExactlyInstanceOf(SpringWebFluxTemplateEngine.class);
ThymeleafReactiveViewResolver resolver = context.getBean(ThymeleafReactiveViewResolver.class);
assertThat(resolver.getResponseMaxChunkSizeBytes()).isEqualTo(1024);
assertThat(resolver.getFullModeViewNames()).containsExactly("catalog/full");
assertThat(resolver.getChunkedModeViewNames()).isNull();
assertThat(resolver.getOrder()).isEqualTo(Ordered.LOWEST_PRECEDENCE - 5);
}
}
The bean names printed by the same test were templateEngine for the engine and thymeleafViewResolver for the reactive resolver, and the resolver’s
supported media types, with the charset, were text/html, application/xhtml+xml, application/xml, text/xml, application/rss+xml,
application/atom+xml, application/javascript, application/ecmascript, text/javascript, text/ecmascript, application/json, text/css,
text/plain and text/event-stream, with UTF-8. The auto-configuration source shows the rules:
| Bean | Created when | Configured from |
|---|---|---|
|
a reactive web application and no |
|
|
a reactive web application and no bean named |
|
|
no bean named |
|
The keys that are common to both stacks are explained on Spring Boot Auto-configuration & Properties;
this page covers only spring.thymeleaf.reactive.*.
Configuring it without Spring Boot
Without Boot (or when you want to replace Boot’s beans) the same three beans are declared by hand and the resolver is registered through
WebFluxConfigurer. This class compiled and rendered a page in a plain Spring context with WebTestClient.bindToApplicationContext(...):
@Configuration
@EnableWebFlux
public class ManualWebFluxConfig implements ApplicationContextAware, WebFluxConfigurer {
private ApplicationContext applicationContext;
@Override
public void setApplicationContext(ApplicationContext applicationContext) {
this.applicationContext = applicationContext;
}
@Bean
public SpringResourceTemplateResolver thymeleafTemplateResolver() {
SpringResourceTemplateResolver resolver = new SpringResourceTemplateResolver();
resolver.setApplicationContext(this.applicationContext);
resolver.setPrefix("classpath:/templates/");
resolver.setSuffix(".html");
resolver.setTemplateMode(TemplateMode.HTML);
resolver.setCacheable(false);
return resolver;
}
@Bean
public ISpringWebFluxTemplateEngine thymeleafTemplateEngine() {
SpringWebFluxTemplateEngine engine = new SpringWebFluxTemplateEngine();
engine.setTemplateResolver(thymeleafTemplateResolver());
return engine;
}
@Bean
public ThymeleafReactiveViewResolver thymeleafChunkedViewResolver() {
ThymeleafReactiveViewResolver viewResolver = new ThymeleafReactiveViewResolver();
viewResolver.setTemplateEngine(thymeleafTemplateEngine());
viewResolver.setResponseMaxChunkSizeBytes(16384); // 16 KiB: CHUNKED unless a view name says otherwise
return viewResolver;
}
@Override
public void configureViewResolvers(ViewResolverRegistry registry) {
registry.viewResolver(thymeleafChunkedViewResolver());
}
}
The same class, imported into a Spring Boot test, also showed a trap. Boot’s engine backed off (the bean list was thymeleafTemplateEngine, the hand-written one) but Boot’s
resolver did not, because its condition looks for a bean named thymeleafReactiveViewResolver and the bean above is called
thymeleafChunkedViewResolver: the context held two resolvers ([thymeleafChunkedViewResolver, thymeleafViewResolver]) and getBean(ThymeleafReactiveViewResolver.class)
failed with NoUniqueBeanDefinitionException. To replace Boot’s resolver, name your bean thymeleafReactiveViewResolver.
The sample application
All the examples use the same small source: a Book record and a service whose reactive methods stand in for a reactive repository or a remote catalogue.
public record Book(long id, String title, String author, int year) {
}
@Service
public class BookService {
private static final List<Book> SHELF = List.of(
new Book(1, "Dune", "Frank Herbert", 1965),
new Book(2, "Emma", "Jane Austen", 1815),
new Book(3, "Persuasion", "Jane Austen", 1817),
new Book(4, "Frankenstein", "Mary Shelley", 1818),
new Book(5, "Middlemarch", "George Eliot", 1871),
new Book(6, "The Left Hand of Darkness", "Ursula K. Le Guin", 1969));
/** A slow reactive source: one book every 300 ms, like a paged remote call. */
public Flux<Book> findAllSlowly() {
return Flux.fromIterable(SHELF).delayElements(Duration.ofMillis(300));
}
/** Everything at once, as a repository that already has the rows would emit them. */
public Flux<Book> findAll() {
return Flux.fromIterable(SHELF);
}
/** A larger in-memory catalogue, to make the markup bigger than one chunk. */
public Flux<Book> findMany(int count) {
return Flux.fromStream(IntStream.rangeClosed(1, count)
.mapToObj(i -> new Book(i, "Volume " + i, "Anonymous", 1900 + i % 100)));
}
public Mono<Book> findFeatured() {
return Mono.just(SHELF.get(0)).delayElement(Duration.ofMillis(100));
}
}
Source: Spring Framework — Reactive Core
Six books at 300 ms each take about 1.8 seconds to arrive, which makes the difference between the modes visible. The application configuration is two lines:
spring.thymeleaf.reactive.max-chunk-size=1KB
spring.thymeleaf.reactive.full-mode-view-names=catalog/full
FULL mode
FULL is what you get when no maximum chunk size is set and no data driver is in the model. The javadoc describes it as generating all the output in
memory as a single DataBuffer, with the engine running unthrottled, which can help with small templates at the cost of a higher memory use. It is the
mode that behaves most like Spring MVC, and it is still reactive: the controller thread is never blocked while the data is on its way.
Reactive model attributes are resolved first
A controller may put Mono and Flux values in the model. Before the template runs, AbstractView (the base class of ThymeleafReactiveView)
resolves them: according to the Spring Framework reference, a single-value reactive type becomes its value (or no value, if it is empty) and a
multi-value type such as Flux<T> is collected and becomes a List<T>. The template therefore sees plain objects:
@Controller
@RequestMapping("/catalog")
public class CatalogController {
private final BookService books;
public CatalogController(BookService books) {
this.books = books;
}
/** Flux and Mono attributes are resolved by WebFlux before the template runs. */
@GetMapping("/full")
public String full(Model model) {
model.addAttribute("books", books.findAllSlowly());
model.addAttribute("featured", books.findFeatured());
return "catalog/full";
}
}
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head><meta charset="UTF-8"><title>Catalogue (full)</title></head>
<body>
<h1>Catalogue</h1>
<p>Featured: <strong th:text="${featured.title}">Dune</strong></p>
<table>
<tbody>
<tr th:each="book : ${books}">
<td th:text="${book.title}">Title</td>
<td th:text="${book.author}">Author</td>
</tr>
</tbody>
</table>
<p th:text="|${#lists.size(books)} books|">0 books</p>
</body>
</html>
${featured.title} is a Book (not a Mono) and ${#lists.size(books)} works because books is a List. Requested with the script that timestamps each
line, the whole page appears at once after the slowest source has finished, with a content-length header and no chunking:
$ curl -sN -H 'Accept-Language: en' -D - localhost:8935/catalog/full | python3 -I ts.py
1.83s HTTP/1.1 200 OK
1.83s Content-Type: text/html
1.83s Content-Language: en
1.83s content-length: 672
1.83s
1.83s <!DOCTYPE html>
1.83s <html>
1.83s <head><meta charset="UTF-8"><title>Catalogue (full)</title></head>
1.83s <body>
1.83s <h1>Catalogue</h1>
1.83s <p>Featured: <strong>Dune</strong></p>
1.83s <table>
...
1.83s </tbody>
1.83s </table>
1.83s <p>6 books</p>
1.83s </body>
1.83s </html>
Nothing is shown before 1.83 seconds. A Flux that never completes (a Flux.interval, a live feed) cannot be used this way: collecting it into a list never
ends, so the template never starts. Wrapping it in a data driver, below, is the answer.
CHUNKED mode
CHUNKED is selected when a maximum chunk size is set on the resolver (or on a single view) and the model has no data driver. The template output is generated in chunks no larger than the limit; after each chunk the engine stops and waits until the server asks for more through reactive back-pressure, and every chunk is flushed so partial content reaches the client. The javadoc points out that this mechanism is single-threaded.
CHUNKED does not make the data arrive sooner: the model is resolved first, exactly as in FULL mode, and only the writing of a large page is bounded. Its value is memory (the whole page is never held as one buffer) and a first byte that does not wait for the end of a very large template.
/** The same model through the Rendering builder; the template is named chunked. */
@GetMapping("/chunked")
public Rendering chunked() {
return Rendering.view("catalog/chunked")
.modelAttribute("books", books.findMany(60).collectList())
.build();
}
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head><meta charset="UTF-8"><title>Catalogue (chunked)</title></head>
<body>
<h1>Catalogue</h1>
<ul>
<li th:each="book : ${books}" th:text="|${book.title} (${book.year})|">Title (1900)</li>
</ul>
</body>
</html>
With max-chunk-size=1KB the 1,817 bytes of markup left the server as two HTTP chunks. A raw socket read (curl hides the framing) shows the chunk sizes:
$ python3 -I raw.py /catalog/chunked
chunk sizes: [1024, 793] total 1817
$ curl -s -D - -o /dev/null -H 'Accept-Language: en' localhost:8935/catalog/chunked
HTTP/1.1 200 OK
transfer-encoding: chunked
Content-Type: text/html
Content-Language: en
The first chunk is exactly the limit (1024 bytes) and the second holds the rest. A client may see smaller pieces than the server wrote, because TCP can
split a chunk: in the tests, the same response arrived as [1019, 5, 793] at the WebClient, so assert totals and upper bounds rather than exact sizes.
Choosing the mode per view
A maximum chunk size set for the whole application makes every view without a data driver CHUNKED, and that is rarely what you want: a small page gains nothing from being split. Two property lists, both taking view-name patterns, refine the choice:
-
spring.thymeleaf.reactive.full-mode-view-nameslists views that run in FULL mode even if a maximum chunk size is set. -
spring.thymeleaf.reactive.chunked-mode-view-nameslists the only views that run in CHUNKED mode when a maximum chunk size is set.
Neither list has any effect on DATA-DRIVEN views, and chunked-mode-view-names without a maximum chunk size does nothing. Patterns accept a wildcard:
catalog/chunk* matched catalog/chunked. The decision, as exercised by the tests, is:
Each branch was checked by a test that starts the application on a random port, requests the view and looks at the headers (FULL has a content-length, the others
transfer-encoding: chunked). ModeProbe is the small helper that does the request:
final class ModeProbe {
record Result(String transferEncoding, long contentLength, List<Integer> pieces) {
boolean full() { return contentLength > 0 && transferEncoding == null; }
}
static Result get(int port, String path, String accept) {
WebClient client = WebClient.create("http://localhost:" + port);
var entity = client.get().uri(path).header(HttpHeaders.ACCEPT, accept)
.retrieve().toEntity(String.class).block(Duration.ofSeconds(10));
var pieces = client.get().uri(path).header(HttpHeaders.ACCEPT, accept)
.retrieve().bodyToFlux(byte[].class).map(b -> b.length).collectList().block(Duration.ofSeconds(10));
return new Result(entity.getHeaders().getFirst(HttpHeaders.TRANSFER_ENCODING),
entity.getHeaders().getContentLength(), pieces);
}
}
A test class using it:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = {
"spring.thymeleaf.reactive.max-chunk-size=1KB",
"spring.thymeleaf.reactive.chunked-mode-view-names=catalog/chunk*",
"spring.thymeleaf.reactive.full-mode-view-names=catalog/full"})
class ViewNamePatternsTest {
@LocalServerPort
int port;
@Test
void patternsAreMatchedAgainstTheViewName() {
assertThat(ModeProbe.get(port, "/catalog/chunked", "text/html").full()).isFalse();
assertThat(ModeProbe.get(port, "/catalog/full", "text/html").full()).isTrue();
}
}
The other cases passed as follows: with max-chunk-size=0B (the default) and only chunked-mode-view-names set, the view stayed FULL; with only a chunk limit, both
views were chunked; and with chunked-mode-view-names set to an empty value, the view stayed FULL, because an empty list is still a list and nothing
matches it.
A limit for one view only
The javadoc adds a finer tool: a prototype ThymeleafReactiveView bean named like the view gets its own limit, which takes precedence over the resolver’s.
Setting it to Integer.MAX_VALUE makes that view FULL whatever the properties say. With max-chunk-size=1KB, the view catalog/chunked was FULL (a
content-length of 1817 and no transfer-encoding) once this configuration was imported:
@Configuration(proxyBeanMethods = false)
public class PerViewConfig {
@Bean("catalog/chunked")
@Scope("prototype")
public ThymeleafReactiveView catalogChunkedView() {
ThymeleafReactiveView view = new ThymeleafReactiveView();
view.setResponseMaxChunkSizeBytes(Integer.MAX_VALUE); // FULL mode for this view, whatever the property says
return view;
}
}
DATA-DRIVEN mode
In DATA-DRIVEN mode Thymeleaf does not wait for the data: the model holds a ReactiveDataDriverContextVariable that wraps the Flux, and the engine
subscribes to it and writes markup as values arrive. In Reactive Streams terms Thymeleaf becomes a Processor: a subscriber of the data stream and a
publisher of output buffers. The server’s output channel throttles the engine through back-pressure, so a slow client slows the template, and the template
slows the request for more data.
/** Data-driven: the Flux is wrapped, so the template starts before the data is complete. */
@GetMapping("/stream")
public String stream(Model model) {
model.addAttribute("books", new ReactiveDataDriverContextVariable(books.findAllSlowly(), 1));
return "catalog/stream";
}
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head><meta charset="UTF-8"><title>Catalogue (data-driven)</title></head>
<body>
<h1>Catalogue</h1>
<table>
<tbody>
<tr th:each="book : ${books}">
<td th:text="${book.title}">Title</td>
<td th:text="${book.author}">Author</td>
</tr>
</tbody>
</table>
<p>End of catalogue</p>
</body>
</html>
The template is an ordinary one: a th:each over the variable (see Iteration & Conditionals). The
difference is only in the model. The head of the page, up to the loop, arrived immediately and each row followed as its book was produced:
$ curl -sN -H 'Accept-Language: en' -D - localhost:8935/catalog/stream | python3 -I ts.py
0.00s HTTP/1.1 200 OK
0.00s transfer-encoding: chunked
0.00s Content-Type: text/html
0.00s Content-Language: en
0.00s
0.00s <!DOCTYPE html>
0.00s <html>
0.00s <head><meta charset="UTF-8"><title>Catalogue (data-driven)</title></head>
0.00s <body>
0.00s <h1>Catalogue</h1>
0.00s <table>
0.00s <tbody>
0.29s <tr>
0.29s <td>Dune</td>
0.29s <td>Frank Herbert</td>
0.60s </tr><tr>
0.60s <td>Emma</td>
0.60s <td>Jane Austen</td>
0.91s </tr><tr>
0.91s <td>Persuasion</td>
0.91s <td>Jane Austen</td>
1.22s </tr><tr>
1.22s <td>Frankenstein</td>
1.22s <td>Mary Shelley</td>
1.53s </tr><tr>
1.53s <td>Middlemarch</td>
1.53s <td>George Eliot</td>
1.84s </tr><tr>
1.84s <td>The Left Hand of Darkness</td>
1.84s <td>Ursula K. Le Guin</td>
1.84s </tr>
1.84s </tbody>
1.84s </table>
1.84s <p>End of catalogue</p>
1.84s </body>
1.84s </html>
The page is complete at the same moment as the FULL one (1.84 s against 1.83 s): data-driven rendering does not make the source faster. What changes is that
the browser has the head of the page, and can fetch the stylesheet and scripts in it, 1.8 seconds earlier, and that the server never holds the whole
page. The row boundary is also visible in the output: the closing </tr> of one row is written together with the opening <tr> of the next, so a client
sees the end of a row only when the following row (or the end of the loop) arrives.
The flow looks like this:
Buffer size
The second constructor argument is the buffer size in elements (the default, ReactiveDataDriverContextVariable.DEFAULT_DATA_DRIVER_BUFFER_SIZE_ELEMENTS, is 10).
The engine collects that many elements before it runs the template for them, because running it for every single element would execute the engine more often than needed. The flush happens after each
execution. So the buffer is a trade-off between latency and overhead: size 1 gives one write per element, a large size gives fewer, bigger writes.
The same source with a buffer of three:
$ curl -sN -H 'Accept-Language: en' localhost:8935/catalog/stream3 | python3 -I ts.py
0.00s <!DOCTYPE html>
...
0.00s <tbody>
0.90s <tr>
0.90s <td>Dune</td>
0.90s <td>Frank Herbert</td>
0.90s </tr><tr>
0.90s <td>Emma</td>
0.90s <td>Jane Austen</td>
0.90s </tr><tr>
0.90s <td>Persuasion</td>
0.90s <td>Jane Austen</td>
1.82s </tr><tr>
1.82s <td>Frankenstein</td>
1.82s <td>Mary Shelley</td>
...
1.82s </html>
The first row now waits for three books (0.90 s) and the next three arrive together. The ViewModesTest below asserts the same
thing without a stopwatch on a screen: the head chunk contains <tbody> but no book, the first piece arrives in under 250 ms, the whole response takes
more than 1.5 s, and a buffer of three produces fewer pieces than a buffer of one.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class ViewModesTest {
@LocalServerPort
int port;
WebClient client() {
return WebClient.create("http://localhost:" + port);
}
/** Body pieces exactly as the HTTP client received them, with the delay since the previous piece. */
List<Tuple2<Long, byte[]>> pieces(String path) {
return client().get().uri(path).header(HttpHeaders.ACCEPT_LANGUAGE, "en")
.retrieve().bodyToFlux(byte[].class).elapsed().collectList().block(Duration.ofSeconds(10));
}
@Test
void dataDrivenModeSendsTheHeadBeforeTheDataArrives() {
var chunks = pieces("/catalog/stream");
long total = chunks.stream().mapToLong(Tuple2::getT1).sum();
assertThat(chunks.get(0).getT2()).asString().contains("<tbody>").doesNotContain("Dune");
assertThat(chunks.get(0).getT1()).isLessThan(250); // the head is not waiting for the books
assertThat(total).isGreaterThan(1500); // six books, 300 ms apart
assertThat(chunks.size()).isGreaterThanOrEqualTo(7); // head, six buffers of one book, and the tail
}
}
Source: Spring Framework — Reactive Core
Rules of the data driver
The javadoc of IReactiveDataDriverContextVariable sets out what a data-driven template must respect:
-
The wrapped object must be multi-valued: a
Fluxqualifies even if it emits one value or none, aMonodoes not. Other reactive types are accepted when Spring’sReactiveAdapterRegistrycan adapt them (the javadoc cites RxJava’sObservable, but notSingle). -
Only one data-driver variable may appear in the context.
-
The template should iterate over it exactly once, normally with a
th:each. Without the iteration the stream may still be consumed, at least in part. -
The variable is lazy: nothing is subscribed until rendering starts, and it is not resolved to a list like an ordinary
Fluxattribute is.
The maximum chunk size also applies: the javadoc states that with a limit set, the output buffers of a data-driven view never exceed it, so one buffer of elements may produce more than one chunk.
Fragments in data-driven mode
A view name with a fragment selector works in DATA-DRIVEN mode too, which is what an htmx or fetch-based partial update needs (see Fragment Rendering & AJAX and htmx with Spring Boot). The selector is a constant in the handler, never a request value (see Template Security & XSS):
/** A fragment selector fixed in code, never taken from the request. */
@GetMapping("/stream-rows")
public String streamRows(Model model) {
model.addAttribute("books", new ReactiveDataDriverContextVariable(books.findAll().take(2), 1));
return "catalog/stream :: tbody";
}
The response contained only the tbody element (without the h1 or the closing paragraph):
<tbody>
<tr>
<td>Dune</td>
<td>Frank Herbert</td>
</tr><tr>
<td>Emma</td>
<td>Jane Austen</td>
</tr>
</tbody>
Server-Sent Events data drivers
When the response content type is text/event-stream, a data-driven view is rendered as Server-Sent Events. The javadoc of
IReactiveSSEDataDriverContextVariable says Thymeleaf then emits three kinds of events: a head event with all the markup before the iterated data (if
any), one message event for each value of the data driver, and a tail event with the markup after the last value. The controller declares
the media type with produces, and the template contains only what each event should carry:
/** SSE data driver: text/event-stream, one event per book. */
@GetMapping(path = "/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public String events(Model model) {
model.addAttribute("books", new ReactiveDataDriverContextVariable(books.findAllSlowly(), 1));
return "catalog/events";
}
<li th:each="book : ${books}" th:text="|${book.title} by ${book.author}|">Title by Author</li>
The th:text escapes the book data, so a title containing markup is sent as text (see Template Security & XSS).
Each book becomes one event, with the event name message and a running id starting at 0:
$ curl -sN -H 'Accept: text/event-stream' -D - localhost:8935/catalog/events | python3 -I ts.py
0.00s HTTP/1.1 200 OK
0.00s transfer-encoding: chunked
0.00s Content-Type: text/event-stream
0.00s Content-Language: es-ES
0.00s
0.29s event: message
0.29s id: 0
0.29s data: <li>Dune by Frank Herbert</li>
0.29s
0.60s event: message
0.60s id: 1
0.60s data: <li>Emma by Jane Austen</li>
0.60s
...
1.83s event: message
1.83s id: 5
1.83s data: <li>The Left Hand of Darkness by Ursula K. Le Guin</li>
1.83s
1.83s event: tail
1.83s id: 6
1.83s data:
1.83s data:
1.83s
Source: MDN — Using server-sent events
There is no head event here because nothing precedes the loop. (The Content-Language: es-ES line is the default locale of the machine that ran the
example; the earlier requests sent Accept-Language: en.) With markup around the loop, a name prefix and a first id, all three event kinds appear:
/** SSE with markup around the loop, a name prefix and a first id after the one the browser last saw. */
@GetMapping(path = "/events-list", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public String eventsList(Model model) {
model.addAttribute("books", new ReactiveDataDriverContextVariable(books.findAll().take(2), 1, "shelf", 100L));
return "catalog/events-list";
}
<ul class="shelf">
<li th:each="book : ${books}" th:text="${book.title}">Title</li>
</ul>
$ curl -sN -H 'Accept: text/event-stream' localhost:8935/catalog/events-list
event: shelf_head
id: shelf_100
data: <ul class="shelf">
data:
event: shelf_message
id: shelf_101
data: <li>Dune</li>
event: shelf_message
id: shelf_102
data: <li>Emma</li>
event: shelf_tail
id: shelf_103
data:
data: </ul>
data:
The two extra constructor arguments are documented as follows. The prefix is added to the event names and ids (shelf_head, shelf_message, shelf_tail), so several
streams of markup, each for a different part of a page, can share one EventSource and the client tells them apart (the javadoc also mentions that
this helps with browsers' limits on concurrent connections). The first id is the number the ids start from; it is meant for reconnection:
after a failure the browser sends the Last-Event-ID header and the application can start producing events after that one, but the javadoc is explicit that
resuming is the job of whoever creates the data stream, not of Thymeleaf, which only renders. The buffer size matters for immediacy: with a buffer of 4,
nothing is sent until four elements are available.
A test with the SSE-aware WebClient decoder confirms the event names and ids:
@SpringBootTest
@AutoConfigureWebTestClient
class SseDriverTest {
@Autowired
WebTestClient client;
@Test
void headMessagesAndTailCarryThePrefixAndRunningIds() {
List<ServerSentEvent<String>> events = client.get().uri("/catalog/events-list")
.accept(MediaType.TEXT_EVENT_STREAM).exchange().expectStatus().isOk()
.returnResult(new ParameterizedTypeReference<ServerSentEvent<String>>() { })
.getResponseBody().collectList().block(Duration.ofSeconds(5));
assertThat(events).extracting(ServerSentEvent::event)
.containsExactly("shelf_head", "shelf_message", "shelf_message", "shelf_tail");
assertThat(events).extracting(ServerSentEvent::id)
.containsExactly("shelf_100", "shelf_101", "shelf_102", "shelf_103");
assertThat(events.get(1).data()).isEqualTo("<li>Dune</li>");
}
}
Source: Spring Framework — Reactive Core
The browser side (EventSource, the htmx SSE extension, and why these named events need addEventListener rather than a single message handler) is the subject of
Server-Sent Events & WebSockets, which also covers the alternative of returning a Flux<ServerSentEvent<String>> of fragments
from a controller instead of a Thymeleaf data driver.
The spring.thymeleaf.reactive.* properties
The four reactive keys, as Spring Boot 4.1.1 documents and maps them:
| Property | Default | Meaning and mapping |
|---|---|---|
|
|
Maximum size of the data buffers written to the response. Setting it makes templates CHUNKED by default. The auto-configuration applies it to the resolver only when the value is greater than zero, so |
|
not set |
Comma-separated view names (patterns allowed) that run in FULL mode even if a maximum chunk size is set. |
|
not set |
Comma-separated view names (patterns allowed) that are the only ones run in CHUNKED mode when a maximum chunk size is set. An empty value means no view. |
|
|
Media types the view technology supports. Setting it replaces the list. |
The media-types list decides which responses Thymeleaf may produce. With spring.thymeleaf.reactive.media-types=text/html, the SSE endpoint above answered a
request for text/event-stream with status 406, and with the defaults a request for application/pdf against the HTML view also gave 406 (the
MediaTypesTest printed 406 and the curl check printed 406). Remove text/event-stream from the list only if you do not want SSE views at all.
The keys shared with MVC apply too: spring.thymeleaf.view-names and excluded-view-names select which view names the reactive resolver handles at all,
spring.thymeleaf.encoding is the default charset of the responses, and the template keys feed the template resolver
(see Spring Boot Auto-configuration & Properties).
Errors, threads and blocking
An error after the first chunk
Once the first chunk is flushed the status line has been sent. An error in the data stream afterwards cannot turn the response into a 500 page: WebFlux can only report that the response was already committed and close the connection. The scratch controller below emits three books and then fails:
/** Fails after three books, to show an error in the middle of a stream. */
public Flux<Book> findAllThenFail() {
return findAllSlowly().take(3).concatWith(Flux.error(new IllegalStateException("catalogue backend down")));
}
The client received the head and three rows, then the connection ended, and curl exited with status 18 ("transfer closed with outstanding read data remaining").
The server log held Error [java.lang.IllegalStateException: catalogue backend down] for HTTP GET "/catalog/broken", but ServerHttpResponse already committed (200 OK).
The test that records this asserts a WebClientResponseException whose status is 200. Practical consequence: validate and load everything that can
fail before the data driver is returned, keep the stream itself defensive (onErrorResume with a placeholder row), and expect truncated pages in the browser.
Source: Spring Framework — Reactive Core
Which thread runs the template
The template is not run on a thread of its own: its expressions run on whichever thread delivers the signal. A bean called from a template that returns the current thread name made that visible:
<p>head: <span th:text="${@threads.current()}">thread</span></p>
<ul>
<li th:each="book : ${books}" th:text="|${book.title} on ${@threads.current()}|">Title on thread</li>
</ul>
$ curl -sN -H 'Accept-Language: en' localhost:8935/catalog/threads # source delayed with delayElements
<li>Dune on parallel-6</li><li>Emma on parallel-7</li><li>Persuasion on parallel-8</li>
$ curl -sN -H 'Accept-Language: en' localhost:8935/catalog/threads-now # source already in memory
<li>Dune on reactor-http-nio-3</li><li>Emma on reactor-http-nio-3</li><li>Persuasion on reactor-http-nio-3</li>
Source: Spring Framework — Reactive Core
With the delayed source the body ran on Reactor’s parallel scheduler, with the in-memory one on a Netty event-loop thread (reactor-http-nio-3).
Either way it is a thread that the reactive pipeline shares with other requests. A SpEL call such as ${@threads.current()} that blocks (a JDBC query, a
Mono.block(), a remote call without a timeout) blocks that thread, and on an event loop it delays every other connection assigned to it. Keep templates free of
service calls: put the data into the model (as a Mono, a Flux or a data driver) and let the template only format it. Reactive Programming
shows how to bridge unavoidable blocking code safely.
Limitations
-
No tutorial chapter. The Thymeleaf 3.1 Thymeleaf + Spring tutorial is written for Spring MVC; the WebFlux behaviour is documented in the javadoc and the Spring references, and the Thymeleaf project’s examples repository has WebFlux samples (
springboot3-stsm-webflux,biglist-webflux,sse-webflux, listed on Getting Started). -
One data driver, multi-valued, iterated once. See the rules above. A second driver in the same model is not allowed.
-
The model is not streamed. Only the variable wrapped in a data driver is consumed incrementally. Every other
MonoorFluxattribute is resolved before the template starts, so one slow ordinary attribute delays the first byte of a data-driven page too. -
Errors after the first chunk cannot change the status. See above.
-
Views are inside the trust boundary. The Spring Framework reference advises against WebFlux template support where templates can be edited by external sources, because views have access to beans in the application context. The same holds on MVC (see Template Security & XSS).
-
Spring Security’s
sec:dialect. Boot registers theSpringSecurityDialectbean whenever the dialect and a Spring SecurityCsrfTokenclass are on the classpath, and the extras module’s README states WebFlux support (see Spring Security Integration). Thesec:attributes were not run on WebFlux for this page, so test them in your own application before relying on them. -
Forms and binding. The jar contains reactive counterparts of the MVC integration classes (
SpringWebFluxThymeleafRequestContext,SpringWebFluxThymeleafBindStatusandSpringWebFluxThymeleafRequestDataValueProcessorinorg.thymeleaf.spring6.context.webflux). The form examples of Forms & Data Binding were run on MVC only. -
Proxies and compression. Streaming only helps if nothing between the application and the browser buffers the response. This was not tested here.
When reactive rendering is worth it
Reactive rendering is a property of the whole stack, not of the template engine. The templates are identical to the MVC ones: every th:* attribute and
expression on the other pages of this section works the same, and what you buy with WebFlux is the execution model.
| Situation | Reactive (WebFlux) rendering | Spring MVC, optionally with virtual threads |
|---|---|---|
The data layer is already reactive (R2DBC, a reactive NoSQL driver, |
Natural fit: no thread is parked while the data is on its way, and DATA-DRIVEN mode puts the first bytes on the wire early. |
Works, but each request needs blocking calls ( |
The page is large or slow to assemble and the head (CSS, scripts, navigation) should reach the browser first |
DATA-DRIVEN mode, as the 0.00 s against 1.83 s runs above show. |
The controller has finished loading its data before rendering starts, so the head can only wait for the data. |
Many concurrent requests that wait on slow upstream services |
Few threads handle many waiting requests. |
Virtual threads ( |
JPA/Hibernate, JDBC, blocking libraries, a team used to imperative code |
Poor fit: every blocking call must be moved off the event loop. |
The natural fit; see Concurrency Alternatives. |
Server-sent HTML that is pushed over time |
A data driver with |
|
An ordinary CRUD site, forms, sessions |
No gain; the page is small and the model is ready before the template runs. |
Simpler, and the rest of this section was run on it. |
The runs on this page measure what the modes change: the time to the first byte (0.00 s instead of 1.83 s on a page whose data takes 1.8 s) and, per the javadoc, memory. They do not show a throughput difference against MVC, and none was measured: that comparison depends on the data layer, and the choice should follow it. If the rest of the application is blocking, MVC with virtual threads (see Concurrency Alternatives) keeps the simpler model; if it is reactive from the database to the response, the reactive view resolver removes the one blocking step left at the end of the pipeline.
Testing reactive views
WebTestClient is the test client for WebFlux (the Boot 4 test starters are spring-boot-starter-webflux-test and spring-boot-starter-thymeleaf-test, and
@AutoConfigureWebTestClient lives in org.springframework.boot.webtestclient.autoconfigure). A WebTestClient bound to the application context aggregates the body, which
is enough for content assertions (the SseDriverTest above). To look at chunking or timing, start the server on a random port
(SpringBootTest.WebEnvironment.RANDOM_PORT) and use a real WebClient, as ViewModesTest does. The suite described on this page had 19 tests in
ten classes of its own, and all passed with ./mvnw test: ViewModesTest (6: FULL, CHUNKED, data-driven, buffer size, SSE, error after the first chunk),
NoChunkLimitTest (2), ChunkLimitOnlyTest, ViewNamePatternsTest, EmptyChunkedNamesTest, PerViewTest, MediaTypesTest, SseDriverTest (2), AutoConfigTest and
ManualConfigTest, plus the two smoke tests that came with the project (17 plus 2).
Source: Spring Boot — Spring WebFlux
Pitfalls
| Symptom | Cause | Fix |
|---|---|---|
A page backed by a never-ending |
An ordinary |
Wrap it in |
|
The wrapped object must be multi-valued; a |
Use a |
Two data drivers in one model |
Only one data-driver variable may appear in the context |
Combine the streams, or render one of them in a second request |
The first row does not appear for a long time |
The buffer size is large: nothing is rendered until that many elements have arrived |
Lower the second constructor argument |
Many tiny writes |
A buffer size of 1 on a fast source writes once per element |
Raise the buffer size |
|
It needs a maximum chunk size greater than zero, and an empty list matches no view |
Set |
Every small page is chunked |
A global |
Add |
Two view resolvers after adding your own |
Boot backs off only for a bean named |
Use that bean name |
|
The request’s media type is not in |
Keep |
A truncated page with status 200 |
The data stream failed after the first chunk, when the status had been sent |
Load and validate before returning the driver; handle errors inside the stream |
The event loop stalls under load |
A blocking call in a bean used by the template, or in the data pipeline, runs on a shared thread |
Move blocking work out of the pipeline and keep service calls out of the template |
Streaming has no visible effect |
A proxy or a compression layer buffers the response (not tested here) |
Check the buffering settings of every hop |
|
Events named |
Register a listener per event name with |
A user-chosen fragment or view |
A selector or view name built from a request parameter lets a visitor pick a template |
Use constants or a map from a validated key to a fixed name |