Performance & Production

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, Spring Boot 4.1.1 (Spring Framework 7.0.9) and Java 21, checked on 2026-10-08, and every measurement and output below comes from a scratch Spring Boot application run on that stack (Maven wrapper, JDK 26 with --release 21, an embedded Tomcat, H2, and curl against a short-lived server). It applies to Thymeleaf with Spring MVC; the template cache, the SpringEL compiler and the packaging notes are the same with Spring WebFlux and for standalone use (where the settings are Java calls and not properties), while the partial-output property, the HTTP-level sections and Open Session In View are servlet-only topics. The web server examples use Tomcat, the Boot default.

A server-rendered page is fast when four things hold: the template is parsed once and not on every request, the data the page needs is loaded in a small, known number of queries, the browser and any proxy in front of the application do not ask for what they already have, and the application starts quickly in the environment it is deployed to. Thymeleaf only owns the first point, but the other three are decided by how the template and its controller are written, so they belong on the same page.

In a hurry: the production checklist

Concern Do this Section

Parsed templates are re-read on every request

Leave spring.thymeleaf.cache at its default true. Only DevTools or a local profile turns it off.

The template cache

Expressions are slow on hot pages

Try spring.thymeleaf.enable-spring-el-compiler=true and measure.

The expression cache and the SpringEL compiler

An error in the middle of a page leaves a truncated page with status 200

Set spring.thymeleaf.servlet.produce-partial-output-while-processing=false.

Partial output while processing

A collection is loaded but not always shown

Hand the template a LazyContextVariable.

Lazy context variables

One query per row while the page renders

spring.jpa.open-in-view=false, then load what the view needs with JOIN FETCH in the controller or service.

Avoiding N+1 and Open Session In View in views

Browsers re-download unchanged pages

Cache-Control and an ETag for pages that are the same for everyone, content-hashed static resources with a long lifetime.

HTTP caching of rendered pages

Large HTML travels uncompressed

server.compression.enabled=true (Tomcat skips a response that carries a strong ETag).

Compression

Shipping the application

Executable jar, layered extraction for container images, Cloud Native Buildpacks, and native image only after a test of every template.

Packaging and deployment

The template cache

Thymeleaf parses a template file into a sequence of events and applies its processors to that sequence. By default it keeps the parsed sequence in a cache, because cloning an in-memory sequence is much cheaper than reading and parsing a file on every request. The mechanics (the resolver’s setCacheable, the cache patterns, the TTL, StandardCacheManager and clearing entries from code) are covered, with a runnable standalone example, on Standalone Engine & Configuration. Spring Boot gives you the same switch as a property, and it is the first thing to check on a production system.

Property Default Effect

spring.thymeleaf.cache

true

Makes the default template resolver cacheable, and lets the servlet view resolver cache its views. false re-reads and re-parses the template on every request.

spring.thymeleaf.servlet.produce-partial-output-while-processing

true

See Partial output while processing.

spring.thymeleaf.enable-spring-el-compiler

false

See The expression cache and the SpringEL compiler.

The cache can be observed with the logger named in the tutorial’s logging section. Start the application with this property:

Showing cache hits and misses
logging.level.org.thymeleaf.TemplateEngine.cache.TEMPLATE_CACHE=TRACE

Two requests to the same page, with the default spring.thymeleaf.cache=true, logged:

Cache enabled
[THYMELEAF][CACHE_INITIALIZE] Initializing cache TEMPLATE_CACHE. Max size: 200. Soft references are used.
[THYMELEAF][http-nio-18740-exec-2][TEMPLATE_CACHE][CACHE_MISS] Cache miss in cache "TEMPLATE_CACHE" for key "authors/list".
[THYMELEAF][http-nio-18740-exec-2][TEMPLATE_CACHE][CACHE_ADD][1] Adding cache entry in cache "TEMPLATE_CACHE" for key "authors/list". New size is 1.
[THYMELEAF][http-nio-18740-exec-4][TEMPLATE_CACHE][CACHE_HIT] Cache hit in cache "TEMPLATE_CACHE" for key "authors/list".

and with --spring.thymeleaf.cache=false, two misses and no entry is ever added:

Cache disabled
[THYMELEAF][CACHE_INITIALIZE] Initializing cache TEMPLATE_CACHE. Max size: 200. Soft references are used.
[THYMELEAF][http-nio-18740-exec-2][TEMPLATE_CACHE][CACHE_MISS] Cache miss in cache "TEMPLATE_CACHE" for key "authors/list".
[THYMELEAF][http-nio-18740-exec-4][TEMPLATE_CACHE][CACHE_MISS] Cache miss in cache "TEMPLATE_CACHE" for key "authors/list".

The size of the difference depends on how much markup the template holds, because parsing cost grows with the markup and not with the data. A template with 150 small sections (about 600 elements), a handful of expressions and one row of data was rendered 2000 times after a warm-up through the SpringTemplateEngine that Boot auto-configures:

Rendering a markup-heavy template 2000 times
new ApplicationContextRunner()
    .withConfiguration(AutoConfigurations.of(ThymeleafAutoConfiguration.class))
    .withPropertyValues("spring.thymeleaf.cache=false")      // or true
    .run(ctx -> {
        TemplateEngine engine = ctx.getBean(TemplateEngine.class);
        Context c = new Context(Locale.ENGLISH);
        c.setVariable("title", "Open");
        for (int i = 0; i < 300; i++) { engine.process("perf/heavy", c); }   // warm-up
        long t = System.nanoTime();
        for (int i = 0; i < 2000; i++) { engine.process("perf/heavy", c); }
        System.out.println((System.nanoTime() - t) / 1_000_000 + " ms");
    });

The program printed, in two rounds, 920 ms and 910 ms with the cache off and 232 ms and 239 ms with it on: roughly four times faster for this template. These numbers are one run on one laptop and will differ on yours, so treat them as an order of magnitude and measure your own pages. A page dominated by iteration over a long list spends most of its time evaluating expressions and not parsing, so the cache helps it much less: the cache removes the file read and the parsing, not the processing.

Practical rules:

  • Keep spring.thymeleaf.cache=true in production. DevTools switches it off for you in development (see Spring Boot Auto-configuration & Properties); a spring.thymeleaf.cache=false that reaches a production profile is one of the commonest self-inflicted slowdowns.

  • The template cache holds 200 entries and the expression cache 500 by default (StandardCacheManager), and the log above shows that entries are held through soft references, so the JVM may drop them under memory pressure. An application with more than a few hundred distinct templates, or one that builds template names dynamically, can resize the cache with a custom StandardCacheManager set on the engine, as described in Standalone Engine & Configuration.

  • Template content that changes while the application runs (templates read from a database or a file system the application does not own) needs a TTL on the resolver or a call to clearTemplateCache(); a cached template never notices that its source changed.

  • Decoupled logic (.th.xml files) adds a second resource to read and merge for each template, and the cache hides that cost; see Natural Templates & Decoupled Logic.

The expression cache and the SpringEL compiler

Besides parsed templates, the cache manager holds a second cache for parsed expressions, so a ${...} or *{...} expression string is parsed once and reused. With the Spring dialect the variable expressions are SpEL, and SpEL can additionally compile an expression that is evaluated often into bytecode. Thymeleaf exposes the switch as SpringTemplateEngine.setEnableSpringELCompiler(boolean) and Spring Boot as a property:

Enabling the SpringEL compiler
spring.thymeleaf.enable-spring-el-compiler=true

The tutorial keeps it at false by default for safer backwards compatibility, because compilation can be incompatible with templates that reuse the same expression with objects of different types. On a page with 200 table rows, each with four expressions, the same harness as above gave:

Configuration (cache on, 2000 renders of 200 rows) Time

Compiler off, round 1 and 2

1909 ms, 1699 ms

Compiler on, round 1 and 2

700 ms, 688 ms

The compiler removes most of the cost of the expressions themselves, which is what dominates a page like this one. As a check of the warning in the tutorial, a single template line, a th:text that reads item.label, was rendered five times with three instances of one class and then two of another class that also has getLabel(). With the compiler on and with it off the output was the same (book three times, then magazine twice) and no exception was raised. That is one simple case on 3.1.5 and Boot 4.1.1; it does not prove the compiler is safe for every expression, so the rule is the usual one for an optimisation: enable it, run the test-suite and the key pages in a pre-production environment, and keep the property out of the way if a page fails.

Partial output while processing

On the servlet stack, ThymeleafViewResolver writes the rendered markup to the response as processing advances instead of collecting the whole page in memory first. Boot exposes it as spring.thymeleaf.servlet.produce-partial-output-while-processing, true by default (it appears in the property table of Spring Boot Auto-configuration & Properties). That gives a faster first byte and a smaller memory footprint for large pages, with a cost that shows up only when a template fails half way. A page of 3000 paragraphs followed by one element whose expression dereferences a missing variable (${missing.value}) was requested against both settings:

A large page that fails at the end
<p th:each="n : ${lines}" th:text="'Line ' + ${n} + ' of a large page'">Line</p>
<p th:text="${missing.value}">boom</p>
# default: produce-partial-output-while-processing=true
curl -s -o /dev/null -w "status=%{http_code} size=%{size_download} type=%{content_type}\n" localhost:18740/big
# status=200 size=97923 type=text/html;charset=UTF-8

# --spring.thymeleaf.servlet.produce-partial-output-while-processing=false
curl -s -o /dev/null -w "status=%{http_code} size=%{size_download} type=%{content_type}\n" localhost:18740/big
# status=500 size=0 type=

With the default the client received status 200 and 97,923 bytes of a page that ends after "Line 3000 of a large page", with no closing tags and no sign of the error; the failure is only in the server log. With false Thymeleaf renders the whole page into a buffer first, so the exception happens before anything is committed and the response is the 500 status that the error handling of the application then turns into an error page. Choose false when a half-rendered page is worse than an error page (forms, checkouts, anything a user acts on) and your pages are not so large that buffering them hurts; keep true for large reports where time to first byte matters. The errors themselves are the topic of Error Handling & Flash Messages. The property does not exist for WebFlux, where the reactive view resolver has its own chunked and full modes (see Reactive WebFlux).

Lazy context variables

A controller that always loads a collection the template only sometimes shows pays for the query on every request. Thymeleaf’s answer is a context variable that implements ILazyContextVariable, usually by extending LazyContextVariable; its loadValue() runs when an expression first reads the variable and not before, and the result is kept for the rest of that rendering. In Spring MVC it is simply a model attribute. The iteration page, Iteration & Conditionals, explains the mechanism with a test; this is the same idea on the authors and books of the scratch application, with a flag that decides whether the template reads the list:

A model attribute that loads its data only when read
@GetMapping("/authors/lazy")
public String lazy(Model model, @RequestParam(defaultValue = "false") boolean show) {
    model.addAttribute("show", show);
    model.addAttribute("authors", new LazyContextVariable<List<Author>>() {
        @Override protected List<Author> loadValue() { return repo.findAllWithBooks(); }
    });
    return "authors/lazy";
}
<ul th:if="${show}"><li th:each="a : ${authors}" th:text="${a.name} + ': ' + ${a.books.size()}">Author</li></ul>
<p th:unless="${show}">Nothing to show.</p>

Counting the SQL statements Hibernate prepared during each request gave 0 for /authors/lazy and 1 for /authors/lazy?show=true. Note what this does and does not do: it postpones the call, it does not change what the call costs, and it must still run while the data can be loaded. A lazy variable that touches the database is evaluated inside the view, which brings the next topic.

Avoiding N+1 and Open Session In View in views

The scratch model has an Author with a lazy @OneToMany list of Book entities (each book points back with a lazy @ManyToOne). The seed data is 20 authors with three books each. The natural way to show them in a template:

The page: authors and their books
<div th:each="a : ${authors}">
  <h2 th:text="${a.name}">Author</h2>
  <ul><li th:each="b : ${a.books}" th:text="${b.title}">Title</li></ul>
</div>
The controller: plain findAll
@GetMapping("/authors")
public String list(Model model) {
    model.addAttribute("authors", repo.findAll());
    return "authors/list";
}

repo.findAll() returns 20 authors whose books collection is not loaded yet. Whether the view can read it depends on one Boot property.

Open Session In View on (the default)

Spring Boot registers an interceptor that keeps the JPA EntityManager open for the whole web request, including the rendering of the view, when spring.jpa.open-in-view is true, which is the default. Boot warns about it at start up. The line from the log of this run:

WARN ... JpaBaseConfiguration$JpaWebConfiguration : spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed during view rendering. Explicitly configure spring.jpa.open-in-view to disable this warning

The template works, because each a.books is initialised on demand, and that is exactly the problem. With logging.level.org.hibernate.SQL=DEBUG, one request to /authors logged 21 statements: one for the authors and one per author, repeated:

select a1_0.id,a1_0.name from author a1_0
select b1_0.author_id,b1_0.id,b1_0.title from book b1_0 where b1_0.author_id=?
select b1_0.author_id,b1_0.id,b1_0.title from book b1_0 where b1_0.author_id=?
...

That is the N+1 problem: 1 query for the list plus N for the collection of each element. With 20 rows it hides in a fast local database; with 500 rows and a database on another machine it is the slow page. Open Session In View does not cause it, it only lets it happen silently in the view (and can keep a database connection busy for the length of the rendering, including slow clients).

Open Session In View off

Switching it off
spring.jpa.open-in-view=false

Now the same /authors request ends with status 500. The exception chain, printed by a MockMvc test, is ServletException then TemplateProcessingException then LazyInitializationException, whose message is:

org.hibernate.LazyInitializationException: Cannot lazily initialize collection of role 'com.example.perf.Author.books' with key '1' (no session)

This is a good failure: the view can no longer trigger queries, so the controller (or a service method) has to say what the page needs. The repository method that does it:

Loading the whole graph in one query with JOIN FETCH
public interface AuthorRepository extends JpaRepository<Author, Long> {

    @Query("select distinct a from Author a join fetch a.books order by a.name")
    List<Author> findAllWithBooks();
}
@GetMapping("/authors/fetch")
public String listFetched(Model model) {
    model.addAttribute("authors", repo.findAllWithBooks());
    return "authors/list";
}

The same template, /authors/fetch, returned status 200 with one statement, with open-in-view on or off:

select distinct a1_0.id,b1_0.author_id,b1_0.id,b1_0.title,a1_0.name from author a1_0 join book b1_0 on a1_0.id=b1_0.author_id order by a1_0.name

The same figures were checked with a @SpringBootTest and Hibernate statistics (hibernate.generate_statistics=true, getPrepareStatementCount()):

Request open-in-view=true open-in-view=false

/authors (lazy collection read by the view)

21 statements

LazyInitializationException, 500

/authors/fetch (JOIN FETCH)

1 statement

1 statement

/authors/lazy (lazy variable, flag off)

0 statements

0 statements

/authors/lazy?show=true

1 statement

1 statement

Guidelines that follow from this:

  • Turn Open Session In View off explicitly, so that the warning disappears and the intent is in the configuration, and fix each page that then fails. The failures are a to-do list of the N+1 queries you had.

  • Load what the page needs where the transaction is: JOIN FETCH in a query, an entity graph, or a projection that returns just the fields the template shows. A DTO or projection also stops the template from navigating an entity graph, which is a good habit for security as well (an entity exposed to a template exposes every property to an expression).

  • A JOIN FETCH of a collection combined with pagination makes Hibernate fetch everything and page in memory (and log a warning); page the owning entities first and load their collections in a second query. The details, and the alternatives (batch fetching, @BatchSize), are on Fetching and the N+1 Problem, and Hibernate & Spring Boot Integration covers the property itself.

  • Do not combine the lazy context variable with a lazily initialised association unless the variable’s loadValue() itself runs a query that fetches what the view will read.

  • The pagination and sorting patterns that sit on top of this are on Displaying Data, Pagination & Sorting.

HTTP caching of rendered pages

The template cache saves the server from re-parsing. HTTP caching saves the network from carrying a response, or the server from rendering one, at all. Two kinds of response need different treatment.

Pages that are not cacheable by default

Spring Security, when present (and it normally is), adds Cache-Control: no-cache, no-store, max-age=0, must-revalidate, Pragma: no-cache and Expires: 0 to responses that do not already carry caching headers, which is the right default for pages that show personal data. In the scratch application with a permit-all filter chain, the response for /authors carried exactly these headers, and so did the static CSS file. The details of the security headers are on Spring Security Integration. A cacheable page therefore has to say so itself.

A page that is the same for everyone: Cache-Control and an ETag

Cache-Control from the controller, an ETag from a filter
@GetMapping("/about")
public String about(Model model, HttpServletResponse response) {
    response.setHeader(HttpHeaders.CACHE_CONTROL,
            CacheControl.maxAge(Duration.ofMinutes(5)).cachePublic().getHeaderValue());
    model.addAttribute("lines", IntStream.rangeClosed(1, 120).boxed().toList());
    return "perf/about";
}

@Bean
FilterRegistrationBean<ShallowEtagHeaderFilter> etagFilter() {
    FilterRegistrationBean<ShallowEtagHeaderFilter> bean =
            new FilterRegistrationBean<>(new ShallowEtagHeaderFilter());
    bean.addUrlPatterns("/about");
    return bean;
}

ShallowEtagHeaderFilter hashes the rendered body to produce the ETag, so the page is still rendered on every request, but the response shrinks to a status line when the client already has it. With curl:

curl -s -D - -o /dev/null localhost:18740/about
# HTTP/1.1 200
# Cache-Control: max-age=300, public
# ETag: "0a5c6464efd537922c4c6eb7ad4026660"
# Content-Type: text/html;charset=UTF-8
# Content-Length: 4075

curl -s -D - -o /dev/null -H 'If-None-Match: "0a5c6464efd537922c4c6eb7ad4026660"' localhost:18740/about
# HTTP/1.1 304
# Cache-Control: max-age=300, public
# ETag: "0a5c6464efd537922c4c6eb7ad4026660"

The 304 has no body, and the caching headers set by the controller were kept because Spring Security does not overwrite headers that are already present. Use this for public, user-independent pages only; a page that includes the user name, a CSRF token or a flash message must not be cached by shared caches. For pages that depend on data you can date (an entity with a modification time), Spring’s WebRequest.checkNotModified(...) avoids rendering altogether, at the price of computing the date in the controller; it is documented on the same Spring Framework page.

Static resources: long lifetimes and content hashes

CSS, JavaScript and images are best cached for a long time and renamed when they change. Boot’s resource chain can add a hash of the content to the file name, and Thymeleaf’s @{...} link expressions use it automatically, because Boot registers ResourceUrlEncodingFilter when Thymeleaf is present (see Link URLs):

Hashed names and a one-year lifetime
spring.web.resources.chain.strategy.content.enabled=true
spring.web.resources.chain.strategy.content.paths=/**
spring.web.resources.cache.cachecontrol.max-age=365d
spring.web.resources.cache.cachecontrol.cache-public=true

The template line <link rel="stylesheet" th:href="@{/css/app.css}"> was rendered, with these properties on, as:

<link rel="stylesheet" href="/css/app-ff974d062358212ab3c71569650f232a.css">

and that URL was served with Cache-Control: max-age=31536000, public and a weak ETag. Without the properties the same URL was /css/app.css and, behind Spring Security, was served with the no-store headers shown above. The properties that DevTools changes for development (the chain cache, spring.web.resources.cache.period) are listed in the DevTools section of Spring Boot Auto-configuration & Properties. A CDN in front of the application takes over the long-lived assets; see the CDN pages of the Cloud section (for example AWS CloudFront, Azure Front Door and CDN and Google Cloud CDN and Media CDN).

Compression

HTML is highly compressible. Boot’s embedded servers can compress responses, and compression is off by default:

Enabling response compression
server.compression.enabled=true

The reference lists text/html, text/xml, text/plain, text/css, text/javascript, application/javascript, application/json and application/xml as the default compressed types and 2048 bytes as the default server.compression.min-response-size; server.compression.mime-types replaces the list and server.compression.additional-mime-types extends it. It is supported by Tomcat, Jetty and Reactor Netty. With the property on, the /authors page (2010 bytes of HTML, no ETag filter) requested with Accept-Encoding: gzip came back with Content-Encoding: gzip, vary: accept-encoding and 317 bytes on the wire against 2010 plain. (It was compressed although it is below 2048 bytes because the body was streamed without a known length, so the minimum could not be applied; do not rely on the threshold for small pages.)

The interesting interaction is with the ETag filter of the previous section. The /about page of 4075 bytes, with the filter writing a strong ETag, was requested with Accept-Encoding: gzip and compression enabled and came back uncompressed (Content-Length: 4075, no Content-Encoding). Tomcat does not compress a response that carries a strong ETag. Making the filter write a weak validator fixes it:

Weak ETags and compression together
ShallowEtagHeaderFilter filter = new ShallowEtagHeaderFilter();
filter.setWriteWeakETag(true);

With that change the same request returned ETag: W/"07a952673b83b41bda6e33a199fa2ebd9", Content-Encoding: gzip, vary: accept-encoding, Transfer-Encoding: chunked and 442 bytes on the wire for 4075 bytes of HTML. When a reverse proxy, an ingress or a CDN already compresses (see the Kubernetes ingress and the cloud pages linked below), compress in one place only.

Packaging and deployment

Thymeleaf adds no packaging rules of its own: templates are classpath resources under classpath:/templates/, so they ship inside the application jar. The points specific to a template application:

  • Executable jar. ./mvnw package produces a single jar (69,037,863 bytes for the scratch application with Spring Security, JPA, Hibernate and H2) that runs with java -jar. The templates are read from inside the jar, so the cache matters even more: the file is a zip entry. Editing a template means rebuilding the jar, and spring.thymeleaf.cache=false in production does not make editing possible, only slower.

  • Layered extraction for container images. java -Djarmode=tools -jar app.jar list-layers printed dependencies, spring-boot-loader, snapshot-dependencies and application; extract --layers --destination ex produced one directory per layer, with 66M in dependencies and 20K in application. Copying the layers separately lets a container runtime reuse the 66M layer when only a template changes. See Spring Boot — Efficient deployments (unpacking).

  • Cloud Native Buildpacks. ./mvnw spring-boot:build-image builds an image without a Dockerfile; see Spring Boot — Cloud Native Buildpacks. This command was not run for this page, because it downloads the builder image; the Docker section describes both this path and a multi-stage Dockerfile for a Spring Boot service in Java and Spring Boot Images, and Docker has the rest (image best practices and security, Compose for local development, CI/CD).

  • Kubernetes. A rendered-HTML application is stateless apart from the session, so the points of the Kubernetes section apply as for any web service: probes, rolling updates, autoscaling and an ingress that terminates TLS; start from Kubernetes. Keep the session either in a shared store or on sticky sessions, and remember the compression and cache headers above when the ingress or a CDN sits in front.

  • Cloud services. The container, app-service and CDN options of each provider are in the Cloud section.

  • Spring Boot in production profile. Put spring.thymeleaf.cache=true (or simply do not set it), spring.jpa.open-in-view=false and the compression and resource settings above in application.properties and keep development-only overrides in a local profile, as Spring Boot Auto-configuration & Properties describes.

GraalVM native image

Spring Boot can compile an application ahead of time to a native executable that starts in milliseconds; the Boot reference for GraalVM Native Images describes the build, the closed-world restrictions (no lazy class loading, a classpath and bean definitions fixed at build time, conditional properties not supported) and the fact that reflection, resources and proxies must be declared to GraalVM, partly through hints that Spring generates. What this page could verify is limited: the introduction page of the Boot native-image reference (the only page of it that was read in full) does not mention Thymeleaf, the spring-boot-thymeleaf 4.1.1, thymeleaf 3.1.5 and thymeleaf-spring6 3.1.5 jars contain no GraalVM metadata or runtime-hints files, and no native-image toolchain was installed on the machine used, so no native build of a Thymeleaf application was run for this page. What follows is therefore a consequence of how Thymeleaf works and not a documented guarantee:

  • Expressions such as ${book.title} are evaluated by SpEL through reflection on the model classes, and the templates are resources read at run time. Classes that are used only from templates are invisible to the static analysis that decides what to keep, so a native image may need hints for them. Boot’s advanced-topics page documents how to write them (RuntimeHintsRegistrar with @ImportRuntimeHints, and tests with RuntimeHintsPredicates); see Spring Boot — Custom Hints.

  • A missing hint typically shows up as a run-time error while a page renders and not as a build error, so render every template, with representative model data, in the native executable before shipping it. The SpringEL compiler generates classes at run time and should be treated as untested in a native image.

  • Because the application context is fixed at build time, the Thymeleaf properties that change bean creation cannot be changed at start up in a native executable; set them when you build.

Choose a native image for start-up and memory (scale-to-zero platforms, short-lived jobs), and weigh it against the longer build and the extra verification of reflection-driven templates; for a long-running web application on a normal JVM, the settings in this page matter more.

Pitfalls

Symptom Cause and fix

Every page is slow in production, the log shows only CACHE_MISS

spring.thymeleaf.cache=false was set in a shared property file or a profile. Remove it from production configuration.

A template change is not visible

The template is cached. In development use DevTools or spring.thymeleaf.cache=false; in production redeploy, set a resolver TTL, or clear the cache from code.

Response status is 200 but the page stops in the middle

Partial output is on, and an expression failed late in the template. Set produce-partial-output-while-processing=false or fix the expression.

LazyInitializationException wrapped in TemplateProcessingException

spring.jpa.open-in-view=false and the view reads an association that was not loaded. Fetch it in the controller or service (JOIN FETCH, entity graph, projection).

Dozens of identical select ... where x_id=? statements per request

N+1, hidden by Open Session In View. Count statements in a test with Hibernate statistics and fix the query.

A cacheable page is still no-store

Spring Security’s default cache headers. Set Cache-Control explicitly on the response, for the pages that are safe to cache.

Compression has no effect on an ETag page

Tomcat does not compress a response with a strong ETag. Use setWriteWeakETag(true).

A compressed response is also compressed by the proxy

Compress in one layer only.

Native executable renders blank values or fails on ${...}

A model class used only from templates lacks reflection hints. Register hints and test every template in the native build.

Where the books are outdated

The "Tips and tricks" chapter of Taming Thymeleaf (Wim Deblauwe; the consulted PDF is v1.1.1 from 2021, written for Spring Boot 2.3, Java 11 and Thymeleaf 3.0, and the current Leanpub edition is described as updated for Spring Boot 3) starts with turning Open Session In View off and loading the data the view needs with JOIN FETCH. That advice is unchanged and is the section above, and the same default and the same warning exist in Boot 4.1. What is different today: the persistence imports are jakarta.persistence.*; the Boot starters and test annotations moved (spring-boot-starter-webmvc, spring-boot-starter-thymeleaf-test, org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc); Hibernate ORM 7.4 generates the select statements shown above; and DevTools in Boot 4 gets the Thymeleaf cache default from a per-module META-INF/spring-devtools.properties file. The other tips of that chapter appear where they belong in this section: trimming input with StringTrimmerEditor and PropertyEditor versus Formatter in Conversion & Formatting, a global @ModelAttribute for values such as the application version in Spring MVC Integration, file upload, linked-entity <select> and dynamic rows in Forms & Data Binding and Fragment Rendering & Ajax, and the date picker web component in Alpine.js & Web Components.