Search

↑↓ to navigate to openEsc to close

Site

  • Home
  • All articles
  • About
  • Privacy Policy

Series

  • spring authorize server
  • spring boot
  • spring cloud
  • spring data
  • spring security

Newsletter

A monthly digest of new Spring Boot tutorials. No spam.

© 2026 Nonestack

LinkedInFacebook
  1. Home
  2. spring data
  3. Spring Data JPA Performance: Pitfalls and Fixes

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.

September 7, 202613 min readDjamel Eddine Korei

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 update we 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 LAZY alone is not the fix, it just moves the problem. If we make it lazy and then touch product.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 TransactionTemplate when a method mixes DB work and I/O, and set hibernate.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 a record) so dirty checking does not turn reads into writes.
  • Make @ManyToOne and @OneToOne LAZY, then join fetch where you need the association. hibernate.default_batch_fetch_size is 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 SEQUENCE id with allocationSize plus hibernate.jdbc.batch_size so bulk inserts actually batch. IDENTITY silently disables batching.
  • Turn off open-in-view so the session and its connection are released when the transaction ends, not when the response is written.
  • Turn off hikari.auto-commit and 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.
spring data · Part 2 of 2
PreviousSpring Data - JPA DataTable

Table of contents

  • Introduction
  • Why is the app slow?
  • 1. Database connection management
  • Acquire &amp; release
  • Acquiring too early
  • Releasing too late
  • Self invocation, the transaction that never existed
  • REQUIRES_NEW can deadlock the whole pool
  • Missing readOnly = true
  • 2. Dirty checking
  • Before
  • After
  • 3. The N+1 problem
  • Before
  • After
  • The one line that fixes most N+1
  • 4. Never return entities, always return DTOs
  • Before
  • After
  • 5. Writes and batching
  • Use the correct ID generation
  • 6. Open Session in View
  • Before
  • After
  • Takeaways
40views
  • X / Twitter
  • LinkedIn
  • Facebook
40views
  • X / Twitter
  • LinkedIn
  • Facebook

Keep reading

spring data

Spring Data - JPA DataTable

This guide walks you through the process of building a Spring boot application that uses JPA DataTable.

Continue reading
10 min read1,815 views
spring boot

Spring 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 reading
7 min read130 views
spring boot

Spring Web - Rest Client

Spring Framework introduces RestClient a new Fluent API to make synchronous HTTP requests

Continue reading
4 min read2,222 views
spring authorize server

Spring 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
9 min read2,173 views