This is a reference for ten changes that made a small Spring Boot shop application faster on my laptop. For each change you get the code, the command I measured it with and the output I got. At the end of each section there is a short note on when the change does not help, because several of them only pay off under specific conditions.
Result first
| # | Change | Before | After | Measured with |
|---|---|---|---|---|
| 1 | Index on the column the slow query filters by | 108 req/s | 18,383 req/s | k6, 20 users |
| 2 | join fetch for a lazy collection | 101 queries, 25 ms | 1 query, 2.4 ms | curl |
| 3 | JDBC batching, IDENTITY id | 10,000 statements, 1,379 ms | 10,000 statements, 1,438 ms | curl |
| 3 | JDBC batching, SEQUENCE id | 10,000 statements, 1,379 ms | 201 statements, 102 ms | curl |
| 4 | @Cacheable with Caffeine | 421 ms | 1.8 ms on the second call | curl |
| 5 | Projection in place of the entity | 10.2 MB, 24.8 ms | 240 KB, 3.5 ms | curl |
| 6 | spring.jpa.open-in-view=false | 94 req/s | 911 req/s | k6, 100 users |
| 7 | Keyset paging in place of OFFSET | 139 ms | 1.5 ms | curl |
| 8 | Three independent calls in parallel | 921 ms | 306 ms | curl |
| 9 | Response compression | 240 KB, 6.9 ms | 43 KB, 8.4 ms | curl |
| 10 | JDK AOT cache | 1.884 s startup | 0.677 s startup | application log |
Row 3 appears twice on purpose. The batching property is accepted in both cases, and in one of them it changes nothing. Row 9 is the only change that made my local measurement slower, and I would still switch it on for an API that is called over a real network.
Setup
- Spring Boot
4.1.1with Spring Data JPA, Oracle JDK 25, on an Apple M4 Max. - PostgreSQL 18 in Docker, with
pg_stat_statementsloaded. - Data: 2,000,000 orders spread over 200,000 customer emails, 300,000 order items that belong to the newest 100,000 orders, and 5,000 products that each carry about 2 KB of description text.
- HikariCP with
maximum-pool-size=10, Tomcat with 400 request threads. - The application runs twice: on port 8085 with the default settings, and on port 8086 with a
tunedprofile that holds every property this post adds.
spring.jpa.properties.hibernate.jdbc.batch_size=50
spring.datasource.url=jdbc:postgresql://localhost:5441/shop?reWriteBatchedInserts=true
spring.jpa.open-in-view=false
server.compression.enabled=trueapplication-tuned.properties
Every number is from a single run. Treat the ratios as the result and the absolute values as specific to this machine.
1. Let the database name the slow query
pg_stat_statements keeps a row per normalized query with its call count and total execution time. It has to be listed in shared_preload_libraries before the server starts, and then created as an extension in the database.
The endpoint under test is a derived Spring Data method, so there is no SQL in the project to read:
List<Order> findByCustomerEmail(String email);OrderRepo.java
The load test sends 2,000 requests from 20 virtual users:
k6 run -q k6/by-email.js | grep -E "http_req(s|_duration)\."
http_req_duration..............: avg=183.5ms p(95)=259.37ms
http_reqs......................: 2000 108.425702/soutput
After that run, the statistics view shows where the time went:
psql -c "select calls, round(total_exec_time) as total_ms, left(substring(query from 'from .*'), 44) as query from pg_stat_statements order by 2 desc limit 3"
calls | total_ms | query
-------+----------+----------------------------------------------
2000 | 181844 | from orders o1_0 where o1_0.customer_email=$
200 | 652 | from products p1_0 order by p1_0.id
200 | 10 | from orders o1_0 where o1_0.id>$1 order by o
(3 rows)output
The first row took 181,844 ms in total and the second 652 ms, so there is one query worth looking at. EXPLAIN ANALYZE on it, before and after an index:
psql -c "explain (analyze, costs off) select * from orders where customer_email = 'user4242@example.com'" | grep -E "Scan|Execution"
-> Parallel Seq Scan on orders (actual time=8.868..35.285 rows=3.33 loops=3)
Execution Time: 38.995 msoutput
psql -c "create index orders_email on orders (customer_email)"
Bitmap Heap Scan on orders (actual time=0.096..0.163 rows=10.00 loops=1)
-> Bitmap Index Scan on orders_email (actual time=0.023..0.023 rows=10.00 loops=1)
Execution Time: 0.187 msoutput
The same k6 script afterwards:
http_req_duration..............: avg=1.03ms p(95)=1.86ms
http_reqs......................: 2000 18382.521898/soutput
When it does not help: an index costs disk space and makes every insert and update on that table a little slower. A column with few distinct values (the status column here has four) usually gets no benefit from a plain index. On a table that is in use, create index concurrently avoids blocking writes while the index is built.
2. N+1 and join fetch
An order has a lazy list of items:
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
List<OrderItem> items;Order.java
The service loads 100 orders and reads o.items.size() for each. That one expression triggers a query per order. The repository has three ways to load the same orders:
// N+1: the items are loaded later, one query per order
@Query("select o from Order o where o.id > :after")
List<Order> recent(long after);
// one query: orders and their items together
@Query("select o from Order o join fetch o.items where o.id > :after")
List<Order> recentJoinFetch(long after);
// the same thing, the Spring Data way
@EntityGraph(attributePaths = "items")
List<Order> findByIdGreaterThan(long after);OrderRepo.java
To count queries per request, the endpoint reads Hibernate’s getPrepareStatementCount() before and after the call and returns the difference in an X-Queries header. That needs hibernate.generate_statistics=true.
curl -s -o /dev/null -w '%header{x-queries} queries, %{time_total}s\n' localhost:8085/orders/recent
curl -s -o /dev/null -w '%header{x-queries} queries, %{time_total}s\n' 'localhost:8085/orders/recent?fetch=join'
curl -s -o /dev/null -w '%header{x-queries} queries, %{time_total}s\n' 'localhost:8085/orders/recent?fetch=graph'
101 queries, 0.025003s
1 queries, 0.002421s
1 queries, 0.002965soutput
25 ms for 101 queries is fast because the database is on the same machine. Each of those queries is a network round trip, so the gap grows with the distance to the database.
When it does not help: a join fetch on a collection multiplies the rows, one per item. Combined with paging, Hibernate has to page in memory and logs a warning about it. Fetching two collections in one query multiplies them with each other.
3. Batching, and the id strategy that switches it off
The import saves 10,000 new rows with one saveAll call. The property that should group the inserts:
spring.jpa.properties.hibernate.jdbc.batch_size=50
The endpoint reports the row count, the time and the number of prepared statements. First the default application, then the one with the property:
curl -s -X POST 'localhost:8085/import?n=10000&id=identity'
curl -s -X POST 'localhost:8086/import?n=10000&id=identity'
10000 rows in 1379 ms, 10000 statements
10000 rows in 1438 ms, 10000 statementsoutput
Nothing changed, and nothing in the log said why. The reason is the id mapping of the entity:
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
Long id;Events.java
With IDENTITY the database assigns the id during the insert. Hibernate needs the id of every entity it manages, so it runs each insert on its own, and the Hibernate documentation states that insert batching is disabled for identity generators. A second entity takes its ids from a sequence in blocks of 50:
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "events")
@SequenceGenerator(name = "events", sequenceName = "events_seq_id",
allocationSize = 50)Events.java
curl -s -X POST 'localhost:8086/import?n=10000&id=seq'
10000 rows in 102 ms, 201 statementsoutput
Two details about this number. The sequence in the database has to increase by the same 50 as allocationSize. And the tuned profile also sets reWriteBatchedInserts=true on the JDBC URL, which lets the PostgreSQL driver merge a batch into multi-row inserts. I did not measure the sequence entity without that flag, so the 102 ms is the result of both settings together.
When it does not help: batching groups statements of the same shape. If inserts for different entities alternate, add hibernate.order_inserts=true so Hibernate sorts them first.
4. Cache an answer that rarely changes
The report sums the totals of all 2,000,000 orders per customer and returns the top ten. The service exposes the query twice, once plain and once behind @Cacheable:
public List<TopCustomer> topCustomers() {
return orders.topCustomers(); // adds up 2,000,000 orders
}
@Cacheable("topCustomers")
public List<TopCustomer> topCustomersCached() {
return orders.topCustomers();
}Reports.java
spring.cache.cache-names=topCustomers
spring.cache.caffeine.spec=expireAfterWrite=60sapplication.properties
The application class also needs @EnableCaching, and Caffeine has to be on the classpath.
curl -s -o /dev/null -w '%{time_total}s\n' localhost:8085/report/top-customers
curl -s -o /dev/null -w '%{time_total}s\n' 'localhost:8085/report/top-customers?cached=true'
curl -s -o /dev/null -w '%{time_total}s\n' 'localhost:8085/report/top-customers?cached=true'
0.420887s
0.430755s
0.001752soutput
The first cached call pays the full price because the cache is empty. For the next 60 seconds the answer comes from memory.
When it does not help: the answer can be up to 60 seconds old, which is fine for a ranking and wrong for a stock level. Caffeine lives inside one JVM, so three instances of the application hold three separate caches that expire at different moments. @Cacheable works through a Spring proxy, so a call from another method of the same class skips the cache.
5. Select only the columns the page shows
The product entity has a description of about 2 KB. A list page never displays that text.
record ProductView(Long id, String name, BigDecimal price) {}
interface ProductRepo extends JpaRepository<Product, Long> {
@Query("""
select new shop.ProductView(p.id, p.name, p.price)
from Product p order by p.id""")
List<ProductView> findAllViews();Products.java
curl -s -o /dev/null -w '%{size_download} bytes, %{time_total}s\n' localhost:8085/products
curl -s -o /dev/null -w '%{size_download} bytes, %{time_total}s\n' localhost:8085/products/list
10225082 bytes, 0.024767s
240082 bytes, 0.003549soutput
The response is 43 times smaller. The saving is in three places: Postgres reads less, Hibernate builds records and does not track 5,000 managed entities, and the JSON is shorter.
When it does not help: a projection is read-only. Code that changes the object and expects Hibernate to write it back needs the entity.
6. Open session in view
Spring Boot sets spring.jpa.open-in-view to true unless you say otherwise, and logs a warning about that at startup. With it, the persistence context stays open until the response is written. Once a query has run, the request keeps its database connection for the rest of its lifetime.
In the checkout endpoint below, a single findById is followed by a 100 ms sleep that plays the role of an external payment API:
@GetMapping("/checkout/{id}")
String checkout(@PathVariable long id) throws InterruptedException {
Order order = orders.findById(id).orElseThrow(); // one query
Thread.sleep(100); // stands in for the payment provider
return "paid " + order.total + "\n";
}CheckoutApi.java
With 100 users at once and a pool of 10 connections:
k6 run -q -e PORT=8085 k6/checkout.js | grep -E "http_req(s|_duration)\."
k6 run -q -e PORT=8086 k6/checkout.js | grep -E "http_req(s|_duration)\."
http_req_duration..............: avg=1.01s p(95)=1.82s
http_reqs......................: 1000 93.922871/soutput
http_req_duration..............: avg=108.65ms p(95)=126.8ms
http_reqs......................: 1000 910.760084/soutput
The first result is close to what the arithmetic predicts: 10 connections, each held for about 100 ms, allow about 100 requests per second. With the property set to false the connection goes back to the pool when the repository call returns, and the request waits for the provider without it.
When it does not help: if a controller or a JSON serializer touches a lazy association after the service returned, that access now fails with a LazyInitializationException. The data has to be loaded inside the service, which sections 2 and 5 already do. An endpoint that holds a connection for another reason, for example a slow call inside a @Transactional method, is not fixed by this property.
7. Paging without OFFSET
// paging with OFFSET: the database reads and drops the skipped rows
@Query("select o from Order o order by o.id")
List<Order> page(Pageable page);
// keyset paging: start right after the last id the client saw
@Query("select o from Order o where o.id > :lastId order by o.id limit 20")
List<Order> pageAfter(long lastId);OrderRepo.java
curl -s -o /dev/null -w '%{time_total}s\n' 'localhost:8085/orders/page?offset=1900000'
curl -s -o /dev/null -w '%{time_total}s\n' 'localhost:8085/orders/after?id=1900000'
0.139213s
0.001473soutput
Both calls return the same 20 orders. The first makes Postgres walk past 1,900,000 rows, and its cost grows with the page number. The second starts at a position in the primary key index, and costs the same on any page.
When it does not help: keyset paging has no “go to page 500”. It offers next and previous. It also needs a sort key that is unique and indexed; when the list is sorted by a date, add the id as a second key.
8. Independent calls in parallel
The dashboard needs three answers from other services. Each call takes 300 ms in this demo.
@GetMapping("/dashboard")
String oneAfterAnother() {
String profile = call("profile");
String orders = call("orders");
String offers = call("offers");
return profile + " " + orders + " " + offers + "\n";
}
@GetMapping("/dashboard/parallel")
String parallel() {
var profile = CompletableFuture.supplyAsync(() -> call("profile"), pool);
var orders = CompletableFuture.supplyAsync(() -> call("orders"), pool);
var offers = CompletableFuture.supplyAsync(() -> call("offers"), pool);
return profile.join() + " " + orders.join() + " " + offers.join() + "\n";
}DashboardApi.java
curl -s -o /dev/null -w '%{time_total}s\n' localhost:8085/dashboard
curl -s -o /dev/null -w '%{time_total}s\n' localhost:8085/dashboard/parallel
0.921129s
0.306393soutput
pool is a fixed thread pool of 30 threads that belongs to this controller. Passing your own executor matters: without it, supplyAsync uses the common fork-join pool, which is sized for CPU work and shared by the whole JVM.
When it does not help: the calls must not depend on each other’s results. The total is still as slow as the slowest call, and one failing call makes its join() throw, so each call needs a timeout and a decision about what the page shows without it.
9. Response compression
server.compression.enabled=true
curl -s --compressed -o /dev/null -w '%{size_download} bytes, %{time_total}s\n' localhost:8085/products/list
curl -s --compressed -o /dev/null -w '%{size_download} bytes, %{time_total}s\n' localhost:8086/products/list
240082 bytes, 0.006928s
42927 bytes, 0.008429soutput
The body is 5.6 times smaller and the request took 1.5 ms longer. On localhost there is no network to save time on, so the CPU cost of gzip is all that shows. I did not measure this over a slow connection; the size difference is the measured part.
When it does not help: Spring Boot compresses responses of 2 KB and more by default, and only for text-like content types such as JSON and HTML. If a reverse proxy or CDN in front of the application already compresses, doing it twice gains nothing.
10. A JDK AOT cache for startup
JDK 24 added a cache for loaded and linked classes (JEP 483). JDK 25 lets you create it with one flag. Spring’s spring.context.exit=onRefresh makes the training run stop as soon as the context is ready.
java -jar shop.jar | grep -o "Started.*seconds"
java -XX:AOTCacheOutput=shop.aot -Dspring.context.exit=onRefresh -jar shop.jar | grep "AOTCache creation"
java -XX:AOTCache=shop.aot -jar shop.jar | grep -o "Started.*seconds"
Started ShopApplication in 1.884 seconds
AOTCache creation is complete: shop.aot 121733120 bytes
Started ShopApplication in 0.677 secondsoutput
The cache file is 116 MB for this application, which adds to the size of a container image.
When it does not help: the cache belongs to one JDK build and one classpath. After a new build of the jar, or a JDK update, it has to be recorded again, so it fits best as a step of the image build. It shortens startup and does nothing for requests once the application is running.
What this does not show
- Other hardware and a real network. The application, the database and the load generator share one laptop. Round trips cost almost nothing here, which makes sections 2 and 3 look smaller than they would with a remote database and section 9 look worse.
- Repeated runs. Each number is one run. I did not compute a spread.
- Writes under load. Only section 3 writes. The cost of the index from section 1 on inserts was not measured.
- Memory. Neither the heap used by the Caffeine cache nor the footprint of the AOT cache at runtime was measured.
- Cache invalidation. The cache expires by time only. Nothing here evicts it when an order changes.