<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Olga Ermolaeva</title>
    <description>The latest articles on DEV Community by Olga Ermolaeva (@olga_ermolaeva_f7bd121ab9).</description>
    <link>https://dev.to/olga_ermolaeva_f7bd121ab9</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3916044%2Ff9646b04-5e20-4f8d-a273-88ef57c2e0fe.jpg</url>
      <title>DEV Community: Olga Ermolaeva</title>
      <link>https://dev.to/olga_ermolaeva_f7bd121ab9</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/olga_ermolaeva_f7bd121ab9"/>
    <language>en</language>
    <item>
      <title>Part 3. How to Fix External I/O Inside Transactions Without Overengineering Everything</title>
      <dc:creator>Olga Ermolaeva</dc:creator>
      <pubDate>Tue, 28 Jul 2026 11:48:15 +0000</pubDate>
      <link>https://dev.to/olga_ermolaeva_f7bd121ab9/part-3-how-to-fix-external-io-inside-transactions-without-overengineering-everything-3eo6</link>
      <guid>https://dev.to/olga_ermolaeva_f7bd121ab9/part-3-how-to-fix-external-io-inside-transactions-without-overengineering-everything-3eo6</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;There's no universal fix — each call needs the pattern that matches its delivery guarantees. Post-commit handoff where event loss is acceptable (SSE). No transaction at all where the database doesn't need to know (FCM). Short DB phases bracketing the network call where state has to be tracked (SMS). Presigned POSTs to keep large uploads off the application entirely (S3). An outbox where delivery has to survive a crash (anything that matters).&lt;/p&gt;

&lt;h2&gt;
  
  
  A Diagnostic Checklist
&lt;/h2&gt;

&lt;p&gt;When the pool is exhausted but the database itself looks healthy, work through these in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Is HikariCP at max active while DB query throughput is down?&lt;/strong&gt; If yes, your connections aren't doing database work — they're held by something else. Skip ahead.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Are app threads blocked on HTTP, JMS, S3, Firebase, or SMS calls&lt;/strong&gt;? Take a thread dump (jstack, async-profiler, your APM's "blocked threads" view). Group by stack frame. Anything not in java.sql / org.postgresql / com.zaxxer.hikari is a candidate.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Do any @Transactional methods enclose external I/O&lt;/strong&gt;? Search for @Transactional annotations and trace what they call. The bad shape is: @Transactional → service method → external client (HTTP, JMS, AWS SDK, Firebase). The boundary is wrong — move the I/O out, not the annotation.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Are @EventListener methods synchronous and called from inside a transaction&lt;/strong&gt;? Default Spring listeners run on the publishing thread. If the listener does I/O, that I/O is inside the publisher's transaction. Switch to &lt;code&gt;@TransactionalEventListener(phase = AFTER_COMMIT)&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Is the upstream that triggered this incident correlated in time with the pool exhaustion&lt;/strong&gt;? If yes, that's your culprit. If no, look for any external system whose latency spiked.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The order matters: 1–2 confirm the shape of the problem, 3–4 find the cause, 5 names the trigger. Without all four pieces it's easy to "fix" the wrong layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Underlying Pattern
&lt;/h2&gt;

&lt;p&gt;This is a common production trap in Spring systems: a &lt;code&gt;@Transactional&lt;/code&gt; method makes a synchronous call to an external system — a message broker, an HTTP API, an SDK that wraps one — and a slow upstream drains the database pool while the database itself sits idle. We found it the hard way, traced it across metrics and code, and this part is the field guide that came out of it: the patterns to apply, sized to the delivery guarantees each call actually needs.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/olga_ermolaeva_f7bd121ab9/part-1-our-database-looked-overloaded-the-real-problem-was-transactional-1eg4"&gt;Part 1&lt;/a&gt; walked through the metrics that pointed away from the database. &lt;a href="https://dev.to/olga_ermolaeva_f7bd121ab9/part-2-the-spring-transactional-pattern-that-drained-our-hikari-pool-1jdo"&gt;Part 2&lt;/a&gt; named the shape: external I/O inside transaction boundaries — synchronous calls to ActiveMQ, Firebase, S3, and an SMS gateway, each one holding a Hikari connection until it returned. This part is how to handle each variant.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Constraint That Shapes the Solutions
&lt;/h2&gt;

&lt;p&gt;There's one piece of infrastructure context that decides which solution fits where: the ActiveMQ deployment is a single broker instance, not a cluster. This isn't something the application layer can change — it's a given. The right move is to treat it as such.&lt;/p&gt;

&lt;p&gt;(Yes, clustering has been considered. The cost is high, and today ActiveMQ isn't relied on as a system for durability — the loads that go through it are all loss-tolerant. If that shifts and a real event queue is needed, clustering becomes the answer then. Until that day, the transactional outbox pattern (covered below) gives stronger durability guarantees on the paths that actually need them, with less infrastructure to operate.)&lt;/p&gt;

&lt;p&gt;That constraint has a clean implication. ActiveMQ is fine as a fan-out bus for messages whose loss is recoverable: a single-instance broker can be restarted, and any message in flight at the moment of the restart is gone. It is not fine as the durability layer for messages that absolutely must reach their destination. Those need a different mechanism.&lt;/p&gt;

&lt;p&gt;That's why this isn't one fix — it's several, sized to the criticality of each path.&lt;/p&gt;

&lt;h2&gt;
  
  
  SSE: The Smallest Possible Annotation Change
&lt;/h2&gt;

&lt;p&gt;Server-Sent Events are how the frontend gets near-real-time updates. The flow is: a write commits in the API, an SSE event is published to ActiveMQ, every API instance receives it on the topic, and each instance forwards it to its locally-connected SSE clients. If the broker drops a message, the client doesn't get the update via that path — but the SSE client always reconnects with a Last-Event-Id header, and the API replays missed events from an in-memory ring buffer (EventHistory). Loss is tolerable because the client recovers.&lt;/p&gt;

&lt;p&gt;Spring's docs note that with @TransactionalEventListener, transactional resources may still be active and accessible after commit. "After commit" doesn't guarantee that the connection has already been returned to the pool. So a synchronous broker call inside an AFTER_COMMIT listener can still hold the JDBC connection while it waits.&lt;/p&gt;

&lt;p&gt;That means two changes are needed:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Move the broker call outside the transaction boundary&lt;/strong&gt; — switch from @EventListener to @TransactionalEventListener(phase = AFTER_COMMIT), so the publish happens after the commit completes rather than mid-transaction.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Make the broker call itself non-blocking on the listener thread&lt;/strong&gt; — hand the publish off to a separate executor, so even if the listener thread is still associated with transactional resources, those resources can be released while the broker call proceeds elsewhere.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Together, these guarantee that a slow or unreachable broker can no longer pin a Hikari connection.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before — runs on the publishing thread, inside the transaction.&lt;/span&gt;
&lt;span class="nd"&gt;@EventListener&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;onOrderUpdated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;OrderUpdatedEvent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// After — runs on a different thread, and only after the transaction commits.&lt;/span&gt;
&lt;span class="nd"&gt;@TransactionalEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;phase&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;AFTER_COMMIT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;onOrderUpdated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;OrderUpdatedEvent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;ssePublishExecutor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;jmsPublisher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toMessage&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works for SSE because SSE losses are tolerable. It's not enough for anything that must not be lost.&lt;/p&gt;

&lt;h2&gt;
  
  
  FCM: Remove the Wrapping Transaction, Keep the Reads Short
&lt;/h2&gt;

&lt;p&gt;The push notifications listener was already on &lt;code&gt;@TransactionalEventListener(AFTER_COMMIT)&lt;/code&gt;. The remaining problem was an inner transaction wrapping the listener body:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@TransactionalEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AFTER_COMMIT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@Transactional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;propagation&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Propagation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;REQUIRES_NEW&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// ← the problem&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;onOrderAssigned&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;OrderAssignedEvent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;recipients&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;userService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findRecipientsFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;pushClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendPush&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;recipients&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="c1"&gt;// asynchronous HTTPS&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;REQUIRES_NEW&lt;/code&gt; opens a fresh transaction at the start of the listener and holds it until the FCM HTTP call returns. Remove it. The listener body now runs with no transaction. Each method it calls handles its own transactional needs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Transactional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;readOnly&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;findRecipientsFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;UUID&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The flow now looks like: listener fires after commit → short read transaction → connection returned → short read transaction → connection returned → pushClient.sendPush returns immediately and the HTTPS work runs on FCM's own async executor. A Firebase regional incident now blocks neither a Hikari connection nor a Tomcat thread.&lt;/p&gt;

&lt;p&gt;This is a deliberately conservative change. FCM isn't behind an outbox or on a queue — push notifications are best-effort by nature, and the existing code already makes that bargain. The point is just to stop letting Firebase's bad afternoons drain the database pool.&lt;/p&gt;

&lt;h2&gt;
  
  
  SMS: Split the Dispatcher Into Phases
&lt;/h2&gt;

&lt;p&gt;SMS sending was already half-fixed by design. When a feature wants to send an SMS, it doesn't call the gateway directly — it inserts a row into a scheduled_messages table in the caller's transaction and returns. The actual HTTPS call is made later by a separate scheduled dispatcher. The user-facing request never hits the upstream provider.&lt;/p&gt;

&lt;p&gt;That part was already correct. The problem was inside the dispatcher itself. The original loop opened one transaction per batch and held it across every gateway call. The fix splits the work into three short transactions and one transaction-free HTTP call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Scheduled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fixedDelayString&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;dispatchPendingMessages&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Phase 1: short transaction, read pending IDs only.&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;pendingIds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;UUID&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;transactionTemplate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findPendingMessageIds&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// Phase 2: process each ID outside any DB transaction.&lt;/span&gt;
    &lt;span class="n"&gt;pendingIds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
        &lt;span class="n"&gt;processor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;processMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;processMessage&lt;/code&gt; itself is the second important piece. It opens a &lt;code&gt;REQUIRES_NEW&lt;/code&gt; transaction only to flip status from &lt;code&gt;SCHEDULED&lt;/code&gt; to &lt;code&gt;SENDING&lt;/code&gt; and to read a snapshot of the fields the gateway needs. It returns a plain value object — not a JPA entity. The gateway HTTPS call then runs with no transaction at all. A second &lt;code&gt;REQUIRES_NEW&lt;/code&gt; transaction writes the final status once the gateway returns.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;processMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;messageId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;UUID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;snapshot&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;messageService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;markAsSending&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;messageId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;?:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;    &lt;span class="c1"&gt;// short tx&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;result&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;smsGateway&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;                                   &lt;span class="c1"&gt;// no tx&lt;/span&gt;
            &lt;span class="n"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;toNumber&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fromNumber&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;messageService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;updateFinalStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;messageId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;             &lt;span class="c1"&gt;// short tx&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;messageService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;markAsFailed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;messageId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;              &lt;span class="c1"&gt;// short tx&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="kd"&gt;data class&lt;/span&gt; &lt;span class="nc"&gt;MessageSnapshot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;toNumber&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;fromNumber&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;?,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The snapshot matters. If markAsSending returned the JPA entity, that entity would be detached the moment its transaction commits, and any lazy-loaded field accessed later (during the gateway call, for example) would throw LazyInitializationException. The snapshot carries exactly the fields the gateway needs and nothing else, which makes it physically impossible to accidentally drag a JPA session into the HTTP call.&lt;/p&gt;

&lt;p&gt;An upstream regional outage now holds, at worst, a single in-flight HTTPS call per concurrent dispatcher tick. It does not hold a Hikari connection.&lt;/p&gt;

&lt;h2&gt;
  
  
  S3: Don't Carry the Bytes Through the Application
&lt;/h2&gt;

&lt;p&gt;For object storage, the fix isn't a transactional change at all — it's an architectural one. Instead of streaming the user's bytes through the application server and into S3, issue a presigned upload URL and let the browser upload directly to the bucket.&lt;/p&gt;

&lt;p&gt;The flow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Client calls POST /upload-intent with mime type and max size.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Backend writes a draft file-metadata row inside its (short) transaction, asks the storage SDK to generate a presigned upload URL constrained by storage key, mime type, content-length range, and a TTL, and returns the URL plus any required form fields to the client.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Client uploads the file directly to the storage endpoint using the presigned URL. The application server is not in the data path.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;When the client confirms the upload, the backend (in another short transaction) flips the file's status from PENDING_UPLOAD to UPLOADED.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Two things this gets you. First, the application never holds a database connection while bytes move over the wire — there are no bytes moving through the application. Second, a slow user network or a slow storage region affects only that user (their progress bar moves slowly), not other unrelated requests.&lt;/p&gt;

&lt;p&gt;This was already the design for new client-driven uploads. The discipline is to make sure no feature builds new uploads on the "stream through the server" path, and to fail closed if anyone tries to introduce one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Webhooks: The Outbox Pattern
&lt;/h2&gt;

&lt;p&gt;Webhooks are the case where SSE-style "loss is tolerable" is wrong. If a customer is subscribed to a business event and delivery silently fails, their downstream system is out of sync. A single-instance broker can't carry durability here.&lt;/p&gt;

&lt;p&gt;The standard fix is the transactional outbox pattern. The mechanism, briefly, for readers who haven't seen it:&lt;/p&gt;

&lt;p&gt;When a business operation needs to publish a message that must not be lost, the producing transaction inserts the message into an outbox table in the same database, in the same transaction as the business write. Either both commit or neither does — the message is now durable on the same storage as the business state. A separate background poller reads the outbox table, dispatches the messages downstream (to a broker, to an HTTP endpoint, wherever), and marks them processed.&lt;/p&gt;

&lt;p&gt;Because the message is in the database, no broker outage can lose it. The poller can crash, restart, fall behind — the messages stay in the table until they're successfully delivered. Retries, dead-lettering, and ordering are properties of the table, not of the broker.&lt;/p&gt;

&lt;p&gt;The producer side is one extra line inside the existing business transaction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Transactional&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;createOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;OrderRequest&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;order&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;orderRepository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toEntity&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;     &lt;span class="c1"&gt;// business write&lt;/span&gt;
    &lt;span class="n"&gt;outboxService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enqueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;                                   &lt;span class="c1"&gt;// outbox write (same tx)&lt;/span&gt;
        &lt;span class="n"&gt;aggregateId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"order:${order.id}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;eventType&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"order.created"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;objectMapper&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeValueAsString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;outboxService.enqueue(...)&lt;/code&gt; is annotated &lt;code&gt;@Transactional(MANDATORY)&lt;/code&gt;— it refuses to run outside an existing transaction. It writes a row to the outbox table and returns. The business write and the outbox row commit together, atomically, on the same connection.&lt;/p&gt;

&lt;p&gt;The consumer side is a scheduled poller that runs every 5 seconds. It claims a batch of pending records using &lt;code&gt;SELECT ... FOR UPDATE SKIP LOCKED&lt;/code&gt;, processes each in its own transaction (so one bad message doesn't block the others), and lets a registered handler decide what "processing" means. For webhooks, the handler resolves the subscribed endpoints for the event type and publishes one broker queue message per subscriber. From there, a separate consumer makes the actual HTTP call to the customer's endpoint with retry, signing, and dead-lettering.&lt;/p&gt;

&lt;p&gt;The chain of guarantees:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Database → outbox row&lt;/strong&gt;: atomic, transactional, can't be lost.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Outbox row → broker queue&lt;/strong&gt;: at-least-once. The poller commits "processed" only after the broker accepts the message. If the broker is down, the row stays PENDING and the next poll tries again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Broker queue → customer HTTP endpoint&lt;/strong&gt;: at-least-once with explicit retry counters and dead-letter routing. A single-instance broker outage delays delivery; it does not lose messages, because the canonical state is in the outbox row, not the broker.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The cost is straightforward and acceptable. Latency for webhook delivery is bounded below by the poller's 5-second tick. The outbox table grows and is cleaned up on a schedule (hourly, 7-day retention by default). And because the poller uses the same short-transaction shape as everything else, it composes naturally with the rest of the system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Preventing Recurrence
&lt;/h2&gt;

&lt;p&gt;Finding and fixing the bugs is half the work. The other half is making sure the next instance of the same pattern can't ship quietly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;HikariCP configuration.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;spring&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;datasource&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;hikari&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;maximum-pool-size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;50&lt;/span&gt;
      &lt;span class="na"&gt;leak-detection-threshold&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;25000&lt;/span&gt;  &lt;span class="c1"&gt;# gives stack trace before pool exhaustion&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Enable Hikari's leak detection in staging, and in production too if you can tolerate the log noise. Pick a threshold longer than your typical worst-case connection hold (the p99 — the time 99% of healthy connections finish within), so it only triggers on real outliers. Treat it as a diagnostic alarm, not as proof of a leak: when it fires, you get the stack trace of the thread still holding the connection — exactly the evidence that's missing during the incident itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An architecture rule.&lt;/strong&gt; ArchUnit (or your equivalent) enforces the "no external I/O inside &lt;code&gt;@Transactional&lt;/code&gt;" rule at build time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@ArchTest&lt;/span&gt;
&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;transactionalCodeMustNotCallExternalIo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ArchRule&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
    &lt;span class="nf"&gt;methods&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;that&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;areAnnotatedWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Transactional&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;java&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;or&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;areDeclaredInClassesThat&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;areAnnotatedWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Transactional&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;java&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;should&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;notTransitivelyCall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nc"&gt;FirebaseMessaging&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;java&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nc"&gt;S3Client&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;java&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nc"&gt;JmsTemplate&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;java&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nc"&gt;RestTemplate&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;java&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="c1"&gt;// ...whatever else moves bytes off-server&lt;/span&gt;
        &lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exact DSL depends on your version of ArchUnit and how strict you want to be about transitive calls. The point is: name the external clients explicitly, and make the build fail when one of them appears inside a transaction. Two-minute lookup at PR time, instead of a 15-minute outage at 2am.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An alert that catches it in production&lt;/strong&gt;. Even with config and rules in place, you want to know before users do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Alert on &lt;code&gt;hikaricp_connections_pending&lt;/code&gt; &amp;gt; 0 sustained for more than 30 seconds. Pending connections are threads waiting for a connection; sustained pending means the pool is saturated. This will fire long before the 30-second timeout starts surfacing as user errors.&lt;/li&gt;
&lt;li&gt;Alert on &lt;code&gt;hikaricp_connections_active&lt;/code&gt; / hikaricp_connections_max &amp;gt; 0.8 sustained for more than 1 minute. Saturation, not exhaustion — but it's the warning sign.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both are off-the-shelf Micrometer metrics. The thresholds are starting points; tune them to your traffic.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This All Adds Up To
&lt;/h2&gt;

&lt;p&gt;This isn't a one-bug problem and it isn't a one-fix problem. It's a category of bugs — external I/O inside a transaction — that quietly accumulates in any sufficiently old Spring codebase, every instance harmless until an upstream has a bad day. And the fixes form a menu, not a single recipe:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;For SSE&lt;/strong&gt;: tolerate loss, use @TransactionalEventListener(AFTER_COMMIT) with handoff to a separate executor. Cheap, sufficient.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For FCM&lt;/strong&gt;: remove the wrapping transaction, make inner reads short and read-only. Best-effort by nature, no database connection held during the HTTP call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For SMS&lt;/strong&gt;: split the dispatcher into phases, snapshot the row into a DTO, send outside any transaction. A scheduled_messages table covers durability; the dispatcher just keeps connections short.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For S3&lt;/strong&gt;: don't carry the bytes. Presigned POST, client uploads directly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For webhooks&lt;/strong&gt;: full transactional outbox. Durable on the database, survives broker outages.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The unifying principle is small enough to fit on a sticky note: &lt;strong&gt;a database transaction must not enclose a network call to a system the database doesn't manage&lt;/strong&gt;. The size of the fix depends on how much you care if the message is lost, how big the message is, and what infrastructure you can rely on. Pick the smallest pattern that matches your delivery guarantees — and enforce it at build time, because the next instance of this bug will look harmless on review.&lt;/p&gt;

&lt;h2&gt;
  
  
  For Your Runbook
&lt;/h2&gt;

&lt;p&gt;When HikariCP is exhausted but database metrics look healthy, the diagnostic checklist at the top of this article is the fastest path to the cause.&lt;/p&gt;

&lt;p&gt;The fixes are sized to delivery guarantees:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Loss-tolerant (SSE)&lt;/strong&gt; → post-commit listener with executor handoff.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best-effort (FCM)&lt;/strong&gt; → remove the wrapping transaction; short read-only inner transactions; HTTP call outside.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Durable but not user-facing (SMS)&lt;/strong&gt; → short-phase dispatcher; snapshot DTOs; HTTP call between transactions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Large payloads (S3)&lt;/strong&gt; → presigned upload directly from client to storage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Must-not-lose (webhooks)&lt;/strong&gt; → transactional outbox; poller dispatches; broker becomes optional.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pick the smallest pattern that matches the guarantee you actually need. Enforce "no external I/O inside @Transactional" at build time with ArchUnit. Alert on hikaricp_connections_pending &amp;gt; 0 sustained for 30 seconds. Set leak-detection-threshold above your p99 connection-hold time.&lt;/p&gt;

&lt;p&gt;About me: Olga Ermolaeva is a technical solution owner on the Tourfold team at OpenResearch. &lt;a href="https://openresearch.com/" rel="noopener noreferrer"&gt;https://openresearch.com/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>backend</category>
      <category>database</category>
      <category>performance</category>
    </item>
    <item>
      <title>Part 2. The Spring @Transactional Pattern That Drained Our Hikari Pool</title>
      <dc:creator>Olga Ermolaeva</dc:creator>
      <pubDate>Tue, 28 Jul 2026 11:28:06 +0000</pubDate>
      <link>https://dev.to/olga_ermolaeva_f7bd121ab9/part-2-the-spring-transactional-pattern-that-drained-our-hikari-pool-1jdo</link>
      <guid>https://dev.to/olga_ermolaeva_f7bd121ab9/part-2-the-spring-transactional-pattern-that-drained-our-hikari-pool-1jdo</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;The threads were waiting on external I/O performed inside transaction boundaries. A synchronous Spring event listener published to ActiveMQ before the transaction committed — and the same pattern repeated around Firebase, S3, and SMS.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recap
&lt;/h2&gt;

&lt;p&gt;The infrastructure investigation in &lt;a href="https://dev.to/olga_ermolaeva_f7bd121ab9/part-1-our-database-looked-overloaded-the-real-problem-was-transactional-180l-temp-slug-4421161?preview=1e775b444046a69f0d4a2495643f704ab8460995645598467a75d185f3e42750f832107fd1740e4e558268f75425a33dc74c963ce01144e0c69d2b25"&gt;Part 1&lt;/a&gt; narrowed the problem to a precise shape: threads were holding Hikari connections without doing database work. The database itself was healthy. The application was the problem.&lt;/p&gt;

&lt;p&gt;There was one more correlation that turned out to be the anchor for everything that followed. During the same 15-minute window when Hikari was failing, the ActiveMQ broker had been unavailable. The broker's connection failures started just before the Hikari errors and recovered at the same moment Hikari recovered.&lt;/p&gt;

&lt;p&gt;That was the clue. Whatever the application was doing, it was doing it to the broker, in a way that kept JDBC connections checked out from the pool while the broker call was in flight. As soon as we framed it that way, the question became: where in our code does a database transaction enclose a call to the broker (or any other external system)?&lt;/p&gt;

&lt;h2&gt;
  
  
  The Trigger: Synchronous Publishing Inside a Transaction
&lt;/h2&gt;

&lt;p&gt;The smoking gun looked almost reasonable at first glance. A simplified version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Transactional&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;saveOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;OrderRequest&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;OrderResponse&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;saved&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;orderRepository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toEntity&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;     &lt;span class="c1"&gt;// grabs JDBC connection&lt;/span&gt;
    &lt;span class="n"&gt;eventPublisher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OrderUpdatedEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;saved&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="c1"&gt;// synchronous: blocks here&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;saved&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toResponse&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Bad shape: @Transactional → save entity → publish event → synchronous listener → external I/O.&lt;/strong&gt;&lt;br&gt;
The intent is clean: do the write, build a response, broadcast a domain event so other parts of the system can react. Three things you're supposed to do.&lt;/p&gt;

&lt;p&gt;The problem is what &lt;code&gt;eventPublisher.publish(...)&lt;/code&gt; actually does. The publisher is a thin wrapper around Spring's &lt;code&gt;ApplicationEventPublisher&lt;/code&gt;, and Spring's default &lt;code&gt;@EventListener&lt;/code&gt; runs synchronously on the publishing thread. If a listener does I/O — say, sending a JMS message to ActiveMQ so other app instances can fan it out as SSE events — that I/O happens before publish(...) returns.&lt;/p&gt;

&lt;p&gt;Now look at the call site again. &lt;code&gt;publish(...)&lt;/code&gt; is being called from inside an &lt;code&gt;@Transactional&lt;/code&gt; method. Spring's JpaTransactionManager opens a transaction at method entry and grabs a JDBC connection from Hikari. The transaction is still open when &lt;code&gt;publish(...)&lt;/code&gt; runs. That connection is still checked out from Hikari while we're waiting on a network round-trip to a message broker.&lt;/p&gt;

&lt;p&gt;When the broker is healthy, the round-trip takes single-digit milliseconds and nobody notices. When the broker has a bad afternoon — broker pod evicted, network partition, head-of-line blocking on a TCP socket — every write request in the system parks on a JMS send. JDBC connections aren't released until &lt;code&gt;publish(...)&lt;/code&gt;returns and the transaction commits. The pool drains. New requests wait, time out after 30 seconds, and surface as &lt;code&gt;SQLTransientConnectionException&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is the pattern the infrastructure metrics had been pointing at: many TCP connections from app to database (because Hikari hands them out before requests park), low query throughput at the database (because parked requests aren't sending queries), elevated thread count and non-heap memory in the JVM (because every parked request is a Tomcat thread sitting on a JMS send). All of it consistent with one root cause: a transaction boundary that encloses a network call to a system the database doesn't know about.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Family of Sibling Bugs
&lt;/h2&gt;

&lt;p&gt;Once we knew what to look for, we found more of it. The general shape is the same — a database transaction held open across a call to an external system — but each variant fails for a different reason and hits a different upstream.&lt;/p&gt;

&lt;p&gt;It's worth describing each one in its own terms, because the failure modes are not interchangeable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Push notifications via Firebase&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Certain business writes produce a domain event — for example, an entity being assigned to a user. A listener picks the event up and calls Firebase Cloud Messaging so the mobile app gets a notification.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;FirebaseMessaging.send(...)&lt;/code&gt; is a synchronous HTTP call to Google's servers. It's not exotic — there's no special timeout configured, no rate limit you'd hit in normal operation. It just makes an HTTPS request, waits for the 200, and returns.&lt;/p&gt;

&lt;p&gt;When that listener runs inside an &lt;code&gt;@Transactional(REQUIRES_NEW)&lt;/code&gt;boundary — which it did — every push notification holds a JDBC connection for the entire HTTPS round-trip. On a normal day, that's tens of milliseconds. During a Firebase regional incident, that's seconds-to-minutes per send, and we'd see the same connection-pool symptom from Part 1, but with Firebase as the trigger instead of ActiveMQ.&lt;/p&gt;

&lt;p&gt;The kicker is that the listener doesn't actually need a database transaction at all. It reads a couple of associations to decide who to notify, then calls Firebase. Each of those reads is a tiny query that could happen in its own short transaction. The wrapping transaction was there because someone had wanted "REQUIRES_NEW so the failure can't roll back the original commit" — a defensible instinct that turned the connection pool into Firebase's hostage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Object storage uploads to S3&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;File uploads went through the application: the user's HTTP request hit a controller, the controller wrote a metadata row to Postgres, and inside the same transaction it called &lt;code&gt;s3Client.putObject(...)&lt;/code&gt; to push the bytes to S3.&lt;/p&gt;

&lt;p&gt;This one is worse than it looks. The AWS SDK's default &lt;code&gt;apiCallTimeout&lt;/code&gt; is unset — meaning the SDK is willing to wait indefinitely for a slow PUT. A multi-megabyte upload to a slow region holds a Postgres connection for the entire transfer, and the SDK won't bail on its own. A user uploading a 50 MB file from a flaky mobile network is a connection on loan to S3 for a minute or more.&lt;/p&gt;

&lt;p&gt;The transactional intent here was "if the S3 upload fails, roll back the metadata row so we don't have a database row pointing at a non-existent object". Reasonable in principle, dangerous in practice: while the upload is in flight, that connection is unavailable to anyone else, and the database has no way to know whether to wait or not. It's just patient.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. SMS sending through SMS gateway&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Outbound SMS goes through a per-tenant scheduled dispatcher. The dispatcher wakes up periodically, finds messages in SCHEDULED status, and sends them through whichever gateway the tenant has configured.&lt;/p&gt;

&lt;p&gt;The original dispatcher loop opened &lt;strong&gt;one transaction per tenant&lt;/strong&gt; and processed every pending message for that tenant inside it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="n"&gt;transactionTemplate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findPendingMessages&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
        &lt;span class="n"&gt;smsGateway&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;// synchronous HTTPS call&lt;/span&gt;
        &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;markAsSent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;smsGateway.send&lt;/code&gt; is a synchronous HTTPS call to the upstream provider. The transaction was open across every one of those calls for every pending message in the batch. During a regional outage at the upstream — which has happened — a batch of 50 pending messages would hold one Hikari connection for the duration of 50 sequential HTTPS round-trips, while every other request in the system competed for the remaining 49.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The unifying pattern&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Three different upstream services, three different failure modes, one recipe.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A transaction boundary that encloses I/O to a system the database doesn't know about.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The database is patient. It will happily hold your connection open for as long as you want, and it has no opinion about whether the thing you're waiting on is its problem. Every external system you touch inside that boundary is a potential 30-second (or longer) lien on a connection — and during a real outage, "30 seconds" is "until the upstream comes back".&lt;/p&gt;

&lt;p&gt;The transactional integrity you think you're preserving by including the external call in the transaction is, in most of these cases, an illusion. If the HTTPS call to Firebase succeeds and the database commit fails, you've already sent the notification. There is no "rollback" for an SMS that has already been delivered. The transaction boundary is doing nothing for correctness; it's just keeping a connection on hold.&lt;/p&gt;

&lt;p&gt;The fix, in every case, is some flavor of the same shape: do the database work first, finish it, release the connection, then do the external work. Part 3 is about the variations of that shape, why a single one-size-fits-all answer doesn't work for us, and what each fix actually looks like.&lt;/p&gt;

&lt;p&gt;About me: Olga Ermolaeva is a technical solution owner on the Tourfold team at OpenResearch. &lt;a href="https://openresearch.com/" rel="noopener noreferrer"&gt;https://openresearch.com/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>backend</category>
      <category>database</category>
      <category>java</category>
      <category>performance</category>
    </item>
    <item>
      <title>Part 1. Our Database Looked Overloaded. The Real Problem Was @Transactional.</title>
      <dc:creator>Olga Ermolaeva</dc:creator>
      <pubDate>Tue, 28 Jul 2026 11:26:58 +0000</pubDate>
      <link>https://dev.to/olga_ermolaeva_f7bd121ab9/part-1-our-database-looked-overloaded-the-real-problem-was-transactional-1eg4</link>
      <guid>https://dev.to/olga_ermolaeva_f7bd121ab9/part-1-our-database-looked-overloaded-the-real-problem-was-transactional-1eg4</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;br&gt;
HikariCP was exhausted and Aurora's dashboard screamed overload — but the database was actually doing half its usual work. Connections were checked out; queries weren't running. The threads holding those connections were blocked on something else entirely, and finding what meant looking outside the database.&lt;/p&gt;

&lt;p&gt;This three-part series walks through the diagnostic path that pinned the cause to the application side (Part 1), the code patterns we found once we knew what to look for (Part 2), and the per-path fixes we shipped (Part 3).&lt;/p&gt;
&lt;h2&gt;
  
  
  The Symptom
&lt;/h2&gt;

&lt;p&gt;Our Spring Boot application, deployed on AWS ECS and backed by Aurora PostgreSQL, started throwing this exception:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="n"&gt;java&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;sql&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;SQLTransientConnectionException&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;OrdersApi&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nc"&gt;Connection&lt;/span&gt; &lt;span class="n"&gt;is&lt;/span&gt; &lt;span class="n"&gt;not&lt;/span&gt; &lt;span class="n"&gt;available&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; 
&lt;span class="n"&gt;request&lt;/span&gt; &lt;span class="n"&gt;timed&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="n"&gt;after&lt;/span&gt; &lt;span class="mi"&gt;30000&lt;/span&gt;&lt;span class="n"&gt;ms&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;active&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;idle&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;waiting&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;at&lt;/span&gt; &lt;span class="n"&gt;com&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;zaxxer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;hikari&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;pool&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;HikariPool&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;createTimeoutException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HikariPool&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;java&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;709&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;at&lt;/span&gt; &lt;span class="n"&gt;com&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;zaxxer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;hikari&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;pool&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;HikariPool&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getConnection&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HikariPool&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;java&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;188&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;...&lt;/span&gt;
    &lt;span class="n"&gt;at&lt;/span&gt; &lt;span class="n"&gt;org&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;springframework&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;orm&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;jpa&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;JpaTransactionManager&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;doBegin&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;JpaTransactionManager&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;java&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;420&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The message is unambiguous: HikariCP had all 50 of its connections checked out, six more threads were queued waiting, and after 30 seconds it gave up. Requests started failing.&lt;/p&gt;

&lt;p&gt;Then, exactly 15 minutes later, the errors stopped on their own. No deployment, no restart, no human intervention. The application appeared to recover.&lt;/p&gt;

&lt;p&gt;The natural first instinct was to look at the database. That's where the investigation started — and that's where it almost ended in the wrong place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Looking at the Database (And Finding Confusing Signals)
&lt;/h2&gt;

&lt;p&gt;The first stop was the Aurora cluster's monitoring tab in the AWS console. The numbers there told a dramatic story:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;DatabaseConnections jumped from ~12 to 73 during the incident&lt;/li&gt;
&lt;li&gt;DBLoad spiked from ~0 to 62&lt;/li&gt;
&lt;li&gt;DBLoadNonCPU reached 62&lt;/li&gt;
&lt;li&gt;DBLoadRelativeToNumVCPUs jumped to 30&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyk93o1ehqhdn9xjqroqr.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyk93o1ehqhdn9xjqroqr.jpeg" alt="Generic DB metrics" width="798" height="172"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;But there are no deadlocks according to metrics.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F74wmx1onub155gad4baz.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F74wmx1onub155gad4baz.jpeg" alt="Deadlocks metrics" width="672" height="588"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This looked like a textbook database overload. DBLoadNonCPU = 62 in particular is a strong signal: it means dozens of sessions were waiting on something that isn't the CPU — typically locks, I/O, or buffer waits. With one application connecting to one database, seeing 62 average active sessions waiting on non-CPU resources is the kind of thing that makes you sit up.&lt;/p&gt;

&lt;p&gt;The PostgreSQL logs, however, told a different story. During the entire 15-minute window — exactly when the application was screaming about timeouts — the database logs were silent. No errors, no slow query warnings, nothing. Then, at the precise moment the application stopped throwing exceptions, Postgres logged:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;connection interrupted by peer
unexpected EOF on client connection
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This was the first contradiction. The database had no problem during the incident, and only complained at the very end — and what it complained about was the client hanging up. Not the database failing. The application closed the sockets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Performance Insights Says the Database Was Idle
&lt;/h2&gt;

&lt;p&gt;The next step was AWS Performance Insights, which provides much finer-grained visibility into what Aurora is actually doing. We pulled up the exact incident window and looked at the standard panels:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Top SQL&lt;/strong&gt; (the Load by waits view) — the top entry was the application's normal, most-frequent query: a very simple request we run thousands of times per minute, that individually completes in well under a millisecond. Exactly what you'd expect to see at the top on any healthy day, and nothing capable of holding a connection long enough to drain the pool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Queries per second&lt;/strong&gt; - dropped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sessions / Idle in transaction&lt;/strong&gt; — reached 34.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transactions in progress&lt;/strong&gt; — spiked to 80.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Queries per second&lt;/strong&gt; - dropped from ~450 to ~230&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tuple reads&lt;/strong&gt; - dropped from ~350K to ~190K&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transactions per second&lt;/strong&gt;  - dropped from ~100 to ~60&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IO cache hits&lt;/strong&gt;  - dropped from ~11K to ~5K pages/sec&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6cviy6kkrpmmvrqs26n1.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6cviy6kkrpmmvrqs26n1.jpeg" alt=" " width="798" height="296"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3788mko55g78mip3dnvs.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3788mko55g78mip3dnvs.jpeg" alt=" " width="800" height="268"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb4bfe6z3lfvektw4hi5p.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb4bfe6z3lfvektw4hi5p.jpeg" alt=" " width="800" height="268"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Performance Insights said it was almost idle. Both views came from the same database, the same time window — yet they disagreed completely on whether anything was wrong.&lt;/p&gt;

&lt;p&gt;At this point we considered three possibilities:&lt;/p&gt;

&lt;p&gt;We were looking at the wrong time window in one of the tools (we weren't — timestamps matched).&lt;/p&gt;

&lt;p&gt;One of the metrics was lying (neither was).&lt;/p&gt;

&lt;p&gt;Both numbers were correct, and the discrepancy itself was a clue.&lt;/p&gt;

&lt;p&gt;It turned out to be #3. But before we could understand why, we needed more evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: The Workload Metrics Tell the Real Story
&lt;/h2&gt;

&lt;p&gt;We then looked at the database's workload metrics during the incident — not "how busy is the database" but "what work is the database actually doing":&lt;/p&gt;

&lt;p&gt;This was the moment the investigation pivoted.&lt;/p&gt;

&lt;p&gt;If the database had been overloaded, we'd have seen queries piling up, transaction durations climbing, blocked transactions appearing. Instead, the database was doing roughly half as much work as usual. It wasn't slow. It was underused. The database had spare capacity and was waiting for work that wasn't arriving.&lt;/p&gt;

&lt;p&gt;Combined with everything else, the picture became clear:&lt;/p&gt;

&lt;p&gt;93 TCP connections existed at the network level - much more than usual.&lt;/p&gt;

&lt;p&gt;Real query throughput dropped 50% because half the application's effective capacity wasn't reaching the database&lt;/p&gt;

&lt;p&gt;The two views from AWS were measuring different layers of the same lifecycle. In our case, the gap meant: connections exist, but they aren't doing anything.&lt;/p&gt;

&lt;p&gt;The problem was on the application side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Confirming the Application Side With JVM Metrics
&lt;/h2&gt;

&lt;p&gt;We turned to the application's JVM metrics, scraped via Micrometer into our monitoring stack. The relevant queries:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight prometheus"&gt;&lt;code&gt;&lt;span class="n"&gt;jvm_threads_live_threads&lt;/span&gt;
&lt;span class="n"&gt;jvm_threads_daemon_threads&lt;/span&gt;
&lt;span class="n"&gt;jvm_threads_states_threads&lt;/span&gt;
&lt;span class="n"&gt;jvm_memory_used_bytes&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;area&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"nonheap"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All four spiked during the incident. That alone was consistent with the database being slow — more requests waiting means more threads parked. &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm7lotz5z8rub56bk81bx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm7lotz5z8rub56bk81bx.png" alt="JVM nonHeap" width="800" height="487"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwd081182p9dj1aoo4ssd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwd081182p9dj1aoo4ssd.png" alt="JVM Threads" width="800" height="337"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What We Knew at the End of the Infrastructure Investigation
&lt;/h2&gt;

&lt;p&gt;By this point, we had ruled out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Database overload (workload metrics dropped, not climbed)&lt;/li&gt;
&lt;li&gt;Lock contention (zero blocked transactions, zero deadlocks)&lt;/li&gt;
&lt;li&gt;Slow queries (queries_started ≈ queries_finished)&lt;/li&gt;
&lt;li&gt;Idle-in-transaction abuse (peaked at 117 ms)&lt;/li&gt;
&lt;li&gt;I/O bottleneck (cache reads dropped, didn't spike)&lt;/li&gt;
&lt;li&gt;Database CPU pressure (CPU wait at 0.05 AAS)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And we had confirmed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The database was healthy and underutilized during the incident.&lt;/li&gt;
&lt;li&gt;The application's connection pool was being drained by threads holding connections without using them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The remaining questions were entirely application-side:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which code paths were holding connections?&lt;/li&gt;
&lt;li&gt;Why were threads getting stuck instead of failing fast?&lt;/li&gt;
&lt;li&gt;What changes would prevent this from recurring?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These weren't questions infrastructure metrics could answer. They required reading the code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Handoff to Code Analysis
&lt;/h2&gt;

&lt;p&gt;We turned the infrastructure findings into a code-review checklist and went looking for these patterns:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;@Transactional methods making blocking calls to external systems (HTTP, message brokers, AWS services) while holding a database connection&lt;/li&gt;
&lt;li&gt;JDBC and HTTP client configurations missing explicit timeouts (Java's default is "wait forever")&lt;/li&gt;
&lt;li&gt;HikariCP configuration gaps — specifically missing keepalive-time and leak-detection-threshold&lt;/li&gt;
&lt;li&gt;Unbounded thread pools that could grow without limit during traffic spikes&lt;/li&gt;
&lt;li&gt;Resource leaks: connections, streams, or entity managers not properly closed&lt;/li&gt;
&lt;li&gt;Self-invocation of @Transactional methods that bypass Spring's proxy and silently lose transactional behavior&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The database was not the bottleneck. It was the victim. The next question was: what code path can hold a JDBC connection while doing no SQL?&lt;/p&gt;

&lt;p&gt;That's Part 2.&lt;/p&gt;

&lt;p&gt;About me:  Olga Ermolaeva is a technical solution owner on the Tourfold team at OpenResearch.  &lt;a href="https://openresearch.com/" rel="noopener noreferrer"&gt;https://openresearch.com/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>database</category>
      <category>performance</category>
      <category>postgres</category>
    </item>
  </channel>
</rss>
