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.
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 explodesOptimization: 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 sKey monitoring items: hikaricp.connections.pending > 0 → pool too small. hikaricp.connections.active ≈ max → pool saturated.
management:
metrics:
enable:
hikaricp: true4. 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 parseOptimization: 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: trueExecution 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.
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.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
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.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
