Skip to content

Would a Larger Connection Pool Have Solved It?

Published: July 24, 2026

While looking through the Minichat codebase again, the chat room creation logic caught my attention.

@Transactional
public ChatResponseDTO createChat(Long creatorId, ChatRequestDTO dto) {
chatRepository.save(chat); // 1 record
associateUsersWithChat(chat, users); // Based on number of users
messageService.createSystemEntryMessage(chat, users); // 1 record
publishSystemMessageToKafka(chat.getId(), systemMessage);
}

Creating a single room executes at least three INSERT statements, and inviting 50 people increases that to 52 INSERTs. On top of that, publishing a message to Kafka was also placed inside @Transactional.

Once a database connection is acquired to execute SQL, it isn’t returned to the pool until the transaction completes. If there’s a slow operation inside, the connection stays held for that entire duration. I thought the connection pool might run dry under heavy traffic, and I wanted to test if that was actually true.

Producing to Kafka inside a transaction increases connection hold time. Longer connection hold times cause Pending Threads to appear under lower loads.

This was the hypothesis I came up with after reading the code. To see if it was actually true in practice, I set up a test environment first.

ItemValue
Server instances3 (local1 / local2 / local3)
Connection poolHikariCP, maximumPoolSize=20, minimumIdle=20
DataSourceMaster-Replica routing + LazyConnectionDataSourceProxy
Load testing toolk6, constant-vus for 30s
MonitoringPrometheus + Grafana

I added three HikariCP panels to Grafana.

PanelQuery
Active Connectionshikaricp_connections_active
Pending Threadshikaricp_connections_pending
Usage Timehikaricp_connections_usage_seconds_max

hikaricp_connections_pending — The number of threads waiting because they couldn’t get a connection. If this goes above 0, it means the pool is already running short. usage_seconds_max — A metric that slowly decays after capturing the highest peak over a period. It shows the “worst single case,” not the average.

I dropped Prometheus’s scrape_interval from the default 15 seconds down to 1 second. A transaction finishes in a few hundred milliseconds, so if you scrape metrics every 15 seconds, you miss everything happening in between. When Pending reads as 0, you can’t tell if there was really no waiting or if it just missed the scan timing. Setting a short interval resolved that uncertainty.

global:
scrape_interval: 1s
evaluation_interval: 5s

I generated load using an existing message pipeline test scenario. In this test, 60 users send 1,200 TALK messages over 30 seconds.

MetricValue
Heap usage1.9%
CPU usage0.179
Active Connections0
Pending Threads0

It looked like almost no load reached the server. Looking into why, two issues were overlapping.

The code did not wait for the Kafka produce result. kafkaTemplate.send() returns a CompletableFuture right away. Since the code didn’t wait for the result with .get(), the transaction finished without waiting for Kafka’s response. There was no reason for the connection to stay held. So the first hypothesis was wrong. Note that send() isn’t always non-blocking. If the producer’s internal buffer fills up, it actually blocks for the configured timeout duration. Under this test load, though, it never got to that point.

I was measuring the wrong thing. The transaction for sending a single TALK message executes only 1 INSERT. The heavy operation, createChat, ran sequentially only 15 times inside the test script’s setup() step and stopped there. It was omitted entirely from the actual load scenario body. What I intended to measure wasn’t even in the benchmark.

This time, I wrote a new script moving createChat inside the main body of the load test scenario. In this setup, each VU repeatedly creates chat rooms for 30 seconds.

I separated two variables into environment variables:

VariableTarget of Control
MEMBERSNumber of INSERTs per transaction
VUSNumber of concurrent requests
Terminal window
k6 run -e MEMBERS=20 -e VUS=30 connection-pool-test.js
k6 run -e MEMBERS=50 -e VUS=100 connection-pool-test.js

I separated these two variables to test my next hypothesis.

If the transaction is heavy, the connection hold time gets longer. Therefore, Pending Threads will show up at a lower number of concurrent requests.

I expected that bumping MEMBERS from 20 to 50 would trigger pool exhaustion earlier, even at the same VU count.

I combined 2 MEMBERS settings and 3 VUS settings into 6 total conditions and ran each for 30 seconds.

VUsConcurrent Requests per ServerActivePendingThroughputp95Median
3010100140.98/s158ms85ms
6020200217.43/s253ms137ms
100332011234.64/s422ms275ms
VUsConcurrent Requests per ServerActivePendingThroughputp95Median
301010068.26/s356ms239ms
602020097.36/s569ms389ms
100332014103.57/s892ms672ms

Not a single request failed across all 6 runs.

Because VUs were split across 3 server instances, concurrent requests per server equaled VUs ÷ 3. Active Connections moved right along with that number until capping out at the maximum pool size of 20.

MEMBERS20_VU100_pending MEMBERS20_VU100_usage_time

The second hypothesis was wrong too.

ConditionRequests per ServerINSERTs per TransactionPending
MEMBERS=20, VU6020220
MEMBERS=50, VU6020520
MEMBERS=20, VU100332211
MEMBERS=50, VU100335214

Even when I multiplied the INSERTs inside the transaction by 2.4x, Pending stayed at 0 for VU60. Pending threads only started showing up on both sides when I raised VUs from 60 to 100.

MEMBERS50_VU60_pending MEMBERS50_VU60_usage_time

Each incoming request holds exactly one connection whether it takes a short time or a long time. If a server receives 20 concurrent requests and has a pool of 20, everyone gets one and nobody waits. In the end, Pending is a metric that shows up specifically when Concurrent Requests > Pool Size.

However, the load generation strategy plays a role here. In k6, constant-vus sends the next request only after getting a response for the previous one, so maximum concurrent requests stay capped at the VU count. If transactions slow down, the interval between requests just widens, but concurrent requests don’t increase.

Real production services are different. Request arrival rates come in regardless of response times, so when transactions slow down, processing requests pile up and concurrent requests go up. In that situation, a heavy transaction can eventually lead to Pending connections.

Comparing conditions where Pending was 0 gives us the answer:

ConditionPendingThroughputp95Usage Time
MEMBERS=20, VU600217.43/s253ms0.25s
MEMBERS=50, VU60097.36/s569ms0.8s

Even with connections remaining in the pool, throughput dropped by nearly half while latency more than doubled.

In the end, these two metrics were looking at different things:

  • Pending Threads — Did concurrent requests exceed the pool size?
  • Usage Time / Throughput — How long is a connection held inside the transaction?

Distinguishing between these two matters because what you need to fix changes. If I had judged performance strictly by Pending threads, I might have said “Let’s just bump the pool size to 40.” But in the MEMBERS=50, VU60 run, p95 latency was already 569ms despite room left in the pool. Increasing pool size in this state changes nothing.

Looking strictly at transaction count, MEMBERS=50 ran half as many transactions, but total INSERT counts showed this side was giving the DB more work.

ConditionTransaction CountTotal INSERTsINSERTs per Second
MEMBERS=20, VU1007,802171,644~5,720/s
MEMBERS=50, VU1003,916203,632~6,790/s

Execution was stuck right around 6,000–7,000 INSERTs per second. Looking into why it capped out there, the issue was in the code connecting users to the chat room:

private void associateUsersWithChat(Chat chat, List<User> users) {
for (User user : users) {
UserChat userChat = new UserChat();
userChat.setId(userChatIdGenerator.generate());
userChat.setUser(user);
userChat.setChat(chat);
userChatRepository.save(userChat); // Repeated for each user
}
}

Creating a 50-user room calls save() 50 times. Without explicit batch settings, Hibernate flushes 50 individual INSERT statements one by one, stacking up network round trips to the DB. The active transaction holds onto its connection without letting go until all 50 finish.

This was the main reason Usage Time reached 0.8 seconds.

I changed two places in the code.

private void associateUsersWithChat(Chat chat, List<User> users) {
List<UserChat> userChats = users.stream()
.map(user -> {
UserChat userChat = new UserChat();
userChat.setId(userChatIdGenerator.generate());
userChat.setUser(user);
userChat.setChat(chat);
return userChat;
})
.toList();
userChatRepository.saveAll(userChats);
}
spring:
jpa:
properties:
hibernate:
jdbc:
batch_size: 50
order_inserts: true

One thing to watch out for: if you only change to saveAll() without setting batch_size, INSERTs still go out one by one under the hood. You have to apply both to get them bundled into real batch inserts.

I re-tested under the highest load condition (MEMBERS=50, VUS=100).

MetricBefore ImprovementAfter ImprovementChange
Throughput103.57/s175.06/s+69%
Created Rooms3,9166,699+71%
p95892ms555ms-38%
Median672ms325ms-52%
Average672ms350ms-48%
INSERTs per Second~6,790~11,610+71%

While p95 improved, what caught my eye was that median latency dropped even more. This means normal requests got faster overall, not just a few outlier cases.

MEMBERS50_VU100_pending MEMBERS50_VU100_usage_time
After_pending After_usage_time

Looking at the graphs alone, peak values for Pending and Usage Time don’t look like they dropped much after the fix. That’s because these panels show “the worst single value at that moment.” What actually changed was the total request volume processed during the same 30 seconds and the reduced median latency.

Both hypotheses I set in this experiment were wrong.

The first assumed external I/O inside a transaction would hold the connection longer. But because Kafka produce calls didn’t wait for the result, the connection wasn’t really held. That wasn’t something I could know just by looking at the code.

The second assumed doing more work inside a transaction would cause pool exhaustion sooner. But in this test scope, Pending responded to concurrent request counts rather than transaction weight.

To summarize:

  • Pending Threads are driven by the relationship between concurrent requests and pool size.
  • Usage Time and Throughput show how long the work inside the transaction actually takes.
  • These two metrics track different things, and you can’t judge one just by looking at the other.

Reducing DB round trips was the priority, not increasing the pool size. If I hadn’t actually run the test and measured it, I would have looked at code structure alone and fixed the wrong place.