Ch. 8 · Spring Boot

Spring Transaction Duration and Remote Calls

Why long @Transactional methods exhaust the Hikari pool and hold locks, and how to keep remote calls, files and email outside the transaction.

~7 min readintermediateupdated Oct 6, 2026

“What is wrong with calling a REST API inside a @Transactional method?” Interviewers ask this because it is one of the most common causes of Spring Boot outages that look like database problems. The database is healthy, CPU is low, yet requests time out with Connection is not available, request timed out after 30000ms. The cause is usually transactions that stay open while waiting for something that is not the database. Good answers explain the resource being held, show how to restructure the code, and handle the consistency question that restructuring creates.

Before you start

You should know how @Transactional works through a proxy (see transaction proxies), what a connection pool is (Spring Boot uses HikariCP with 10 connections by default), and roughly how row locks work in a relational database. Examples target Spring Boot 3.x with Spring Data JPA.

The short answer

When a @Transactional method starts, Spring takes a connection from the pool and keeps it until commit or rollback. Any slow work inside, such as HTTP calls, file uploads, PDF generation, email or sleeping retries, keeps that connection and any acquired row locks busy without using them. With a 10-connection pool, ten slow requests block every other database user. Keep transactions to the database work only: gather inputs and call remote services before the transaction or after it, and write the results in a short transaction. For side effects that must happen only after a successful commit, use @TransactionalEventListener(phase = AFTER_COMMIT) or the transactional outbox pattern.

How it works

@Service
class OrderService {

    @Transactional
    public Order place(OrderRequest req) {
        Order order = orders.save(Order.pending(req));          // connection acquired here
        PaymentResult payment = paymentClient.charge(req);      // 800 ms average, 30 s worst case
        order.markPaid(payment.id());
        emailClient.sendConfirmation(order);                    // 300 ms
        return order;                                           // commit, connection released
    }
}
java

The connection is held for about 1.1 seconds on a good day while the database does a few milliseconds of work. Hikari’s pool is 10 connections, so throughput is capped near 9 orders per second, regardless of how fast the database is. If the payment provider slows to 10 seconds, the pool drains in seconds and every endpoint that touches the database starts waiting connectionTimeout (30 seconds by default) and failing.

Locks make it worse. If place also updated the customer’s row (customer.incrementOrderCount()) early in the method, that row lock is held during both remote calls, blocking every other transaction that touches that customer.

There is also a correctness problem: if the email call throws after the payment succeeded, the transaction rolls back and the order disappears, but the customer was charged.

Step-by-step walkthrough

Step 1: Measure how long transactions really are

Hikari exposes hikaricp.connections.usage (how long connections are held) and hikaricp.connections.pending through Micrometer. A usage p99 far above your query times means non-database work inside transactions. spring.datasource.hikari.leak-detection-threshold=5000 logs a stack trace for any connection held longer than 5 seconds, pointing at the culprit.

Step 2: Move remote calls out of the transaction

Split the operation into short transactional steps around the remote call. The transactional methods must live in a different bean (or use TransactionTemplate) so the proxy applies.

@Service
class PlaceOrderUseCase {
    private final OrderTx tx;
    private final PaymentClient payments;

    PlaceOrderUseCase(OrderTx tx, PaymentClient payments) {
        this.tx = tx;
        this.payments = payments;
    }

    public Order place(OrderRequest req) {
        long orderId = tx.createPending(req);                 // short transaction 1
        PaymentResult result = payments.charge(req, orderId); // no connection held
        return tx.markPaid(orderId, result.id());             // short transaction 2
    }
}

@Service
class OrderTx {
    @Transactional
    public long createPending(OrderRequest req) { return orders.save(Order.pending(req)).getId(); }

    @Transactional
    public Order markPaid(long id, String paymentId) {
        Order order = orders.findById(id).orElseThrow();
        order.markPaid(paymentId);
        return order;
    }
}
java

Each transaction lasts milliseconds. The order is visible as PENDING between steps, which is a real state the business can reason about.

Step 3: Handle partial failure explicitly

Splitting introduces a gap: the charge can succeed and the second transaction can fail. Make the remote call idempotent (pass orderId as the idempotency key), and run a reconciliation job that finds PENDING orders older than a few minutes and asks the payment provider for their status. That is more honest than a single transaction, which never actually covered the remote side effect anyway.

Step 4: Send side effects after commit

Emails and messages should go out only if the data committed:

@Transactional
public Order markPaid(long id, String paymentId) {
    Order order = orders.findById(id).orElseThrow();
    order.markPaid(paymentId);
    events.publishEvent(new OrderPaid(order.getId()));   // ApplicationEventPublisher
    return order;
}

@Component
class OrderEmailListener {
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    void onPaid(OrderPaid event) {
        emailClient.sendConfirmation(event.orderId());
    }
}
java

If the process crashes between commit and the listener, the email is lost. When that matters, write an outbox row in the same transaction and let a separate publisher deliver it.

Worked scenario

A document service uploads files to object storage inside a transaction that also records metadata:

@Transactional
public Document upload(MultipartFile file, long ownerId) throws IOException {
    Document doc = documents.save(new Document(ownerId, file.getOriginalFilename()));
    storage.put(doc.storageKey(), file.getInputStream());   // 20 s for large files
    return doc;
}
java

During an end-of-month spike, users upload large PDFs. Hikari logs Connection is not available, request timed out after 30000ms for unrelated endpoints such as login and search. The database dashboard shows 10 active connections, all idle in transaction.

The fix uploads first, then records metadata in a short transaction:

public Document upload(MultipartFile file, long ownerId) throws IOException {
    String key = "docs/" + UUID.randomUUID();
    storage.put(key, file.getInputStream());               // no transaction
    try {
        return documentTx.record(ownerId, file.getOriginalFilename(), key);   // @Transactional, ms
    } catch (RuntimeException ex) {
        storage.delete(key);                               // compensate
        throw ex;
    }
}
java

A nightly job deletes objects with no metadata row, covering crashes between the steps. Connection usage p99 drops from 18 seconds to 12 ms, and the pool never saturates during the next spike.

Common mistake

  • “Make the pool bigger.” It delays the failure and moves the bottleneck to the database, which has its own connection limit.
  • Expecting a rollback to undo a remote call. The database rolls back; the payment, email or uploaded file does not.
  • Annotating a whole controller or a long batch loop with @Transactional.
  • Leaving open-in-view on, which holds a connection for the whole request once one has been used. See JPA lazy loading.
  • Splitting into methods on the same class and calling them with this., which bypasses the proxy so the “short transactions” are not transactions at all.
  • Retrying with sleeps inside a transaction.

Verify the behavior

Prove the remote call runs with no transaction active:

@SpringBootTest
class PlaceOrderTransactionTest {

    @Autowired PlaceOrderUseCase useCase;
    @MockitoBean PaymentClient payments;

    @Test
    void paymentIsChargedOutsideTransaction() {
        when(payments.charge(any(), anyLong())).thenAnswer(inv -> {
            assertThat(TransactionSynchronizationManager.isActualTransactionActive()).isFalse();
            return new PaymentResult("pay_1");
        });

        useCase.place(sampleRequest());

        verify(payments).charge(any(), anyLong());
    }
}
java

Under load, compare hikaricp.connections.usage before and after the change, or run a load test with a stub payment service delayed by 2 seconds and confirm that unrelated endpoints keep responding.

Follow-up questions

How long should a transaction be? As short as the database work requires, typically milliseconds. No user think time, network calls or file I/O.

What is the transactional outbox pattern? Write the event to an outbox table in the same transaction as the business change, and have a separate process publish it. Commit and event become atomic without distributed transactions.

Does @Transactional(readOnly = true) avoid holding a connection? No. It still holds a connection; it only hints optimizations such as skipping dirty checks.

What about timeout on @Transactional? It sets a deadline checked by JDBC statements and the transaction manager. It limits damage but does not interrupt an HTTP call in progress.

Interview exercise

An endpoint reads a customer, calls a credit-scoring API (p99 2 seconds), stores the score, and publishes a Kafka message. It is one @Transactional method, and under load the service runs out of database connections. Restructure it and explain the consistency trade-offs.

Answer and reasoning

Read the customer data you need in a short read-only transaction, or without a transaction if a single query suffices. Call the credit API with no transaction or connection held, using a timeout derived from the endpoint budget. Store the score in a short write transaction that also inserts an outbox row describing the Kafka message. A relay publishes outbox rows, so the message is sent if and only if the score is committed. The trade-offs: the customer could change between read and write, so either re-check a version or accept that the score reflects the earlier read, and a crash after the API call loses that call’s result, which is acceptable because it can be repeated. The reasoning: connections and locks are scarce, remote side effects cannot be rolled back, so keep transactions database-only and make cross-system consistency explicit.

Continue learning

More in Spring Boot

read ✓Spring Boot · mid

Spring JPA N-Plus-One Query Diagnosis

Detect and fix JPA N+1 selects in Spring Boot with fetch joins, @EntityGraph, batch fetching and DTO queries, then prove it with a test.

~6 min readread →
read ✓Spring Boot · mid

Spring Transactions and Proxy Invocation

Why @Transactional is ignored on self-invocation and private methods in Spring Boot, how the proxy works, and four ways to fix it.

~7 min readread →
esc