Read-Only Transactions and Their Real Cost
Read-Only Transactions and Their Real Cost
@Transactional(readOnly = true) appears in almost every service method that reads data. Most developers add it reflexively. Few know what it does, and fewer know when it matters.
The Lie
readOnly = true tells the database not to write. It is a safety guard and a performance optimization.
The Reality
readOnly = true does different things at different layers. Some of those things improve performance. Some are purely cosmetic.
Layer 1: Spring Transaction Manager
Spring sets the readOnly flag on the transaction definition. If you have routing logic that sends read-only transactions to a replica, this is where the routing decision happens. Without that routing logic, this flag does nothing at the Spring level.
Layer 2: JDBC Connection
Spring calls connection.setReadOnly(true). What this does depends on the JDBC driver:
- PostgreSQL: Sets the transaction to
READ ONLYmode. Any INSERT, UPDATE, DELETE statement will throw an exception. This is a real safety guard. - MySQL/MariaDB (Connector/J): Routes queries to a read replica if connection load balancing is configured. Without replica configuration, it is a no-op.
- HikariCP: Does not pool read-only and read-write connections separately. The
readOnlyflag is set on each connection borrow and reset on return.
Layer 3: Hibernate Session
This is where the real performance impact lives. When the transaction is marked read-only, Hibernate’s DefaultFlushMode is set to MANUAL. This means:
- No automatic flush before queries (saves a persistence context iteration)
- No dirty checking at commit time (saves a full entity graph comparison)
- Hibernate may use read-only memory mode for loaded entities (skips snapshot storage)
The snapshot storage optimization is the significant one. For each managed entity, Hibernate normally stores two copies: the current state and a snapshot of the state at load time. At flush, it compares them to detect changes. In read-only mode, Hibernate skips storing the snapshot. For an entity with 20 fields, this roughly halves the memory per entity in the persistence context.
The Evidence
// Measuring the difference
@Transactional(readOnly = true)
public List<Product> loadProductsReadOnly() {
return productRepository.findAll(); // 10,000 products
}
@Transactional
public List<Product> loadProductsReadWrite() {
return productRepository.findAll(); // 10,000 products
}
// Memory comparison for 10,000 Product entities (20 fields each):
//
// Read-write transaction:
// - Entity instances: ~3.2 MB
// - Hydrated state (snapshots): ~3.2 MB
// - Persistence context overhead: ~0.8 MB
// - Total: ~7.2 MB
//
// Read-only transaction:
// - Entity instances: ~3.2 MB
// - Hydrated state: skipped
// - Persistence context overhead: ~0.4 MB
// - Total: ~3.6 MB
//
// Memory savings: ~50% per loaded entity set
// Dirty checking cost at flush time
// Read-write transaction with 10,000 entities:
//
// Flush cycle:
// for each entity in persistenceContext:
// currentState = getPropertyValues(entity)
// loadedState = getSnapshot(entity)
// if (currentState != loadedState):
// scheduleUpdate(entity)
//
// 10,000 entities × 20 fields = 200,000 field comparisons
// At commit time, even if nothing changed.
//
// Read-only transaction: flush is MANUAL and skipped entirely.
// Zero field comparisons at commit.
The SQL generated is identical in both cases. The database does the same work. The difference is entirely in Hibernate’s in-memory overhead.
The Fix
Use readOnly = true on every method that only reads data. Not because it is a “best practice,” but because it provides two concrete benefits:
- Memory reduction: ~50% less heap per loaded entity set. At 10,000 entities, that is 3.6 MB saved. At 100,000 entities, 36 MB.
- CPU reduction at commit: No dirty checking. For 10,000 entities with 20 fields, that is 200,000 field comparisons eliminated.
// BETTER: Read-only for all read operations
@Service
public class ReportService {
@Transactional(readOnly = true)
public SalesReport generateMonthlyReport(YearMonth month) {
List<Order> orders = orderRepository
.findByCreatedAtBetween(month.atDay(1).atStartOfDay(),
month.atEndOfMonth().atTime(23, 59, 59));
// Process 50,000 orders without dirty-check overhead
// and without snapshot memory
return SalesReport.from(orders);
}
}
Do not use readOnly = true on methods that modify data. It will not throw an exception in all cases (Hibernate may still flush if you call flush() explicitly), but the behavior is undefined and database-dependent.
The Cost Model
| Entity Count | Memory Savings (readOnly) | Dirty Check Savings (readOnly) | Real-World Impact |
|---|---|---|---|
| 100 | ~36 KB | < 1ms | Negligible |
| 1,000 | ~360 KB | ~1-2ms | Negligible |
| 10,000 | ~3.6 MB | ~10-20ms | Measurable under load |
| 100,000 | ~36 MB | ~100-200ms | Significant, may prevent GC pauses |
Below 1,000 entities per request, readOnly = true is a correctness annotation. It communicates intent and provides PostgreSQL-level write protection. The performance benefit is real but invisible.
Above 10,000 entities per request, readOnly = true is a performance requirement. The memory savings prevent GC pressure. The dirty-check avoidance saves measurable CPU time. Omitting it on large read queries is leaving performance on the table.
The rule is straightforward: if the method does not write, mark it readOnly = true. The cost is zero. The benefit scales with entity count.