Driver FixRecommendedSound, Wi-Fi or graphics acting up? Check drivers firstFind missing or outdated drivers fast.Check DriversHalloween DealsAmazon USHalloween week deals: useful picks before month-endAmazon US: current deals, useful picks and tech finds.Check DealsSlow PC?RecommendedPC slow today? Run a repair scan before it gets worseResolve common Windows issues and optimize system performance.Scan Now×
FREEDOM251

Freedom251 › Blog › @Transactional

The Silent Killer in Spring Boot: Why @Transactional Around Third-Party APIs Exhausts Your Connection Pool

By Freedom251 Team · · 8 min read

What’s actually slowing this PC down?

Pick the symptom - the matching free tool is one click away.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

When a Spring Boot method annotated with @Transactional calls a payment, shipping, or other third-party API before the transaction completes, the remote wait can keep a database connection checked out for as long as that call takes. Under concurrent load, requests queue for connections, and even simple queries from unrelated endpoints start timing out, while the database itself may look under-used. The fix is almost always to narrow the transaction so it wraps only the database work and leaves the network call outside.

The annotation alone does not explain the problem. What matters is when the connection is first needed, how long the transaction stays open, and how many requests are waiting at the same time.

As an Amazon Associate I earn from qualifying purchases.

What @Transactional does and does not do

An annotation such as @Transactional declares a transaction boundary. It does not, by itself, say that a pooled JDBC connection is taken the moment the method starts. Spring Boot’s SQL Databases reference describes a lazy mode, enabled with spring.datasource.connection-fetch=lazy, in which connections are fetched from the pool only when actually needed. In the reference’s words: “With this feature enabled, JDBC Connections are only fetched from the pool when actually necessary.” (Spring Boot SQL Databases reference)

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Lazy fetching changes the picture for one important case. If a method makes its remote call before it runs any JDBC statement, the transaction can be open without holding a connection. It is a different matter once the method has already touched the database. A connection fetched for earlier work is not returned to the pool just because the method keeps going inside the same transaction. The connection stays associated with that transaction until it commits or rolls back.

How the connection ends up held during the API call

Consider two methods that look nearly identical in code review. The only difference is the order of operations.

Order inside the @Transactional method When a connection is first needed (lazy mode) Held during the remote call?
Remote call first, then the first JDBC statement At the first JDBC statement, after the call returns No connection is held during the call
JDBC write first, then remote call, then a second JDBC write At the first write, before the call starts Yes, from the first write until the transaction completes

The second row is the dangerous one. The transaction manager and persistence provider keep the connection bound for the life of the transaction, so the duration of the vendor call is added to connection occupancy even though no SQL runs while the application waits. This is an inference from how Spring binds transaction resources and defers acquisition. It is not a guarantee for every ORM, driver, or transaction-manager combination, so confirm the behavior in your own stack by measuring hold time, as described later in this article. The Spring Framework data-access reference covers the transaction-resource handling in its 5.3.30 reference, and the same material appears in the 5.2.x reference.

Why the damage is silent until traffic arrives

A single request that waits three seconds on a vendor is only slow. The problem appears under concurrency. Suppose, purely as an illustration and not a measured figure, that a pool has eight connections and each request holds one for four seconds while it waits on a vendor. Eight simultaneous requests occupy the whole pool. The ninth request, even one that only reads a product row, waits for a free connection. If the wait exceeds the pool’s connection timeout, that request fails.

Free tools Windows power users keep installed

One-click scans. No signup required.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Spring Boot prefers HikariCP when it is on the classpath, and the JDBC and JPA starters bring it in. Its Hikari-specific options live under the spring.datasource.hikari.* namespace. Tuning those options does not shorten a transaction. If a transaction is holding a connection through a remote call, a larger pool only lets more requests hold connections at once.

The nested REQUIRES_NEW trap

The same resource problem appears in a different form with PROPAGATION_REQUIRES_NEW. The Spring Framework 5.3.30 reference warns: “This may lead to exhaustion of the connection pool and potentially to a deadlock if several threads have an active outer transaction and wait to acquire a new connection for their inner transaction, with the pool not being able to hand out any such inner connection anymore.” That warning concerns nested transaction resources, not remote calls specifically. The outer transaction keeps its connection while the inner one asks for another, so both compete for the same pool.

If your method already holds a connection and calls a method that declares REQUIRES_NEW, check whether the inner scope needs a second connection. Do not add REQUIRES_NEW to a path that already runs inside a constrained pool without checking that.

The self-invocation trap

In the default proxy mode, transaction advice applies when a call passes through the Spring proxy. A method calling another method on the same class bypasses the proxy, so the inner method’s annotation does not create the separate transaction its annotation appears to declare. The Spring reference documents this in its transaction proxy section, and the same behavior is described in the 5.2.x reference.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

This matters when you fix a long transaction by extracting a helper. If the helper lives in the same class and is called directly, it runs inside the caller’s transaction, or without one, depending on how the caller was entered. Move the transactional step into a separate bean, or use a TransactionTemplate so the boundary is explicit in code.

Restructuring the method

The most reliable fix is to shorten the database transaction so it wraps only the database operations. Where the business workflow allows it, perform the remote call outside any transaction, then persist the result in a focused transaction. Here is a method that looks reasonable but holds a connection through the payment call:

@Transactional
public Order placeOrder(OrderRequest request) {
    Order order = orderRepository.save(Order.pending(request));
    PaymentResult payment = paymentClient.charge(request.amount(), request.cardToken());
    order.markPaid(payment.id());
    return order;
}

The insert runs first, so the connection is acquired before the HTTP call and held until the method returns. A version that separates the database work looks like this:

private final TransactionTemplate tx; // built from the PlatformTransactionManager

public Order placeOrder(OrderRequest request) {
    Long orderId = tx.execute(status ->
        orderRepository.save(Order.pending(request)).getId());

    PaymentResult payment = paymentClient.charge(
        request.amount(), request.cardToken(), idempotencyKey(orderId)); // no transaction open here

    return tx.execute(status -> {
        Order order = orderRepository.findById(orderId).orElseThrow();
        order.markPaid(payment.id());
        return order;
    });
}

The shape changes in three ways. Each database step commits quickly. The payment call runs with no connection held. The payment carries an idempotency key, so a retry does not charge the card twice if the vendor supports keys. The new version also creates a visible intermediate state: a row marked pending exists between the two transactions. Your workflow needs to handle an order that stays pending when the charge fails.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

When to persist intent and process asynchronously

Some workflows cannot tolerate a window in which the remote side has acted but the local record is incomplete, or the reverse. In those cases, commit a record of the intended operation in the same transaction as the business change, then have a separate worker call the vendor and update status. This outbox-style pattern gives reliable retries at the cost of more moving parts: a status model, a worker, retry scheduling, and visibility for operators. It is a product decision as much as an engineering one, so define the acceptable delay between the business change and the remote effect before choosing it.

Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Failure domains after the database commits

A local database transaction does not make an external service part of the same atomic commit. Once the payment provider has accepted a charge, the database cannot roll it back. If the second transaction then fails, the system holds a charge with no matching paid order. Treat the remote side effect and the database commit as separate failure domains.

  • Idempotency: send a stable key with every retryable remote request, so a repeated call does not repeat the side effect.
  • Reconciliation: run a job that finds pending orders and asks the vendor for the status of each charge.
  • Compensation: define what happens when the remote action succeeds but the local update cannot be saved, such as a refund or a manual review queue.
  • Timeouts: set explicit timeouts on the HTTP client. A call with no bound can hold application threads indefinitely, even when no connection is involved.

Use these mechanisms only as far as the workflow requires. A read-only lookup to a third-party API usually needs timeouts and nothing more.

Diagnosing the problem before changing capacity

Confirm the mechanism in your own deployment before changing code or pool settings. Work through these steps in order:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
  1. Find the transaction boundaries across the full call chain, including class-level @Transactional annotations and every caller that may already have opened a transaction.
  2. For each path, record the order of JDBC work relative to the outbound HTTP or API call. This tells you whether lazy fetching can help that path at all.
  3. Check the pool’s connection acquisition time, hold time, active connection count, and pending (waiting) thread count, using the metrics your Hikari integration exposes.
  4. Compare transaction duration with database latency and downstream API latency. A transaction whose duration tracks the vendor’s latency is holding connections through the remote call.
  5. Look for nested REQUIRES_NEW scopes and self-invoked transactional methods on the same path.

The metrics show whether connections are held through remote calls. They do not show a safe pool size, which depends on your workload and database capacity.

Why pool size is a later lever

Pool size controls how much concurrent work is allowed to queue for database access. It does not change how long each transaction holds its connection. The official Spring Boot documentation does not establish a universal maximum, and the HikariCP maintainers’ FAQ covers operational details such as isolation reset and datasource shutdown rather than a sizing rule for transactions that call external APIs. Increasing the pool can also push more concurrent load onto the database, so measure database capacity before raising the limit.

Correct the transaction scope first. Then, if measurements still show waiting threads with short hold times, the pool size is worth revisiting.

Checklist before you ship a change

  • No @Transactional method performs an outbound HTTP or API call after its first JDBC statement, unless the transaction is deliberately kept short and measured.
  • Each remote call that can be retried carries an idempotency key.
  • Every outbound HTTP client has an explicit connect and read timeout.
  • Nested REQUIRES_NEW scopes on busy paths have been checked for a second connection request.
  • Transactional helper methods are called through a bean or a TransactionTemplate, not through a same-class call.

“

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

@TransactionalConnection PoolHikariCPJavaJDBCSpring BootSpring Transactions

Leave a Reply

Your email address will not be published. Required fields are marked *

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Recommended PC Tool
Recommended PC Tool
PC Slower Than It Used to Be?Free scan - under a minute
Outdated Drivers Are Slowing You DownFree scan - exact matches

Two free Windows tools

One Free Minute Could Fix That PC

Before you go - each of these free tools takes about a minute and tackles what quietly slows a Windows PC down.

Special offer. View Outbyte info, uninstall instructions, EULA, and Privacy Policy.