DEV Community

Cover image for Connection Pool Tuning Under Load: How HikariCP Defaults Silently Kill Your Mobile Backend
SoftwareDevs mvpfactory.io
SoftwareDevs mvpfactory.io

Posted on • Originally published at mvpfactory.io

Connection Pool Tuning Under Load: How HikariCP Defaults Silently Kill Your Mobile Backend

---
title: "Connection Pool Tuning: How HikariCP Defaults Silently Kill Your Mobile Backend"
published: true
description: "A hands-on guide to instrumenting, detecting, and fixing HikariCP connection starvation under bursty mobile traffic with Micrometer, PgBouncer, and adaptive pool sizing."
tags: kotlin, postgresql, architecture, performance
canonical_url: https://blog.mvpfactory.co/connection-pool-tuning-hikaricp-mobile-backend
---

## What We're Building

By the end of this tutorial, you'll have a Ktor service with a properly instrumented HikariCP pool, a PgBouncer layer for connection multiplexing, and an adaptive pool sizer that responds to real traffic — not a formula written for a different workload.

Let me show you a pattern I use in every project that handles mobile traffic at scale.

## Prerequisites

- Kotlin + Ktor project with `ktor-server-metrics-micrometer`
- PostgreSQL 15+
- PgBouncer installed (`apt install pgbouncer` or your package manager)
- Familiarity with HikariCP basics

## Step 1: Understand Why the Default Formula Fails

The classic formula — `connections = (CPU cores * 2) + 1` — gives you ~17 connections on an 8-core VM. That's fine for steady web traffic. Mobile backends don't have steady traffic. A single push notification to 50K users can 10x your QPS in under 3 seconds.

Here's what I measured on a production Ktor service during a push-notification burst:

| Pool Size | p99 Latency | Timeout Errors |
|-----------|-------------|----------------|
| 17 (formula) | 2,400ms | 38 per burst |
| 50 (3x formula) | 85ms | 0 |

Your CPUs sit at 30% while threads block waiting for a connection that never comes. The bottleneck is pool wait time, not compute.

## Step 2: Instrument with Micrometer

Before changing any pool size, wire up metrics. HikariCP integrates with Micrometer out of the box:

Enter fullscreen mode Exit fullscreen mode


kotlin
val hikariConfig = HikariConfig().apply {
maximumPoolSize = 20
metricRegistry = prometheusMeterRegistry
poolName = "mobile-api-pool"
}


Three metrics matter:

- **`hikaricp_connections_pending`** — threads waiting for a connection. Alert if this exceeds 0 for more than 500ms.
- **`hikaricp_connections_usage_seconds`** — how long connections are checked out. A rising p99 signals slow queries or transaction leaks.
- **`hikaricp_connections_timeout_total`** — each increment is a failed request. This is your failure counter.

The docs don't mention this, but alert on **pending connections**, not pool utilization. A pool at 80% is fine. A pool with 15 pending waiters for 2 seconds is about to cascade.

## Step 3: Deploy PgBouncer as a Connection Multiplexer

Setting `maximumPoolSize = 50` on a PostgreSQL instance with `max_connections = 100` breaks the moment you run two replicas. PgBouncer fixes this by multiplexing many client connections onto fewer server connections.

Enter fullscreen mode Exit fullscreen mode


ini
[mobile_api]
pool_mode = transaction
default_pool_size = 30
reserve_pool_size = 10
reserve_pool_timeout = 3


In `transaction` mode, PgBouncer holds a real PostgreSQL connection only during a transaction. The `reserve_pool` gives you burst headroom — 10 extra connections that activate when the main pool is saturated for more than 3 seconds.

Your architecture now looks like this:

Enter fullscreen mode Exit fullscreen mode


plaintext
[Mobile App] → [Ktor (HikariCP: 50)] → [PgBouncer (transaction)] → [PostgreSQL (max 60)]


## Step 4: Build Adaptive Pool Sizing

Here's the minimal setup to get this working — a feedback loop that adjusts pool size every 30 seconds based on actual contention:

Enter fullscreen mode Exit fullscreen mode


kotlin
@Scheduled(fixedRate = 30_000)
fun adjustPoolSize() {
val pending = meterRegistry.get("hikaricp.connections.pending")
.gauge().value()
val currentMax = dataSource.hikariPoolMXBean.totalConnections

val newSize = when {
    pending > 5 && currentMax < MAX_CEILING -> currentMax + 10
    pending == 0.0 && currentMax > MIN_FLOOR -> currentMax - 5
    else -> currentMax
}
dataSource.hikariConfigMXBean.maximumPoolSize = newSize
Enter fullscreen mode Exit fullscreen mode

}


Running this in production reduced our average pool size by 40% during off-peak hours while maintaining zero timeouts during bursts.

## Gotchas

**PgBouncer's `transaction` mode breaks prepared statements.** If your ORM uses server-side prepared statements, switch to `SET plan_cache_mode = force_generic_plan` or disable prepared statements at the HikariCP level.

**Don't skip PgBouncer and just raise PostgreSQL's `max_connections`.** PostgreSQL allocates ~10MB per connection. At 500 connections, you're burning 5GB of RAM on connection overhead alone, and context-switching tanks query performance.

**Adaptive sizing needs bounds.** Always set `MAX_CEILING` and `MIN_FLOOR`. Without them, a runaway burst can open connections until PostgreSQL refuses new ones — and then every replica fails simultaneously.

**Retry storms amplify the problem.** If your mobile clients use aggressive retry logic, a brief connection starvation event triggers a second, larger spike. Configure exponential backoff with jitter on the client side.

## Wrapping Up

The pool size formula is a starting point for one type of workload. Mobile backends are a different animal. Instrument with Micrometer, deploy PgBouncer in transaction mode, and let a metrics-driven feedback loop size your pool instead of a formula.

Here is the gotcha that will save you hours: measure `connections_pending`, not pool utilization. That single metric is the earliest signal that your backend is about to cascade.

[HikariCP Metrics Docs](https://github.com/brettwooldridge/HikariCP/wiki/MBean-(JMX)-Monitoring-and-Management) | [PgBouncer Config Reference](https://www.pgbouncer.org/config.html) | [Micrometer + Ktor Guide](https://ktor.io/docs/server-metrics-micrometer.html)
Enter fullscreen mode Exit fullscreen mode

Top comments (0)