5 Spring Boot Transaction Optimizations That Drastically Boost Performance

This article examines five practical ways to improve Spring Boot 3.5.0 transaction performance—read‑only transactions, explicit propagation settings, correctly sized HikariCP pools, JDBC prepared‑statement caching, and Hibernate batch writes—showing the underlying overhead and providing concrete code and configuration examples.

Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
5 Spring Boot Transaction Optimizations That Drastically Boost Performance

Introduction

Spring Boot applications often encounter latency bottlenecks in the data‑access layer. The root causes are usually improper use of @Transactional, mismatched connection‑pool settings, missing statement caching, and inefficient batch writes rather than business‑logic flaws.

1. Read‑Only Transactions

When a @GetMapping method is annotated only with @Transactional (read‑write), Hibernate creates a snapshot for every loaded entity, allocates a transaction ID, and holds locks even though no data is modified. This adds unnecessary work.

// ❌ Read‑write transaction on a query
@Transactional
public List<Order> getOrders(Pageable pageable) {
    return orderRepository.findAll(pageable).getContent();
}

Optimization: add readOnly = true to @Transactional for pure queries.

// ✅ Read‑only transaction
@Transactional(readOnly = true)
public List<Order> getOrders(Pageable pageable) {
    return orderRepository.findAll(pageable).getContent();
}

Hibernate skips dirty‑checking and automatic flush.

No extra write operations are sent to the database.

PostgreSQL does not allocate a transaction ID for read‑only transactions, reducing WAL overhead.

Read‑only traffic can be routed to replicas by proxies such as pgBouncer.

Rule of thumb: annotate only query methods with @Transactional(readOnly = true); keep write methods with the default annotation.

2. Transaction Propagation

By default, nested @Transactional calls share the outer transaction. If any step fails, the whole chain rolls back, which can unintentionally erase audit logs or hold DB connections during long‑running HTTP calls.

// ❌ Audit log shares outer transaction
@Transactional
public void placeOrder(CreateOrderInput input) {
    Order order = createOrderUseCase.execute(input);
    paymentService.charge(order); // rolled back together
    auditService.logOrderPlaced(order); // rolled back together
}

Optimization: explicitly set propagation behavior based on fault‑tolerance needs.

@Service
@Transactional
public class OrderFacade {
    public void placeOrder(CreateOrderInput input) {
        Order order = createOrderUseCase.execute(input);
        // NOT_SUPPORTED: release DB connection before HTTP call
        notificationService.notify(order);
        // REQUIRES_NEW: audit log in independent transaction
        auditService.logOrderPlaced(order);
    }
}

@Service
public class AuditService {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void logOrderPlaced(Order order) {
        auditRepository.save(new AuditRecord("ORDER_PLACED", order.getId()));
    }
}

@Service
public class NotificationService {
    @Transactional(propagation = Propagation.NOT_SUPPORTED)
    public void notify(Order order) {
        httpClient.post(webhookUrl, order);
    }
}

REQUIRES_NEW keeps audit records even if the main order fails.

NOT_SUPPORTED frees the DB connection during external HTTP calls.

Controllers without @Transactional close the connection before JSON serialization.

3. Proper Connection‑Pool Sizing

HikariCP defaults to a pool of 10 connections, while a typical Spring Boot app may have 200 Tomcat threads. Under moderate load, most threads wait for a connection, leaving the DB idle.

# ❌ Default pool: 200 business threads, only 10 DB connections
# hikaricp.connections.pending spikes, latency explodes

Optimization: size the pool according to the database’s actual concurrent capacity, not the number of application threads, and monitor key metrics.

# ✅ Pool size formula: pool = dbConcurrentThreads × (diskFactor‑1) + 1
# Example: DB supports 20 concurrent connections → 20 × (2‑1) + 1 = 21
spring:
  datasource:
    hikari:
      maximum-pool-size: 20   # match DB capability
      minimum-idle: 5          # keep a few idle connections
      idle-timeout: 300000     # 5 min
      max-lifetime: 1800000    # 30 min
      connection-timeout: 3000 # fail fast after 3 s

Key monitoring items: hikaricp.connections.pending > 0 → pool too small. hikaricp.connections.active ≈ max → pool saturated.

management:
  metrics:
    enable:
      hikaricp: true

4. Prepared‑Statement Caching

Without caching, each repository call sends a full SQL string to the DB, forcing the server to parse, optimize, and generate an execution plan every time.

# ❌ No statement cache: every findById/save triggers full parse

Optimization: enable driver‑level prepared‑statement caching.

# ✅ Enable cache at the driver level
spring:
  datasource:
    hikari:
      data-source-properties:
        cachePrepStmts: true
        prepStmtCacheSize: 250
        prepStmtCacheSqlLimit: 2048
        useServerPrepStmts: true

Execution plans are generated once and reused.

Server‑side caching (useServerPrepStmts) shares plans across connections.

250 cached statements cover most Spring Data queries with negligible memory impact.

5. JDBC Batch Writes

Calling repository.save() inside a loop creates one INSERT per row, each incurring a network round‑trip. With 1 ms latency, 1 000 rows cost ~1 s just for network latency.

// ❌ One network round‑trip per row
@Transactional
public void importOrders(List<Order> orders) {
    for (Order order : orders) {
        orderRepository.save(order);
    }
}

Optimization: enable Hibernate batch inserts, set a reasonable batch size, and use a sequence‑based primary‑key strategy (IDENTITY breaks batching).

# Enable batch size in application.yml
spring:
  jpa:
    properties:
      hibernate:
        jdbc.batch_size: 50
        order_inserts: true
        order_updates: true

# Use SEQUENCE for primary keys
@Entity
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "order_seq")
    @SequenceGenerator(name = "order_seq", allocationSize = 50)
    private Long id;
}

After the changes, MySQL logs show a single batch INSERT instead of thousands of individual statements.

MySQL batch log
MySQL batch log
Console SQL
Console SQL

These five optimizations can be applied with minimal code changes and configuration tweaks, yet they often yield order‑of‑magnitude latency reductions in high‑concurrency Spring Boot services.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

performancetransactionSpring BoothikariCPjpajdbc-batch
Spring Full-Stack Practical Cases
Written by

Spring Full-Stack Practical Cases

Full-stack Java development with Vue 2/3 front-end suite; hands-on examples and source code analysis for Spring, Spring Boot 2/3, and Spring Cloud.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.