DEV Community

SoftwareDevs mvpfactory.io
SoftwareDevs mvpfactory.io

Posted on • Originally published at mvpfactory.io

Ktor Connection Pooling with Coroutine-Per-Request

---
title: "From 12K to 50K RPM: HikariCP Tuning for Ktor"
published: true
description: "Tune HikariCP with Ktor's limitedParallelism dispatcher to handle 50K RPM. Pool sizing, leak detection, and coroutine-safe patterns for PostgreSQL."
tags: kotlin, postgresql, architecture, api
canonical_url: https://blog.mvpfactory.co/from-12k-to-50k-rpm-hikaricp-tuning-for-ktor
---

## What We're Building

In this workshop, I'll walk you through the dispatcher architecture that took a production Ktor service from request timeouts at 12K RPM to stable throughput at 50K RPM — on a single $20 VPS. No extra hardware. Just the right constraints in the right places.

You'll learn how to size a HikariCP pool using the PostgreSQL formula, create a dedicated coroutine dispatcher that prevents thread starvation, and configure leak detection that actually works with suspended coroutines.

## Prerequisites

- A Ktor project with Exposed or raw JDBC
- HikariCP as your connection pool (the Ktor default)
- PostgreSQL (the sizing formula is Postgres-specific, though the dispatcher pattern applies anywhere)
- Basic familiarity with Kotlin coroutines and `Dispatchers.IO`

## Step 1: Understand the Mismatch

Here is the gotcha that will save you hours. JDBC is a blocking API. Every `connection.prepareStatement().executeQuery()` blocks the underlying thread. When you pair thousands of coroutines with `Dispatchers.IO` (64 threads) and a HikariCP pool of 10 connections, only 10 threads make progress. The remaining 54 sit parked, starving your file reads, HTTP client calls, and other IO work.

I've seen this kill more production Ktor services than any other misconfiguration.

## Step 2: Create a Limited-Parallelism Dispatcher

Here is the minimal setup to get this working. Cap database concurrency to match your pool size:

Enter fullscreen mode Exit fullscreen mode


kotlin
object DatabaseDispatcher {
val dispatcher = Dispatchers.IO.limitedParallelism(12)
}

suspend fun dbQuery(block: () -> T): T =
withContext(DatabaseDispatcher.dispatcher) {
block()
}


At most 12 threads ever block on JDBC calls. The rest of `Dispatchers.IO` stays free for actual IO work. The 13th coroutine simply *suspends* without blocking a thread and resumes when a slot opens. Built-in backpressure.

## Step 3: Size Your Pool with the PostgreSQL Formula

The canonical formula is `connections = (cores * 2) + effective_spindle_count`. The spindle term models rotational disk latency — with SSDs it effectively becomes 0 or 1. On a $20 VPS (2 vCPUs, SSD):

Enter fullscreen mode Exit fullscreen mode


plaintext
connections = (2 * 2) + 1 = 5


That feels low, but PostgreSQL's own benchmarks confirm a small pool with queued requests outperforms a large pool with contention. I run 10–12 connections because my workload includes longer analytical queries, but the principle holds: more connections does not mean more throughput.

## Step 4: Configure HikariCP

Enter fullscreen mode Exit fullscreen mode


kotlin
val hikariConfig = HikariConfig().apply {
maximumPoolSize = 12
minimumIdle = 4
idleTimeout = 600_000 // 10 minutes
connectionTimeout = 3_000 // fail fast at 3s
maxLifetime = 1_800_000 // 30 minutes
leakDetectionThreshold = 8_000 // tuned for coroutines
}


The `connectionTimeout` of 3 seconds is intentional — fail fast rather than queue indefinitely.

## Step 5: Tune Leak Detection for Coroutines

The docs do not mention this, but the commonly recommended 2,000ms leak detection threshold floods logs with false positives in coroutine-heavy services. A coroutine holding a connection can be *suspended* while waiting on downstream logic, legitimately holding it for 3–5 seconds. After profiling actual hold times (p99.9 was 5,100ms), I set the threshold to 8,000ms — roughly 4x my p99 of 2,400ms. This catches genuine leaks without the noise.

## Production Results

Real metrics from a Ktor service on a Hetzner CX22 (2 vCPU, 4GB RAM), load tested with k6 over 15-minute sustained runs:

| Metric | Before | After |
|---|---|---|
| Max stable RPM | ~12,000 | ~50,000 |
| p99 latency | 4,200ms | 180ms |
| HikariCP wait time (p99) | 2,800ms | 35ms |
| IO thread starvation events/hr | ~340 | 0 |

The workload was 80% reads, 20% writes, tested at 50–400 virtual users. Error rate at 50K RPM was 0.02% — all transient connection resets, zero application errors.

## Gotchas

- **Don't size the dispatcher and pool independently.** If your `limitedParallelism` is 20 but your pool is 10, you've just moved the starvation problem. Match them.
- **A larger pool reduces throughput.** Counterintuitive, but more connections means more lock contention inside PostgreSQL itself. Benchmark, don't guess.
- **This pattern applies beyond databases.** HTTP client pools, rate-limited APIs, file handle limits — match dispatcher parallelism to the bounded resource. Your $20 VPS has more headroom than you think once you stop over-subscribing.
- **Take breaks during long tuning sessions.** Seriously — I use [HealthyDesk](https://play.google.com/store/apps/details?id=com.healthydesk) for break reminders and desk exercises when I'm deep in load-testing rabbit holes. A fresh pair of eyes catches config mistakes faster.

## Wrapping Up

Let me show you a pattern I use in every project: constrain concurrency to match the actual bottleneck. The `limitedParallelism` dispatcher is a backpressure mechanism that costs zero extra infrastructure. Size your pool with the PostgreSQL formula as a baseline, profile your connection hold times before setting leak detection, and let the coroutine scheduler do the queuing for you.

The full approach — dedicated dispatcher, right-sized pool, coroutine-aware leak detection — is the difference between a service that falls over at 12K RPM and one that cruises at 50K.
Enter fullscreen mode Exit fullscreen mode

Top comments (0)