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.

Table 1. The reactive classes of thymeleaf-spring6
Class Package What it does

SpringWebFluxTemplateEngine

org.thymeleaf.spring6

Extends SpringTemplateEngine (so the SpringStandard dialect, SpEL and MessageSource support are the same as on MVC) and implements ISpringWebFluxTemplateEngine. Its two processStream(...) methods return a Publisher of DataBuffer instead of a String; the second one takes the maximum chunk size in bytes.

ThymeleafReactiveViewResolver

org.thymeleaf.spring6.view.reactive

The WebFlux ViewResolver. It turns a view name into a ThymeleafReactiveView and decides the mode: it holds the maximum chunk size, the full-mode and chunked-mode view-name patterns, viewNames and excludedViewNames, the default charset and the supported media types.

ThymeleafReactiveView

org.thymeleaf.spring6.view.reactive

The View that runs the template: it resolves the model, calls processStream(...) on the engine and writes the buffers to the response. Its default maximum chunk size is Integer.MAX_VALUE, which means "no limit".

ReactiveDataDriverContextVariable

org.thymeleaf.spring6.context.webflux

The wrapper that puts a view into DATA-DRIVEN mode (implements IReactiveDataDriverContextVariable and, for Server-Sent Events, IReactiveSSEDataDriverContextVariable).

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:

AutoConfigTest: the beans Boot creates in a WebFlux application
@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:

Table 2. Beans of the reactive auto-configuration (Spring Boot 4.1.1)
Bean Created when Configured from

SpringWebFluxTemplateEngine (bean templateEngine)

a reactive web application and no ISpringWebFluxTemplateEngine bean exists

spring.thymeleaf.enable-spring-el-compiler, spring.thymeleaf.render-hidden-markers-before-checkboxes, and every ITemplateResolver and IDialect bean found in the context (in order)

ThymeleafReactiveViewResolver (bean thymeleafViewResolver)

a reactive web application and no bean named thymeleafReactiveViewResolver exists

spring.thymeleaf.encoding (default charset), view-names, excluded-view-names and the spring.thymeleaf.reactive.* keys; its order is Ordered.LOWEST_PRECEDENCE - 5, so it acts as a fallback after more specific resolvers

defaultTemplateResolver

no bean named defaultTemplateResolver

spring.thymeleaf.prefix, suffix, mode, cache and the other template keys (shared with MVC)

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(...):

ManualWebFluxConfig: Thymeleaf and WebFlux without Spring Boot
@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.

Book and BookService: a slow reactive source
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));
    }
}

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:

application.properties
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:

CatalogController: Flux and Mono attributes in FULL mode
@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";
    }
}
templates/catalog/full.html
<!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 --no-buffer on the FULL view (seconds since the request started)
$ 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.

CatalogController: a large page through the Rendering builder
/** 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();
}
templates/catalog/chunked.html
<!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:

Chunk sizes of the CHUNKED view and its headers
$ 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-names lists views that run in FULL mode even if a maximum chunk size is set.

  • spring.thymeleaf.reactive.chunked-mode-view-names lists 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:

flowchart TD A{"Data-driver variable in the model?"} A -- yes --> DD["DATA-DRIVEN (a chunk limit still caps each buffer)"] A -- no --> B{"Chunk limit set for this view (per-view bean or max-chunk-size)?"} B -- no --> FULL["FULL"] B -- yes --> C{"View name matches full-mode-view-names?"} C -- yes --> FULL C -- no --> D{"chunked-mode-view-names set and name not in it?"} D -- yes --> FULL D -- no --> CH["CHUNKED"]
Figure 1. How a view ends up in FULL, CHUNKED or DATA-DRIVEN mode

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:

ModeProbe: is the response FULL or chunked
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:

ViewNamePatternsTest: patterns are matched against the view name
@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:

PerViewConfig: a per-view maximum chunk size
@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.

CatalogController: a data driver with a buffer of one element
/** 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";
}
templates/catalog/stream.html
<!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 --no-buffer on the DATA-DRIVEN view
$ 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:

sequenceDiagram participant B as Browser participant S as Netty and WebFlux participant V as ThymeleafReactiveView participant E as SpringWebFluxTemplateEngine participant D as Flux of Book (slow source) B->>S: GET /catalog/stream S->>V: render(model) - the model holds the data driver V->>E: processStream(template, context, chunk size) E-->>B: chunk 1: markup before the th:each loop, flushed loop for each buffer of elements D-->>E: the next buffer of books E-->>B: markup of those rows, flushed B-->>E: demand for more (back-pressure) end D-->>E: complete E-->>B: last chunk: markup after the loop, end of response
Figure 2. Data-driven chunked rendering of the catalogue

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 --no-buffer with a buffer of three elements (the head lines are the same as before)
$ 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.

ViewModesTest: the head is sent before the data (excerpt)
@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
    }
}

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 Flux qualifies even if it emits one value or none, a Mono does not. Other reactive types are accepted when Spring’s ReactiveAdapterRegistry can adapt them (the javadoc cites RxJava’s Observable, but not Single).

  • 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 Flux attribute 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):

CatalogController: a fragment of a data-driven template
/** 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):

Rendered output
<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:

CatalogController: an SSE data driver
/** 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";
}
templates/catalog/events.html
<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 --no-buffer on the SSE view
$ 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

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:

CatalogController: markup around the loop, a prefix and a first id
/** 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";
}
templates/catalog/events-list.html
<ul class="shelf">
  <li th:each="book : ${books}" th:text="${book.title}">Title</li>
</ul>
The three kinds of event, with the prefix shelf and ids from 100
$ 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:

SseDriverTest: events, ids and the prefix
@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>");
    }
}

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:

Table 3. Reactive properties of Spring Boot 4.1.1 (all on ThymeleafProperties.Reactive)
Property Default Meaning and mapping

spring.thymeleaf.reactive.max-chunk-size

0B (a DataSize; any size format Boot accepts for a DataSize)

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 0B means no limit.

spring.thymeleaf.reactive.full-mode-view-names

not set

Comma-separated view names (patterns allowed) that run in FULL mode even if a maximum chunk size is set.

spring.thymeleaf.reactive.chunked-mode-view-names

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.

spring.thymeleaf.reactive.media-types

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

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:

BookService: a stream that fails after three books
/** 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.

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:

templates/catalog/threads.html
<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>
Thread names seen by the loop body
$ 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>

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 Mono or Flux attribute 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 the SpringSecurityDialect bean whenever the dialect and a Spring Security CsrfToken class are on the classpath, and the extras module’s README states WebFlux support (see Spring Security Integration). The sec: 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, SpringWebFluxThymeleafBindStatus and SpringWebFluxThymeleafRequestDataValueProcessor in org.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.

Table 4. Reactive Thymeleaf or Spring MVC
Situation Reactive (WebFlux) rendering Spring MVC, optionally with virtual threads

The data layer is already reactive (R2DBC, a reactive NoSQL driver, WebClient calls)

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 (block()) at the boundary.

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 (spring.threads.virtual.enabled=true) make the waiting cheap too, with ordinary blocking code.

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 text/event-stream renders one event per element.

SseEmitter and fragments rendered by hand (see Server-Sent Events & WebSockets).

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).

Pitfalls

Table 5. Common reactive rendering problems
Symptom Cause Fix

A page backed by a never-ending Flux never appears

An ordinary Flux attribute is collected into a List before the template runs, so it must complete first

Wrap it in ReactiveDataDriverContextVariable

Mono passed to a data driver misbehaves

The wrapped object must be multi-valued; a Mono is single-valued

Use a Flux (a Mono can be turned into one with flux()), or leave it as a normal attribute

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

chunked-mode-view-names does nothing

It needs a maximum chunk size greater than zero, and an empty list matches no view

Set max-chunk-size, and list the views (patterns allowed)

Every small page is chunked

A global max-chunk-size makes all views without a data driver CHUNKED

Add full-mode-view-names for them, or use chunked-mode-view-names to opt views in

Two view resolvers after adding your own

Boot backs off only for a bean named thymeleafReactiveViewResolver

Use that bean name

406 Not Acceptable on an SSE or non-HTML view

The request’s media type is not in spring.thymeleaf.reactive.media-types

Keep text/event-stream and the other types you serve in the list

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

SSE events do not reach onmessage

Events named head, tail or with a prefix are named events, not message events

Register a listener per event name with addEventListener

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