iTechGuides is reader-supported. When you buy through links on our site, we may earn an affiliate commission. As an Amazon Associate I earn from qualifying purchases. Learn more
A third-party API call made inside a @Transactional method can keep a database connection checked out for as long as that call takes, but only after the transaction has already acquired a connection. The annotation alone does not take one from the pool. The usual sequence that causes trouble is database work first, which binds a connection to the transaction, followed by a blocking HTTP or vendor call before the transaction commits. When many requests are in that state at once, the pool runs out and unrelated queries start waiting for a connection, often in endpoints that never call the vendor at all.
The fix is usually structural. Keep the database transaction as short as the business rules allow, and move remote I/O outside it. Changing the pool size or tuning HikariCP does not shorten a transaction scope that holds its connection longer than it needs to.
What holds the connection during the call
How the pool and the transaction interact
The connection pool lends a JDBC connection to application work and takes it back when that work releases it. In a typical Spring setup, the transaction manager binds the connection to the current thread for the life of the transaction, so the connection stays with that transaction until it commits or rolls back. A wait after the last SQL statement therefore still counts against the connection. This is an inference from how Spring binds transaction resources, documented in the Spring Framework 5.3.30 Data Access reference, rather than a guarantee for every combination of transaction manager, persistence provider and driver. Your own measurements should decide how your stack behaves.
Recommended Free Tools
Where lazy connection fetching helps, and where it stops
Spring Boot can defer connection acquisition. Setting the property below makes the datasource fetch a JDBC connection only when it is actually needed:
#1 Best Overall
spring.datasource.connection-fetch=lazy
The Spring Boot SQL Databases reference describes the lazy connection proxy this way: “With this feature enabled, JDBC Connections are only fetched from the pool when actually necessary.” Transaction control can start before a connection is fetched, which means a method that makes its remote call before its first JDBC statement does not take a connection during that call.
Lazy fetching does not release a connection that has already been obtained. Once an earlier repository call has fetched one, it stays with the transaction for the rest of the method, including any remote call that follows.
A pattern that produces the problem
The following method looks harmless, and it is the shape most often seen in order, payment, or booking code:
Quick wins for a faster PC:
Repair Windows errors before they cause bigger problemsFix Now →Fix the driver behind crashes, sound loss and screen glitchesFind Drivers →@Service
public class OrderService {
private final OrderRepository orders;
private final PaymentClient payments;
public OrderService(OrderRepository orders, PaymentClient payments) {
this.orders = orders;
this.payments = payments;
}
@Transactional
public Order placeOrder(OrderRequest request) {
Order order = orders.save(Order.pending(request)); // JDBC work: a connection is bound to this transaction
PaymentResult result = payments.charge(request); // blocking HTTP call; the transaction is still open
order.markPaid(result.paymentId());
return order;
}
}
Under load, the sequence for each request runs like this:
Rank #2
- The first repository call acquires a connection from the pool and binds it to the transaction.
- The payment call blocks for as long as the vendor takes to respond, including retries and timeouts inside the client.
- The connection remains checked out during that wait, even though no SQL is running.
- Other requests that need the database queue for a connection. Those requests may be reading a product catalogue or checking a session, and they never touch the payment vendor.
This is why the failure is hard to spot. The slow component is the vendor, but the symptom appears in the database access layer.
Why it appears only under concurrency
Pool exhaustion is a concurrency symptom. The number of connections held at any moment grows with the request rate and with how long each request holds one. A single slow call in a quiet period causes little trouble. The same call during peak traffic can hold every connection in the pool while requests pile up behind it.
The threshold depends on your workload, your pool configuration and your database capacity. No universal pool size or saturation formula is established by the Spring or HikariCP documentation, so treat any specific number as something to measure rather than assume.
Confirm the cause before changing anything
Use this sequence to decide whether a remote call inside a transaction is the source:
Rank #3
- Expose pool metrics. With Spring Boot Actuator and Micrometer on the classpath, Hikari metrics appear under names such as
hikaricp.connections.active,hikaricp.connections.pending,hikaricp.connections.acquireandhikaricp.connections.usage. - Time the transactional service method itself, separately from the remote client, so you can compare transaction duration with vendor latency.
- Look for the pattern where
pendingrises whileactivestays at the configured maximum, and where transaction duration moves with vendor response time rather than with query time. - Check database-side latency and capacity. If queries are slow as well, the pool is only the first place the pressure becomes visible.
If the pending count and transaction duration both track vendor latency, the remote call is inside the connection hold, and the restructuring options below apply.
Two proxy and propagation traps
Two common mistakes change which transaction your code actually runs in. Both matter when you try to split a long transaction, because the split often fails for these reasons.
Self-invocation bypasses the transaction
In default proxy mode, transaction advice applies only to calls that pass through the Spring proxy. A method that calls another @Transactional method on the same class does not go through the proxy, so the annotation on the inner method does not create the boundary it appears to declare. The Spring Framework 5.2 Data Access reference documents this proxy behaviour.
What’s actually slowing this PC down?
Pick the symptom - the matching free tool is one click away.
@Service
public class OrderService {
public Order placeOrder(OrderRequest request) {
return recordPaidOrder(request); // direct call on "this": no proxy, so no new transaction boundary
}
@Transactional
public Order recordPaidOrder(OrderRequest request) {
// database work
}
}
If you split the work, the transactional method must live in a separate bean that is injected into the caller.
Rank #4
REQUIRES_NEW can take a second connection
Developers sometimes wrap a remote call or a logging write in Propagation.REQUIRES_NEW to isolate it. That creates a second transaction, which needs its own connection while the outer transaction still holds one. 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 transactions and their connection acquisition, not remote calls as such. Still, if your pool is already constrained, introducing REQUIRES_NEW around a slow call adds the same kind of risk.
Restructuring the workflow
Choose among these options by asking what the business requires when the remote call succeeds and the local write fails, or the reverse. The table compares them on the axes that matter for connection use and failure handling.
| Approach | Connection held during the remote call | Local state during the call | Failure after the remote call succeeds | Retry and idempotency needs | Implementation effort |
|---|---|---|---|---|---|
| Remote call inside one transaction (the pattern above) | Yes, once database work has bound a connection | Uncommitted writes, not visible to other sessions | Rolls back the local write, but the remote side effect cannot be undone by the database | Still needed for the remote call | Lowest |
| Remote call first, then a short transaction | No, provided no database work runs before the call | Nothing is written until the call returns | Remote effect may exist with no local record, so reconciliation is required | Idempotency key on the remote call, plus a reconciliation path | Moderate |
| Lazy acquisition with database work moved after the call | No connection until the first JDBC statement | Transaction still open, but no writes yet | Same as the remote-first approach for any writes that follow | Same as above | Low to moderate |
| Persist intent, then process asynchronously (outbox pattern) | Only during the short write and the worker’s own updates | Pending state is visible to users and support tools | The worker retries; status must track each attempt | Required for the worker and the remote API | Highest |
Option 1: Call the vendor first, then write in a short transaction
Move the remote call out of the transaction and put the database write in a separate bean:
@Service
public class CheckoutService {
private final OrderWriter orderWriter;
private final PaymentClient payments;
public CheckoutService(OrderWriter orderWriter, PaymentClient payments) {
this.orderWriter = orderWriter;
this.payments = payments;
}
public Order placeOrder(OrderRequest request) {
PaymentResult result = payments.charge(request, request.idempotencyKey()); // no transaction open here
return orderWriter.recordPaidOrder(request, result); // short transaction in another bean
}
}
@Service
public class OrderWriter {
private final OrderRepository orders;
public OrderWriter(OrderRepository orders) {
this.orders = orders;
}
@Transactional
public Order recordPaidOrder(OrderRequest request, PaymentResult result) {
return orders.save(Order.paid(request, result.paymentId()));
}
}
The trade-off is the failure window between the two steps. If the charge succeeds and the local save fails, the customer has been charged with no local order. Send the same idempotency key on every retry, and run a reconciliation job that looks up charges without matching orders.
Option 2: Lazy acquisition with database work after the call
If the method can reach its remote call before its first repository call, lazy fetching keeps the connection out of the pool during that call. Keep spring.datasource.connection-fetch=lazy in place, and order the method so that the vendor call comes first and the first JDBC statement comes last. The transaction still spans the call, so rollback decisions wait for it, but no connection is held while the vendor responds. Confirm the ordering in your own code, because a logging or auditing statement placed earlier in the method will acquire the connection.
Option 3: Persist intent and process asynchronously
When the remote call must be retried for hours or days, or when the user should not wait for it, write the order and an outbox row in one short transaction with a pending status. A separate worker reads pending rows, calls the vendor with an idempotency key, and records the outcome in its own short transaction. The connection is held only for those brief writes. The cost is more moving parts, a visible pending state, and eventual consistency that the user interface and support tooling must handle.
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Pool settings come second
Spring Boot prefers HikariCP when it is present, and the JDBC and JPA starters bring it in. Hikari-specific options use the spring.datasource.hikari.* namespace, for example spring.datasource.hikari.maximum-pool-size and spring.datasource.hikari.connection-timeout. Raising the maximum lets more requests hold connections at once, which increases the load on the database and can move the bottleneck rather than remove it. The HikariCP FAQ covers operational details such as isolation reset and datasource shutdown. It does not provide a sizing rule for transactions that call external services, so size the pool from measured hold times and database capacity.
Code review questions for this problem
- Does the method, or its class, carry
@Transactionalat class level, so that methods which do not need a transaction still hold one? - Which statement acquires the first connection, and does it run before the outbound HTTP or vendor call?
- Does any caller invoke a transactional method on the same bean, expecting a separate boundary that the proxy never creates?
- Is
REQUIRES_NEWwrapped around a remote call while the outer transaction still holds a connection? - Do the remote client’s timeouts bound how long a request can hold a connection?
- If the remote call succeeds and the local write fails, what reconciles the two systems, and is the call idempotent?
Read together, these questions point to the same fix: give each transaction only the database work it needs, and treat the remote call as a separate failure domain with its own retry and recovery path.
Quick Recap
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.

