One Missing Parameter Doubled Our Connection Pool Wait Time

It was a Friday afternoon, the worst possible time for an incident, which is of course when it happened. Traffic was at its weekly peak, and our connection pool metrics started climbing in a way that didn't match the load. Requests weren't failing outright, they were just queuing, waiting longer and longer for a connection to free up. Nothing in the code had changed that week. Nothing in the infrastructure had changed either. The only thing that had changed was traffic volume, and the pool was buckling under it far earlier than our capacity planning said it should. The investigation started where these things usually start, with dashboards and guesses. CPU was fine. Database load was fine. The pool itself was correctly sized for what we thought our read-to-write ratio was. That assumption turned out to be the problem.
A handful of endpoints, all of them read-only by contract, category listings, product lookups, dashboard summaries, were annotated with plain @Transactional, missing the readOnly = true attribute. They worked and they returned the correct data. Nothing about their behavior looked wrong in any functional test. But without that attribute, Hibernate treated every one of those calls as a potential write, It tracked every loaded entity for dirty checking, it ran a flush at the end of the transaction to look for changes to persist, and none of that work is free. On a single request it costs a few milliseconds nobody notices but multiply it across the volume of read traffic we get at peak, and connections were being held measurably longer than they needed to be, which meant fewer of them were cycling back into the pool for the next request in line.
The fix was easy enough - adding readOnly = true to those methods took an afternoon. Hibernate skips the dirty check, skips the flush, and the transaction closes as soon as the data is read. Pool wait times at the next peak dropped by roughly half, which lines up with how much of our traffic was hitting those endpoints in the first place.
What stuck with me afterward wasn't the fix, it was how invisible the cost was until the volume made it visible. readOnly = true reads like a hint, almost decorative, the kind of thing that's easy to skip when you're moving fast and the tests are green but it isn't decorative. It's telling the persistence layer to stop doing work it was never going to need, and at scale that work adds up to real contention on a resource every request in your system is competing for.
If you're auditing your own services for this, the search is quick: grep for @Transactional without readOnly, then check whether the method actually writes anything. The attribute costs a few keystrokes but the absence of it costs connections you'll be short of exactly when you can least afford it.