How to Defer JDBC Connections in Spring Boot 4.1 (Lazy Fetch)
Writing
PERFORMANCE
Published September 2, 20267 min read

How to Defer JDBC Connections in Spring Boot 4.1 (Lazy Fetch)

Learn how Spring Boot 4.1 lazy connection fetching defers JDBC connections until the first statement, cutting pool pressure. One property, verified behavior, and limits.

Rabinarayan Patra - Software Development Engineer

By Rabinarayan Patra

SDE II at Amazon

spring-boot-lazy-connection-fetchingspring-boot-4-1lazyconnectiondatasourceproxyjdbcconnection-poolperformance

Here's a pattern that quietly eats your connection pool. A service method is annotated @Transactional. It opens a transaction on every call. But half the time it returns early from a cache hit or a validation failure, never running a single query. With the default setup, each of those calls still borrows a physical connection from the pool the moment the transaction starts, holds it for the whole method, and gives it back having done nothing.

Multiply that across a busy endpoint and you're exhausting a HikariCP pool with transactions that never touch the database. I've watched a service throw Connection is not available, request timed out under load while the database itself sat almost idle.

Spring Boot 4.1 ships a one-property fix: spring.datasource.connection-fetch=lazy. It wraps your DataSource so the physical connection is fetched only when the first statement actually runs. This post covers why the idle-transaction problem happens, how to turn lazy fetching on, how LazyConnectionDataSourceProxy behaves underneath, how to confirm it's working, and when it helps versus when it doesn't.

Why does an idle transaction still hold a database connection?

An idle transaction holds a connection because the transaction manager borrows one the instant the transaction begins, long before any SQL runs. When Spring sees @Transactional, it asks the DataSource for a connection at method entry to start the transaction, set auto-commit off, and apply isolation. That connection is then pinned to the thread until the method returns.

Here's the kind of method that wastes it:

@Service
public class QuoteService {
 
    private final QuoteRepository repository;
    private final QuoteCache cache;
 
    @Transactional
    public Quote getQuote(String symbol) {
        // Many calls return here, having opened a transaction
        // and borrowed a connection for absolutely nothing.
        Quote cached = cache.get(symbol);
        if (cached != null) {
            return cached;
        }
        return repository.findBySymbol(symbol);
    }
}

On a cache hit, the method opens a transaction, borrows a pooled connection, reads from an in-memory cache, commits, and returns the connection. The database was never involved, but for the duration of that call one of your pool slots was unavailable to everyone else. Under concurrency, those wasted slots are what tip a pool into timeouts.

What is lazy connection fetching in Spring Boot 4.1?

Lazy connection fetching is a Spring Boot 4.1 setting that defers the physical connection until the first JDBC statement, instead of grabbing it when the transaction opens. It works by wrapping your real DataSource in Spring's LazyConnectionDataSourceProxy.

The proxy hands the transaction manager a logical connection right away, so @Transactional is happy, but it doesn't pull a real connection from the pool yet. It records the settings the transaction asks for, like auto-commit and isolation level, and replays them on the real connection the moment a statement is actually issued. If no statement is ever issued, no physical connection is borrowed.

Spring describes the two modes precisely in the configuration metadata. eager (the default) means "fetch JDBC connections directly." lazy means "fetch JDBC connections as late as possible, or not at all if no statement is executed." That last clause is the whole point.

How do you turn on lazy connection fetching?

Set one property. In application.yml:

spring:
  datasource:
    connection-fetch: lazy

Or in application.properties:

spring.datasource.connection-fetch=lazy

That's the entire change. You don't define a bean, you don't touch your repositories, and you don't change a line of business code. Under the hood, Spring Boot 4.1 activates LazyConnectionDataSourceConfiguration, which is gated by @ConditionalOnProperty(name = "spring.datasource.connection-fetch", havingValue = "lazy"). It registers an infrastructure-role BeanPostProcessor that wraps your pooled DataSource:

// What Spring Boot 4.1 does for you when the property is "lazy"
public Object postProcessAfterInitialization(Object bean, String beanName) {
    if (bean instanceof DataSource dataSource) {
        return new LazyConnectionDataSourceProxy(dataSource);
    }
    return bean;
}

Your HikariCP (or other) pool is still the real DataSource. The proxy sits in front of it, and everything downstream, JPA, JDBC template, transaction manager, talks to the proxy.

How does LazyConnectionDataSourceProxy actually behave?

It returns a proxy connection on getConnection() and only borrows a real one from the pool when you run the first statement against it. Until that first prepareStatement, createStatement, or prepareCall, the underlying pool sees no checkout.

Walk through the cache-hit method again with the proxy in place:

  1. @Transactional opens a transaction. The proxy returns a logical connection. The pool is untouched.
  2. The cache hit returns the value. No statement ever ran.
  3. The transaction commits. Because no real connection was acquired, commit is a no-op on the pool.
  4. The pool slot was never taken, so it stayed available to other requests the entire time.

On a cache miss, the first repository.findBySymbol triggers a statement, the proxy borrows a real connection right then, applies the recorded auto-commit and isolation settings, and from that point the transaction behaves exactly as it always did. The only thing that changed is the timing of the checkout.

One detail worth knowing: the proxy records transaction settings and applies them lazily, so a read-only hint or isolation level you set is preserved and pushed to the real connection when it's finally acquired. You don't lose correctness, you just stop paying for connections you don't use.

How do you confirm connections are actually deferred?

Watch the pool's active-connection count under a workload that mixes database and non-database calls. HikariCP exposes this through Micrometer, so the cleanest check is the hikaricp_connections_active gauge before and after flipping the property.

// A quick integration check: a no-SQL transaction must not borrow a connection
@SpringBootTest
class LazyConnectionTests {
 
    @Autowired DataSource dataSource;
    @Autowired QuoteService quoteService;
 
    @Test
    void cacheHitDoesNotTouchThePool() {
        HikariPoolMXBean pool = ((HikariDataSource)
            ((LazyConnectionDataSourceProxy) dataSource).getTargetDataSource())
            .getHikariPoolMXBean();
 
        quoteService.getQuote("CACHED"); // served from cache, no SQL
 
        assertThat(pool.getActiveConnections()).isZero();
    }
}

If you'd rather not write a test, hit /actuator/metrics/hikaricp.connections.active while you load-test the endpoint. With eager, active connections track your request concurrency. With lazy, they track only the requests that actually run SQL. That gap is the pressure you just removed.

When does lazy connection fetching help, and when does it not?

It helps most when transactions frequently open without running SQL, and it does almost nothing when every transaction queries the database anyway. Match it to your traffic shape rather than turning it on everywhere by reflex.

It's a clear win for:

  • Read paths with a cache in front, where hits short-circuit before any query.
  • Service methods that validate input or check authorization and bail out before the data layer.
  • Endpoints fronting a connection pooler like PgBouncer in transaction mode, where holding fewer server-side connections is the whole game. I dug into that interaction in my Postgres connection pool guide.

It buys you little when every request runs at least one query, because the connection gets borrowed almost immediately anyway. And there's a subtle point to keep in mind: if you depend on a connection-level side effect happening at transaction start, like an early validation query or a session variable set through a custom DataSource, lazy fetching delays that until the first real statement. For the vast majority of services that just read and write through JPA, none of that applies, and the property is close to free safety margin on your pool.

What I like is that it's reversible in one line. Set it to lazy, watch your active-connection metric drop on cache-heavy endpoints, and if anything looks off, set it back to eager. It's the rare performance tweak that costs nothing to try and nothing to undo. If you're already on Spring Boot 4.1, this pairs naturally with the other 4.1 changes I covered in the SSRF mitigation guide.

For the details, see the Spring Boot 4.1 release notes and the Spring Framework LazyConnectionDataSourceProxy docs.

Keep Reading

Frequently Asked Questions

What is lazy connection fetching in Spring Boot 4.1?

Lazy connection fetching is a Spring Boot 4.1 option that wraps your auto-configured DataSource in a LazyConnectionDataSourceProxy, so a physical JDBC connection is taken from the pool only when the first statement executes. You enable it with spring.datasource.connection-fetch=lazy. If a transaction runs no SQL, it never borrows a connection at all.

How do you enable lazy connection fetching?

Set spring.datasource.connection-fetch=lazy in application.yml or application.properties. Spring Boot 4.1 then registers a bean post-processor that wraps the pooled DataSource in a LazyConnectionDataSourceProxy automatically. The default value is eager, which keeps the old behavior of fetching connections directly.

Does lazy connection fetching slow down queries?

No. It only changes when the connection is borrowed, not how queries run. The proxy defers acquisition until the first statement, then behaves like a normal connection for the rest of the transaction. The win is fewer connections held during request paths that open a transaction but sometimes return without touching the database.

Rabinarayan Patra - Software Development Engineer

Rabinarayan Patra

SDE II at Amazon. Previously at ThoughtClan Technologies building systems that processed 700M+ daily transactions. I write about Java, Spring Boot, microservices, and the things I figure out along the way. More about me →

X (Twitter)LinkedIn

Stay in the loop

Get the latest articles on system design, frontend and backend development, and emerging tech trends, straight to your inbox. No spam.