Spring Data JPA Performance: Pitfalls and Fixes
A walk through the Spring Data JPA defaults that quietly hurt performance : connection leases, dirty checking, N+1, DTOs, batching, open-in-view and the one-line fix for each.

Introduction
Spring Data JPA is one of the most widely used modules of the Spring ecosystem. It makes our life easier: we declare repository interfaces, queries are derived from method names or written in JPQL, and Hibernate maps rows to objects for us, which hides most of the boilerplate so we can focus on business logic and write less code.
But on the other hand, some developers complain that JPA is slow and hard to optimise for performance. The question is: are we using it the way it is supposed to be used? Little details can make a big difference and impact your app performance.
In this article we walk through different scenarios, how to trace performance leaks and how to solve these problems.
Why is the app slow?
Most JPA performance problems come down to one of these, and each has a section below:
- Holding the connection too long — acquiring it before we need it or releasing it after we're done (section 1)
- Writing when we only meant to read — dirty checking flushing an
updatewe never asked for (section 2) - One query turning into hundreds — the N+1 problem from eager or loop-loaded associations (section 3)
- Fetching more than the caller needs — returning full entities instead of DTOs (section 4)
- Writes that can't be batched — the wrong ID generation strategy silently disabling JDBC batching (section 5)
- Keeping the session open for the whole request — Open Session in View (section 6)
1. Database connection management
When your application needs to run a query, it first asks the DataSource for a connection. Without a pool, this means the JDBC driver opens a network connection to the database, performs the authentication handshake, and initialises the session before the query can run.
So if we open a connection every time we execute a query, the cost is:
time to get connection (~100ms) + time to execute the query (~32ms)
A connection pool solves this by keeping connections alive and reusing them. On startup it opens a set of physical connections, hands them out and takes them back as requests come and go, and opens more (up to a limit) when demand rises.
Example: with a pool size of 20 and 30 concurrent requests, 20 are served immediately and 10 wait for a connection to be returned.
So the pool only helps if those 20 connections are acquired as late as possible and released as soon as possible, otherwise the 10 waiting requests wait longer than they should.
Acquire & release
To see where a connection is held, we log the lease time with FlexyPool (threshold set to 0 so every lease is printed). Let's start with a clean example:
@Transactional(readOnly = true)
public List<Product> findAllProducts() {
return productRepository.findAll();
}
After execution, the log:
Connection leased for 95 millis, while threshold is set to 0 in dataSource FlexyPoolDataSource
Acquiring too early
Now if we add a blocking external call to the method:
@Transactional(readOnly = true)
public void withExternalCallBefore() {
externalService.callExternalService();
IO.println(productRepository.findAll());
}
Connection leased for 600 millis, while threshold is set to 0 in dataSource FlexyPoolDataSource
With the default settings the transaction opens as soon as we enter the method, and Hibernate acquires the connection early. Since @Transactional wraps the whole method, the connection is taken before externalService.callExternalService() and held during the 500ms call.
Make sure the connection is only acquired on the first real statement:
spring.jpa.properties.hibernate.connection.handling_mode=DELAYED_ACQUISITION_AND_RELEASE_AFTER_TRANSACTION
Now the external call runs before any connection is taken. Same execution:
Connection leased for 19 millis, while threshold is set to 0 in dataSource FlexyPoolDataSource
Releasing too late
If the external call comes after the DB call, we end up with the same issue: the connection will be released later than it should be.
@Transactional(readOnly = true)
public void withExternalCallAfter() {
IO.println(productRepository.findAll());
externalService.callExternalService();
}
Connection leased for 544 millis, while threshold is set to 0 in dataSource FlexyPoolDataSource
So in this case we manage the transaction boundary ourselves with a TransactionTemplate, scoped to just the DB work:
// a dedicated read-only template, configured once as a bean
// (never mutate a shared TransactionTemplate per call, it is not thread-safe)
private final TransactionTemplate readOnlyTx;
public MyService(PlatformTransactionManager tm) {
this.readOnlyTx = new TransactionTemplate(tm);
this.readOnlyTx.setReadOnly(true);
}
public void withExternalCallAfter() {
readOnlyTx.executeWithoutResult(status ->
IO.println(productRepository.findAll()));
externalService.callExternalService();
}
And it ends up released ideally, right after the DB call completed:
Connection leased for 25 millis, while threshold is set to 0 in dataSource FlexyPoolDataSource
Self invocation, the transaction that never existed
@Transactional works through a proxy. If we call the annotated method from another method in the same class, the call never goes through the proxy, so the annotation is ignored. Each Spring Data repository call still runs in its own short transaction, taking and releasing a connection each time, and the in-memory changes we make between calls are never flushed because there is no surrounding transaction to flush them.
Before
@Service
public class ProductService {
public void process() {
deactivate(List.of(1L, 2L, 3L)); // internal call, proxy is bypassed
}
@Transactional
public void deactivate(List<Long> ids) {
for (Long id : ids) {
Product p = productRepository.findById(id).orElseThrow();
p.setActive(false);
productRepository.save(p); // needed: no surrounding tx to auto-flush
}
}
}
Connection leased for 12 millis ... -- findById + update for id 1
Connection leased for 11 millis ... -- findById + update for id 2
Connection leased for 13 millis ... -- findById + update for id 3
... a separate transaction per iteration, and no rollback if one fails halfway
After
Move the transactional method to another bean, or use TransactionTemplate:
@Service
public class ProductService {
private final ProductUpdater productUpdater; // separate bean, real proxy
public void process() {
productUpdater.deactivate(List.of(1L, 2L, 3L));
}
}
Connection leased for 38 millis ...
One transaction, one connection, and a rollback that actually works.
REQUIRES_NEW can deadlock the whole pool
This is the one that hurts, because nothing throws. A method that already holds a connection calls another one with REQUIRES_NEW, which asks the pool for a second connection while still holding the first.
Before
@Transactional
public void placeOrder(Order order) {
orderRepository.save(order);
auditService.log(order); // @Transactional(propagation = REQUIRES_NEW)
}
With a pool size of 20 and 20 concurrent requests: all 20 threads hold one connection each, and all 20 are waiting for a second one that nobody will ever give back. The app just stops.
HikariPool-1 - Connection is not available, request timed out after 30000ms
After
Keep the audit in the same transaction, or push it outside with an event that runs after commit:
@Transactional
public void placeOrder(Order order) {
orderRepository.save(order);
events.publishEvent(new OrderPlaced(order.getId()));
}
@TransactionalEventListener(phase = AFTER_COMMIT)
public void onOrderPlaced(OrderPlaced event) {
auditService.log(event.orderId());
}
If we really need REQUIRES_NEW, the pool has to be sized for two connections per thread, not one.
Missing readOnly = true
Without readOnly, the flush mode stays at AUTO, so for every entity we load Hibernate keeps a full snapshot and runs a dirty check at flush time. Spring also skips the Connection.setReadOnly(true) hint, so the transaction can't be routed to a read replica. On a large read that snapshot and dirty check is pure overhead, and if any loaded entity looks dirty (see section 2) it turns into an update we never asked for.
Before
@Transactional
public List<Product> findAllProducts() {
return productRepository.findAll();
}
After
@Transactional(readOnly = true)
public List<Product> findAllProducts() {
return productRepository.findAll();
}
select p1_0.id, p1_0.name, p1_0.attributes from product p1_0
No snapshot, no dirty check, no flush, and the transaction can be routed to a read replica later.
2. Dirty checking
On flush (before a query and at commit) Hibernate compares every managed entity with the snapshot it took when the entity was loaded. If something looks different, it flushes an update.
The problem shows up when the entity has an object or a List stored as JSON/JSONB. Hibernate compares the current value with the snapshot using equals, and if the class has no proper equals, the default identity comparison never matches, so the entity looks dirty every single time even when we changed nothing.
Before
@Entity
public class Product {
@Id
private Long id;
private String name;
@JdbcTypeCode(SqlTypes.JSON)
private Attributes attributes;
}
public class Attributes {
private String color;
private List<String> tags;
// no equals, no hashCode
}
@Transactional
public void readProduct(Long id) {
Product product = productRepository.findById(id).orElseThrow();
// we didn't change anything here
}
Log:
select p1_0.id, p1_0.name, p1_0.attributes from product p1_0 where p1_0.id = ?
update product set name = ?, attributes = ? where id = ?
We only wanted to read, and we ended up with a write.
After
Give the JSON class a real equals and hashCode:
public class Attributes {
private String color;
private List<String> tags;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Attributes that = (Attributes) o;
return Objects.equals(color, that.color)
&& Objects.equals(tags, that.tags);
}
@Override
public int hashCode() {
return Objects.hash(color, tags);
}
}
Log:
select p1_0.id, p1_0.name, p1_0.attributes from product p1_0 where p1_0.id = ?
The update is gone.
3. The N+1 problem
@ManyToOne and @OneToOne are EAGER by default, so Hibernate has to fill the association for every row it loads. One query for the list, then one extra query per row (@OneToMany and @ManyToMany are LAZY by default, but a lazy collection touched in a loop produces the exact same pattern).
Before
@Entity
public class Product {
@Id
private Long id;
@ManyToOne // EAGER by default
private Category category;
}
@Transactional(readOnly = true)
public List<Product> findAllProducts() {
return productRepository.findAll();
}
Log, with 100 products:
select p1_0.id, p1_0.category_id, p1_0.name from product p1_0
select c1_0.id, c1_0.name from category c1_0 where c1_0.id = ?
select c1_0.id, c1_0.name from category c1_0 where c1_0.id = ?
... 100 times
101 queries for one endpoint.
After
Always make @ManyToOne and @OneToOne explicitly LAZY:
@ManyToOne(fetch = FetchType.LAZY)
private Category category;
And when we actually need the category, we ask for it in the same query with a join fetch:
@Query("select p from Product p join fetch p.category")
List<Product> findAllWithCategory();
select p1_0.id, p1_0.name, c1_0.id, c1_0.name
from product p1_0 join category c1_0 on c1_0.id = p1_0.category_id
One query.
Making it
LAZYalone is not the fix, it just moves the problem. If we make it lazy and then touchproduct.getCategory()inside a loop, we get the same 101 queries. Lazy plus join fetch where we need it is the fix.
The one line that fixes most N+1
Before rewriting every query with a join fetch, try this:
spring.jpa.properties.hibernate.default_batch_fetch_size=50
Hibernate now loads lazy associations 50 at a time instead of one by one:
select c1_0.id, c1_0.name from category c1_0 where c1_0.id in (?,?,?,... 50 ids)
101 queries become 3. It costs one property and no mapping change.
4. Never return entities, always return DTOs
When we return an entity, it stays managed in the persistence context until the transaction ends, we pay the dirty check on it, and we select every column even if the caller needs three of them.
Before
@Transactional(readOnly = true)
public List<Product> findAllProducts() {
return productRepository.findAll();
}
select p1_0.id, p1_0.name, p1_0.description, p1_0.attributes,
p1_0.created_at, p1_0.updated_at, p1_0.category_id
from product p1_0
We load the whole row, plus the lazy proxies, plus the snapshot for the dirty check.
After
public record ProductDTO(Long id, String name, BigDecimal price) {}
@Query("select new com.app.dto.ProductDTO(p.id, p.name, p.price) from Product p")
List<ProductDTO> findAllProducts();
select p1_0.id, p1_0.name, p1_0.price from product p1_0
Only the columns we need, nothing managed, no dirty check, and no lazy loading exception later when Jackson serialises it.
5. Writes and batching
Batching is the thing that turns 100 inserts into 2 round trips. But a few defaults quietly turn it off.
Use the correct ID generation
Before
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Transactional
public void saveAll(List<Product> products) {
productRepository.saveAll(products); // 100 products
}
insert into product (name, price) values (?, ?)
insert into product (name, price) values (?, ?)
... 100 statements
With IDENTITY, Hibernate has to run the insert immediately to get the generated id back, so it silently disables batching. Setting batch_size changes nothing.
After
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "product_seq")
@SequenceGenerator(name = "product_seq", sequenceName = "product_seq", allocationSize = 50)
private Long id;
spring.jpa.properties.hibernate.jdbc.batch_size=50
spring.jpa.properties.hibernate.order_inserts=true
spring.jpa.properties.hibernate.order_updates=true
insert into product (name, price) values (?, ?) -- batched, 50 rows
insert into product (name, price) values (?, ?) -- batched, 50 rows
100 inserts in 2 round trips.
6. Open Session in View
Spring Boot has open-in-view set to true by default (it logs a warning to tell you so). It binds the EntityManager to the whole request, from the controller until the response is written, so lazy loading still works during serialisation. The connection is acquired lazily on first use, but with no transaction boundary to release it, it is held until the request ends. That is exactly what we spent section 1 trying to avoid.
Before
# default, nothing in the properties file
@GetMapping("/products")
public List<Product> getProducts() {
return productService.findAllProducts(); // returns managed entities, query ~20ms
// Jackson then serialises them, walking lazy associations, while the
// EntityManager (and its connection) is still bound to the request
}
Connection leased for 320 millis, while threshold is set to 0 in dataSource FlexyPoolDataSource
The query took 20ms, we held the connection for 320ms because serialisation and lazy loading happened while the session was still open.
After
spring.jpa.open-in-view=false
Connection leased for 21 millis, while threshold is set to 0 in dataSource FlexyPoolDataSource
The connection goes back to the pool as soon as the transaction ends. And if turning it off breaks something with a LazyInitializationException, that is a good thing, it just showed us a place where we were lazy loading outside the transaction without knowing it.
Takeaways
- Hold the connection for as little time as possible. Do external calls outside the transaction, use a
TransactionTemplatewhen a method mixes DB work and I/O, and sethibernate.connection.handling_mode=DELAYED_ACQUISITION_AND_RELEASE_AFTER_TRANSACTION. - Mark read paths
@Transactional(readOnly = true)to skip the snapshot, the dirty check and the flush. - Give every JSON/JSONB class a real
equals/hashCode(or make it arecord) so dirty checking does not turn reads into writes. - Make
@ManyToOneand@OneToOneLAZY, thenjoin fetchwhere you need the association.hibernate.default_batch_fetch_sizeis the cheap first fix for N+1. - Return DTOs, not entities, so you select only the columns you need and nothing stays managed.
- Use a
SEQUENCEid withallocationSizeplushibernate.jdbc.batch_sizeso bulk inserts actually batch.IDENTITYsilently disables batching. - Turn off
open-in-viewso the session and its connection are released when the transaction ends, not when the response is written. - Turn off
hikari.auto-commitand let Hibernate own the transaction boundaries. - Measure. Log connection lease times (FlexyPool), enable
hibernate.generate_statistics, and watch the SQL. Most of these problems are invisible until you look.
Keep reading
Spring Data - JPA DataTable
This guide walks you through the process of building a Spring boot application that uses JPA DataTable.
Continue readingSpring Boot - Virtual Thread
With Java 24+ blocking code scales like async without the callback complexity. We look at virtual threads after the removal of synchronized pinning, and put StructuredTaskScope and ScopedValue to work in Spring Boot backed by benchmarks
Continue readingSpring Web - Rest Client
Spring Framework introduces RestClient a new Fluent API to make synchronous HTTP requests
Continue readingSpring Authorization Server - OAuth JWT
This guide walks you through the process of building a Spring Boot Authorization Server JWT that uses Spring Security Security and Spring Security OAuth Resource. Applying the new way to configure Spring Authorization Server JWT without the Custom Filter
Continue reading