Skip to content
AyoKoding

Connection Pooling

Why Connection Pooling Matters

Database connections are expensive resources requiring TCP handshake, authentication, and session initialization. Creating connections per request kills performance—a connection takes 50-100ms to establish. In production systems handling thousands of requests per second, connection pooling reuses connections, reducing latency from 100ms to <1ms and preventing connection exhaustion.

JDBC DriverManager Baseline

Manual connection creation without pooling:

import java.sql.*;
 
// => Repository: no connection pooling
public class ZakatAccountRepository {
 
    // => Connection parameters: hardcoded configuration
    private static final String DB_URL = "jdbc:postgresql://localhost:5432/zakat";
    private static final String DB_USER = "admin";
    private static final String DB_PASSWORD = "secret";
 
    // => Find account: creates new connection every time
    public ZakatAccount findById(Long id) {
        // => DriverManager.getConnection(): creates new connection
        // => Expensive: TCP handshake + authentication + session init
        // => 50-100ms per connection establishment
        Connection conn = null;
        PreparedStatement stmt = null;
        ResultSet rs = null;
 
        try {
            // => Creates new connection: no reuse
            // => Every request establishes new TCP connection
            conn = DriverManager.getConnection(DB_URL, DB_USER, DB_PASSWORD);
 
            String sql = "SELECT id, account_number, balance FROM zakat_accounts WHERE id = ?";
            stmt = conn.prepareStatement(sql);
            stmt.setLong(1, id);
            rs = stmt.executeQuery();
 
            if (rs.next()) {
                // => Extract data: 1-2ms
                return new ZakatAccount(
                    rs.getLong("id"),
                    rs.getString("account_number"),
                    rs.getBigDecimal("balance")
                );
            }
 
            return null;
 
        } catch (SQLException e) {
            throw new RuntimeException("Database error", e);
 
        } finally {
            // => Close connection: destroys TCP connection
            // => Next request must create new connection (expensive)
            if (rs != null) {
                try { rs.close(); } catch (SQLException ignored) {}
            }
            if (stmt != null) {
                try { stmt.close(); } catch (SQLException ignored) {}
            }
            if (conn != null) {
                try {
                    // => conn.close(): closes TCP connection to database
                    // => Connection destroyed, can't be reused
                    conn.close();
                } catch (SQLException ignored) {}
            }
        }
    }
 
    // => Save account: creates another new connection
    public void save(ZakatAccount account) {
        Connection conn = null;
        PreparedStatement stmt = null;
 
        try {
            // => Another new connection: 50-100ms overhead again
            // => Database must handle authentication, create session
            conn = DriverManager.getConnection(DB_URL, DB_USER, DB_PASSWORD);
 
            String sql = "INSERT INTO zakat_accounts (account_number, balance) VALUES (?, ?)";
            stmt = conn.prepareStatement(sql);
            stmt.setString(1, account.getAccountNumber());
            stmt.setBigDecimal(2, account.getBalance());
            stmt.executeUpdate();
 
        } catch (SQLException e) {
            throw new RuntimeException("Database error", e);
 
        } finally {
            if (stmt != null) {
                try { stmt.close(); } catch (SQLException ignored) {}
            }
            if (conn != null) {
                try {
                    // => Close: destroys connection again
                    conn.close();
                } catch (SQLException ignored) {}
            }
        }
    }
}

Limitations:

  • Slow: 50-100ms per connection creation (TCP + auth + session)
  • Resource exhaustion: Database limits connections (e.g., PostgreSQL default: 100)
  • No reuse: Every request creates/destroys connection
  • Performance cliff: At high load, requests wait for available database connections
  • No health checks: Can't detect broken connections until query fails

HikariCP Connection Pool

HikariCP provides production-grade connection pooling:

import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import javax.sql.DataSource;
 
// => Configuration: HikariCP connection pool
@Configuration
public class DatabaseConfig {
 
    @Bean  // => DataSource: connection pool
    public DataSource dataSource() {
        // => HikariConfig: connection pool configuration
        HikariConfig config = new HikariConfig();
 
        // => Database connection parameters
        config.setJdbcUrl("jdbc:postgresql://localhost:5432/zakat");
        config.setUsername("admin");
        config.setPassword("secret");
        config.setDriverClassName("org.postgresql.Driver");
 
        // => Pool sizing: connection pool capacity
        config.setMaximumPoolSize(20);  // => Max 20 connections in pool
        // => More connections = more concurrency but more memory/database load
        // => Formula: connections = ((core_count * 2) + effective_spindle_count)
        // => Example: 8 cores + 1 disk = 17 connections (round to 20)
 
        config.setMinimumIdle(5);  // => Min 5 idle connections maintained
        // => Idle connections: ready for immediate use, no creation delay
        // => Pool keeps at least 5 connections open even when idle
 
        // => Connection timeout: max wait time for connection from pool
        config.setConnectionTimeout(30000);  // => 30 seconds
        // => If pool exhausted: request waits up to 30s for available connection
        // => Throws SQLException after timeout
 
        // => Idle timeout: remove idle connections after duration
        config.setIdleTimeout(600000);  // => 10 minutes
        // => Connections idle >10min removed (down to minimumIdle)
        // => Prevents holding idle connections unnecessarily
 
        // => Max lifetime: maximum connection lifespan
        config.setMaxLifetime(1800000);  // => 30 minutes
        // => Connections closed after 30min regardless of usage
        // => Prevents stale connections, handles firewall timeouts
        // => Should be shorter than database's wait_timeout
 
        // => Leak detection: warn if connection held too long
        config.setLeakDetectionThreshold(60000);  // => 60 seconds
        // => Logs warning if connection not returned to pool after 60s
        // => Helps detect connection leaks in application code
 
        // => Connection test query: validates connection health
        config.setConnectionTestQuery("SELECT 1");
        // => Executes before returning connection from pool
        // => Detects broken connections (network failure, database restart)
 
        // => Pool name: for monitoring and logging
        config.setPoolName("ZakatHikariPool");
        // => Appears in logs: helps identify pool in multi-datasource apps
 
        // => Connection initialization SQL
        config.setConnectionInitSql("SET TIME ZONE 'UTC'");
        // => Executed when connection created
        // => Ensures consistent session settings
 
        // => Auto-commit: default transaction behavior
        config.setAutoCommit(true);
        // => Connections returned with auto-commit enabled
        // => Spring @Transactional disables auto-commit automatically
 
        // => Creates HikariDataSource: connection pool implementation
        return new HikariDataSource(config);
    }
}
 
// => Repository: uses connection pool via JdbcTemplate
@Repository
public class ZakatAccountRepository {
 
    private final JdbcTemplate jdbcTemplate;
 
    // => Constructor: JdbcTemplate wraps DataSource (connection pool)
    public ZakatAccountRepository(DataSource dataSource) {
        this.jdbcTemplate = new JdbcTemplate(dataSource);
    }
 
    // => Find account: gets connection from pool
    public ZakatAccount findById(Long id) {
        // => jdbcTemplate.queryForObject(): internally calls dataSource.getConnection()
        // => HikariCP returns pooled connection: <1ms (vs 50-100ms for new connection)
        // => Connection already authenticated, session established
        return jdbcTemplate.queryForObject(
            "SELECT id, account_number, balance FROM zakat_accounts WHERE id = ?",
            (rs, rowNum) -> new ZakatAccount(
                rs.getLong("id"),
                rs.getString("account_number"),
                rs.getBigDecimal("balance")
            ),
            id
        );
        // => Connection automatically returned to pool after query
        // => Not closed: reused for next request
    }
 
    // => Save account: reuses pooled connection
    public void save(ZakatAccount account) {
        // => Gets connection from pool: <1ms
        // => Same connection may be reused from previous request
        jdbcTemplate.update(
            "INSERT INTO zakat_accounts (account_number, balance) VALUES (?, ?)",
            account.getAccountNumber(),
            account.getBalance()
        );
        // => Connection returned to pool: available for next request
    }
 
    // => Multiple operations: connection automatically managed
    public void transferFunds(Long fromId, Long toId, BigDecimal amount) {
        // => All operations use same pooled connection
        // => Connection checked out once, used for all statements, returned once
 
        ZakatAccount from = findById(fromId);
        from.debit(amount);
        jdbcTemplate.update(
            "UPDATE zakat_accounts SET balance = ? WHERE id = ?",
            from.getBalance(),
            fromId
        );
 
        ZakatAccount to = findById(toId);
        to.credit(amount);
        jdbcTemplate.update(
            "UPDATE zakat_accounts SET balance = ? WHERE id = ?",
            to.getBalance(),
            toId
        );
 
        // => Connection returned to pool after all operations
    }
}

Benefits:

  • 100x faster: <1ms to get pooled connection vs 50-100ms to create new
  • Resource efficiency: Reuses connections, reduces database load
  • Bounded concurrency: maxPoolSize prevents connection exhaustion
  • Health checks: Detects and removes broken connections
  • Leak detection: Warns about unreturned connections

Spring Boot Auto-Configuration

Spring Boot configures HikariCP automatically:

# application.yml: Spring Boot HikariCP configuration
spring:
  datasource:
    # => JDBC URL: database connection string
    url: jdbc:postgresql://localhost:5432/zakat
    username: admin
    password: secret
    driver-class-name: org.postgresql.Driver
 
    # => HikariCP configuration: Spring Boot auto-detects HikariCP on classpath
    hikari:
      # => Pool sizing
      maximum-pool-size: 20 # => Max connections in pool
      minimum-idle: 5 # => Min idle connections maintained
 
      # => Timeouts (milliseconds)
      connection-timeout: 30000 # => 30s to get connection from pool
      idle-timeout: 600000 # => 10min idle before removal
      max-lifetime: 1800000 # => 30min max connection lifetime
 
      # => Leak detection
      leak-detection-threshold: 60000 # => 60s before leak warning
 
      # => Health check
      connection-test-query: SELECT 1 # => Validates connection health
 
      # => Pool name
      pool-name: ZakatHikariPool # => For monitoring
 
      # => Connection properties
      connection-init-sql: SET TIME ZONE 'UTC' # => Initialization SQL
      auto-commit: true # => Default auto-commit behavior
// => Repository: no explicit DataSource configuration needed
@Repository
public class ZakatAccountRepository {
 
    private final JdbcTemplate jdbcTemplate;
 
    // => Spring Boot auto-configures JdbcTemplate with HikariCP DataSource
    // => No manual configuration required
    public ZakatAccountRepository(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }
 
    // => All database operations automatically use connection pool
    public ZakatAccount findById(Long id) {
        return jdbcTemplate.queryForObject(
            "SELECT id, account_number, balance FROM zakat_accounts WHERE id = ?",
            (rs, rowNum) -> new ZakatAccount(
                rs.getLong("id"),
                rs.getString("account_number"),
                rs.getBigDecimal("balance")
            ),
            id
        );
    }
}

Benefits:

  • Zero boilerplate: No HikariConfig or HikariDataSource beans
  • Convention over configuration: Sensible defaults for common use cases
  • Externalized config: Properties file instead of Java code
  • Environment-specific: Override properties per environment (dev/prod)

Connection Pool Monitoring

Production applications need pool health monitoring:

import com.zaxxer.hikari.HikariPoolMXBean;
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
 
// => Health indicator: exposes connection pool metrics
@Component
public class HikariPoolHealthIndicator implements HealthIndicator {
 
    private final HikariDataSource dataSource;
 
    public HikariPoolHealthIndicator(DataSource dataSource) {
        // => Cast to HikariDataSource for pool metrics access
        this.dataSource = (HikariDataSource) dataSource;
    }
 
    @Override
    public Health health() {
        // => HikariPoolMXBean: JMX interface for pool metrics
        HikariPoolMXBean poolMXBean = dataSource.getHikariPoolMXBean();
 
        // => Pool metrics
        int totalConnections = poolMXBean.getTotalConnections();
        // => Total connections: active + idle connections in pool
 
        int activeConnections = poolMXBean.getActiveConnections();
        // => Active connections: currently executing queries
 
        int idleConnections = poolMXBean.getIdleConnections();
        // => Idle connections: available for immediate use
 
        int threadsAwaitingConnection = poolMXBean.getThreadsAwaitingConnection();
        // => Threads waiting: requests blocked waiting for connection
        // => If >0: pool exhausted, consider increasing maximumPoolSize
 
        // => Health status determination
        if (threadsAwaitingConnection > 5) {
            // => Many threads waiting: critical (pool exhausted)
            return Health.down()
                .withDetail("totalConnections", totalConnections)
                .withDetail("activeConnections", activeConnections)
                .withDetail("idleConnections", idleConnections)
                .withDetail("threadsAwaitingConnection", threadsAwaitingConnection)
                .withDetail("reason", "Connection pool exhausted")
                .build();
        }
 
        if (activeConnections == totalConnections && idleConnections == 0) {
            // => All connections active, none idle: warning (pool under pressure)
            return Health.up()
                .withDetail("totalConnections", totalConnections)
                .withDetail("activeConnections", activeConnections)
                .withDetail("idleConnections", idleConnections)
                .withDetail("warning", "Connection pool at capacity")
                .build();
        }
 
        // => Healthy: idle connections available
        return Health.up()
            .withDetail("totalConnections", totalConnections)
            .withDetail("activeConnections", activeConnections)
            .withDetail("idleConnections", idleConnections)
            .build();
    }
}
 
// => Metrics endpoint: Prometheus/Micrometer integration
@Component
public class HikariPoolMetrics implements MeterBinder {
 
    private final HikariDataSource dataSource;
 
    @Override
    public void bindTo(MeterRegistry registry) {
        HikariPoolMXBean poolMXBean = dataSource.getHikariPoolMXBean();
 
        // => Gauge: current value metrics
        Gauge.builder("hikari.connections.total", poolMXBean::getTotalConnections)
            .description("Total connections in pool")
            .register(registry);
 
        Gauge.builder("hikari.connections.active", poolMXBean::getActiveConnections)
            .description("Active connections (executing queries)")
            .register(registry);
 
        Gauge.builder("hikari.connections.idle", poolMXBean::getIdleConnections)
            .description("Idle connections (available)")
            .register(registry);
 
        Gauge.builder("hikari.threads.awaiting", poolMXBean::getThreadsAwaitingConnection)
            .description("Threads waiting for connection")
            .register(registry);
    }
}

Progression Diagram

graph TD
    A[DriverManager<br/>No Pooling] -->|50-100ms per Connection| B[Slow]
    A -->|No Reuse| C[High Load]
    A -->|No Health Checks| D[Broken Connections]
 
    E[HikariCP<br/>Connection Pool] -->|<1ms per Connection| F[Fast]
    E -->|Reuse Connections| G[Low Load]
    E -->|Health Checks| H[Reliability]
    E -->|Leak Detection| I[Safety]
 
    J[Spring Boot<br/>Auto-Configuration] -->|Zero Config| K[Convention]
    J -->|Externalized Properties| L[Environment-Specific]
    J -->|Actuator Integration| M[Monitoring]
 
    style A fill:#DE8F05,stroke:#333,stroke-width:2px,color:#fff
    style E fill:#029E73,stroke:#333,stroke-width:2px,color:#fff
    style J fill:#0173B2,stroke:#333,stroke-width:2px,color:#fff

Production Patterns

Connection Pool Sizing

// => Right-size pool for workload
@Bean
public DataSource dataSource() {
    HikariConfig config = new HikariConfig();
 
    // => Formula: connections = ((core_count * 2) + effective_spindle_count)
    // => Example: 8-core server + 1 disk = (8 * 2) + 1 = 17 connections
    int cpuCores = Runtime.getRuntime().availableProcessors();
    int effectiveSpindleCount = 1;  // 1 for SSD, higher for RAID
    int optimalPoolSize = (cpuCores * 2) + effectiveSpindleCount;
 
    config.setMaximumPoolSize(optimalPoolSize);
    // => Too few: requests wait for connections (high latency)
    // => Too many: database overload, memory waste
 
    config.setMinimumIdle(optimalPoolSize / 4);
    // => minimumIdle: 25% of maximum for typical load patterns
    // => Pool shrinks during low traffic, grows during peaks
 
    return new HikariDataSource(config);
}

Multiple DataSources

// => Primary database configuration
@Configuration
public class PrimaryDatabaseConfig {
 
    @Bean
    @Primary  // => Default DataSource when multiple exist
    public DataSource primaryDataSource() {
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl("jdbc:postgresql://localhost:5432/zakat");
        config.setPoolName("PrimaryPool");
        config.setMaximumPoolSize(20);
        return new HikariDataSource(config);
    }
}
 
// => Secondary database configuration
@Configuration
public class ReportingDatabaseConfig {
 
    @Bean
    public DataSource reportingDataSource() {
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl("jdbc:postgresql://localhost:5432/reporting");
        config.setPoolName("ReportingPool");
        config.setMaximumPoolSize(10);  // => Smaller pool for read-only queries
        config.setReadOnly(true);  // => Read-only optimization
        return new HikariDataSource(config);
    }
}

Connection Initialization

@Bean
public DataSource dataSource() {
    HikariConfig config = new HikariConfig();
 
    // => Initialization SQL: executed when connection created
    config.setConnectionInitSql("""
        SET TIME ZONE 'UTC';
        SET statement_timeout = 30000;
        SET lock_timeout = 10000;
    """);
    // => Ensures consistent session settings across all connections
 
    return new HikariDataSource(config);
}

Trade-offs and When to Use

ApproachLatencyThroughputResource UsageComplexityMonitoring
DriverManager50-100msLowHighLowNone
HikariCP Manual<1msHighLowMediumManual
Spring Boot Auto<1msHighLowVery LowBuilt-in

When to Use DriverManager:

  • Learning JDBC fundamentals
  • Single-threaded applications with infrequent database access
  • Testing/debugging connection issues

When to Use HikariCP Manual Configuration:

  • Need programmatic pool configuration
  • Multiple DataSources with different settings
  • Custom connection validation logic
  • Advanced tuning requirements

When to Use Spring Boot Auto-Configuration:

  • Standard single-database applications (90% of use cases)
  • Convention-over-configuration preference
  • Externalized configuration via properties files
  • Built-in monitoring via Spring Actuator
  • Rapid development without boilerplate

Best Practices

1. Right-Size Connection Pool

Use formula based on CPU cores and disk:

spring:
  datasource:
    hikari:
      maximum-pool-size: 20 # (8 cores * 2) + 1 disk = 17, rounded to 20
      minimum-idle: 5 # 25% of maximum

2. Set Appropriate Timeouts

Prevent indefinite waiting:

spring:
  datasource:
    hikari:
      connection-timeout: 30000 # 30s to get connection
      idle-timeout: 600000 # 10min idle before removal
      max-lifetime: 1800000 # 30min max connection lifetime

3. Enable Leak Detection

Catch connection leaks early:

spring:
  datasource:
    hikari:
      leak-detection-threshold: 60000 # Warn if connection held >60s

4. Configure Health Checks

Detect broken connections:

spring:
  datasource:
    hikari:
      connection-test-query: SELECT 1

5. Monitor Pool Metrics

Track pool health in production:

@Component
public class HikariPoolHealthIndicator implements HealthIndicator {
    // Track totalConnections, activeConnections, threadsAwaitingConnection
}

6. Externalize Configuration

Use environment-specific properties:

# application-dev.yml
spring:
  datasource:
    hikari:
      maximum-pool-size: 5  # Small pool for development
 
# application-prod.yml
spring:
  datasource:
    hikari:
      maximum-pool-size: 20  # Larger pool for production

See Also

Last updated February 5, 2026

Command Palette

Search for a command to run...