PgBouncer for Game Backend Services: Scalable PostgreSQL Connection Pooling

Share
PgBouncer for Game Backend Services: Scalable PostgreSQL Connection Pooling

If you're building online services for your game using Go, C#, or C++, you've probably dealt with PostgreSQL connection management. Opening a new database connection for every request is slow and resource-intensive. The typical solution is writing application-level connection pooling code, but there's a better way: let PgBouncer handle it for you.

Why Not Connect Directly to PostgreSQL?

Every PostgreSQL connection spawns a new process on the server. This works fine for a handful of connections, but game backends often need to handle thousands of concurrent players. Direct connections create problems.

Connection overhead adds latency. Each new connection requires TCP handshake, SSL negotiation, and PostgreSQL authentication. For a real-time game backend, this delay is unacceptable.

Resource exhaustion becomes real. PostgreSQL's default max_connections is 100. Even if you increase it, each connection consumes memory. A server handling 10,000 players with direct connections would need enormous resources just for connection overhead.

Application pooling is fragile. You can write connection pooling into your Go, C#, or C++ code, but now you're maintaining that logic across every service. Different teams implement it differently. Bugs creep in. Configuration becomes scattered.

What PgBouncer Does

PgBouncer sits between your application and PostgreSQL. Your services connect to PgBouncer, which maintains a small pool of actual PostgreSQL connections and multiplexes your application requests across them.

Most, if not, all of the time, when you launch a service it will be containerized running in a cluster. Each of those instances will need to connect to PostgreSQL. Those instances will also auto-scale based on traffic spikes. Managing connection pools from each of those instances to PostgreSQL is very hard since you’ll have to reduce the number of connections in each instance as more instances come online. You will have to do the reverse if you need to scale back down.

You’re service might have a connection pool of 5 set and your PostgreSQL instance might be configured with 10, but as soon as you have 3 instances of your service you’ll already exceed the PostgreSQL configured limit

3 services X 5 connections = 15 total connection

PgBouncer can handle this transparently for you so you can safely scale up and down you service instances without needing to reconfigure their pool sizes. PgBouncer could handle your service scaling up to 3 instances resulting in 15 connections (5 each instance) and yet you upstream PostgreSQL instance still only sees 5 since PgBouncer is multi-plexing those 15 for you.

┌───────────┐ ┌───────────┐ ┌───────────┐          
│ Instance 1│ │ Instance 2│ │ Instance 3│          
└─┬─┬─┬─┬─┬─┘ └─┬─┬─┬─┬─┬─┘ └─┬─┬─┬─┬─┬─┘          
  │ │ │ │ │     │ │ │ │ │     │ │ │ │ │            
  │ │ │ │ │     │ │ │ │ │     │ │ │ │ │   15 total 
  │ │ │ │ │     │ │ │ │ │     │ │ │ │ │ connections
┌─▼─▼─▼─▼─▼─────▼─▼─▼─▼─▼─────▼─▼─▼─▼─▼─┐          
│               PgBouncer               │          
└───────────────┬─┬─┬─┬─┬───────────────┘          
                │ │ │ │ │                          
                │ │ │ │ │    5 total               
                │ │ │ │ │  connections             
              ┌─▼─▼─▼─▼─▼─┐                        
              │PostgreSQL │                        
              └───────────┘                                            

Connecting from Go, C#, or C++

The best part: your application code doesn't change. You point your connection string at PgBouncer instead of PostgreSQL directly.

Go with pgx:

connStr := "postgres://gameuser:password@pgbouncer:6432/gamedb"
pool, err := pgxpool.New(context.Background(), connStr)

C# with Npgsql:

var connStr = "Host=pgbouncer;Port=6432;Database=gamedb;Username=gameuser;Password=password";
await using var conn = new NpgsqlConnection(connStr);
await conn.OpenAsync();

C++ with libpqxx:

pqxx::connection conn("host=pgbouncer port=6432 dbname=gamedb user=gameuser password=password");
pqxx::work txn(conn);

That's it. No special libraries, no pooling configuration in your app. PgBouncer handles everything.

Local Development Setup

Here's a Docker Compose configuration for local development with PgBouncer and multiple PostgreSQL instances. This setup lets you test connection pooling and experiment with failover scenarios.

services:
  postgres-primary:
    image: postgres:16
    environment:
      POSTGRES_USER: gameuser
      POSTGRES_PASSWORD: gamepass
      POSTGRES_DB: gamedb
    volumes:
      - postgres_primary_data:/var/lib/postgresql/data
    ports:
      - "5432:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U gameuser -d gamedb"]
      interval: 5s
      timeout: 5s
      retries: 5

  postgres-replica:
    image: postgres:16
    environment:
      POSTGRES_USER: gameuser
      POSTGRES_PASSWORD: gamepass
      POSTGRES_DB: gamedb
    volumes:
      - postgres_replica_data:/var/lib/postgresql/data
    ports:
      - "5433:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U gameuser -d gamedb"]
      interval: 5s
      timeout: 5s
      retries: 5

  pgbouncer:
    image: bitnami/pgbouncer:latest
    environment:
      POSTGRESQL_HOST: postgres-primary
      POSTGRESQL_PORT: 5432
      POSTGRESQL_USERNAME: gameuser
      POSTGRESQL_PASSWORD: gamepass
      POSTGRESQL_DATABASE: gamedb
      PGBOUNCER_PORT: 6432
      PGBOUNCER_DATABASE: gamedb
      PGBOUNCER_POOL_MODE: transaction
      PGBOUNCER_MAX_CLIENT_CONN: 1000
      PGBOUNCER_DEFAULT_POOL_SIZE: 20
      PGBOUNCER_MIN_POOL_SIZE: 5
    ports:
      - "6432:6432"
    depends_on:
      postgres-primary:
        condition: service_healthy

volumes:
  postgres_primary_data:
  postgres_replica_data:

Start everything with docker compose up -d. Your services connect to localhost:6432 and PgBouncer handles the rest.

The key settings here are:

  • PGBOUNCER_POOL_MODE: transaction which releases connections back to the pool after each transaction completes
  • PGBOUNCER_MAX_CLIENT_CONN: 1000 allowing up to 1000 application connections
  • PGBOUNCER_DEFAULT_POOL_SIZE: 20 meaning only 20 actual PostgreSQL connections serve all those clients.

Process Signals for Operations

PgBouncer responds to Unix signals for runtime control. This is how you manage configuration changes, failover, and scaling without restarting the process or dropping connections.

Signal Effect
SIGHUP Reload configuration file
SIGUSR1 Pause all database activity
SIGUSR2 Resume paused activity
SIGINT Safe shutdown (wait for servers)
SIGTERM Super safe shutdown (wait for clients)
SIGQUIT Immediate shutdown

Configuration Changes

When you modify pgbouncer.ini to adjust pool sizes, add databases, or change connection limits, send SIGHUP to apply changes without dropping existing connections.

kill -SIGHUP $(cat /var/run/pgbouncer/pgbouncer.pid)

PgBouncer reloads the configuration and applies new settings. Existing connections continue uninterrupted.

Failover

When your primary PostgreSQL fails and you need to switch to a replica, use the pause-reload-resume pattern.

# Pause: stop accepting new queries, let active transactions complete
kill -SIGUSR1 $(cat /var/run/pgbouncer/pgbouncer.pid)

# Update pgbouncer.ini to point at the new primary
sed -i 's/postgres-primary/postgres-replica/' /etc/pgbouncer/pgbouncer.ini

# Reload configuration
kill -SIGHUP $(cat /var/run/pgbouncer/pgbouncer.pid)

# Resume: accept new connections to the new primary
kill -SIGUSR2 $(cat /var/run/pgbouncer/pgbouncer.pid)

Your application services experience a brief pause while transactions drain, then continue against the new primary without reconnecting.

Scaling Pool Sizes

To handle a traffic spike, increase default_pool_size in your configuration and reload.

# Edit config to increase pool size
sed -i 's/default_pool_size = 20/default_pool_size = 50/' /etc/pgbouncer/pgbouncer.ini

# Apply without restart
kill -SIGHUP $(cat /var/run/pgbouncer/pgbouncer.pid)

New connections use the larger pool immediately.

Zero-Downtime Database Maintenance

For planned PostgreSQL maintenance, the pause signal lets you drain connections gracefully.

# Pause PgBouncer - active transactions complete, new queries wait
kill -SIGUSR1 $(cat /var/run/pgbouncer/pgbouncer.pid)

# Perform maintenance on PostgreSQL
# ...

# Resume when ready
kill -SIGUSR2 $(cat /var/run/pgbouncer/pgbouncer.pid)

Client connections stay open throughout. They just experience a delay during the maintenance window rather than connection errors.

When to Use PgBouncer

PgBouncer makes sense when you have multiple services connecting to the same PostgreSQL database, when you need to handle high concurrency without exhausting database connections, when you want to simplify failover and scaling operations, or when you want to remove connection pooling logic from your application code.

For small projects with a single service and low traffic, direct connections are fine. But as your game grows and you add matchmaking services, leaderboard services, analytics, and more, PgBouncer becomes essential infrastructure.

Getting Started

Add PgBouncer to your local Docker Compose setup and point your services at it. You'll immediately see reduced connection overhead and simpler application code. When you're ready for production, the same configuration scales up with you.