DEV Community

Anwar Iqbal Dawar
Anwar Iqbal Dawar

Posted on Originally published at xsypher.com on

How We Cut Postgres p95 Latency from 914ms to 2ms (Serverless vs. Docker + PgBouncer)

Serverless PostgreSQL is phenomenal for shipping fast. Branching, automatic scaling, and zero maintenance make it an easy default when spinning up a new Node.js backend.

Until you inspect your tail latencies in production.

When we audited our API telemetry, our p95 query latency was sitting at 914ms. Even simple indexed lookups were occasionally stalling for nearly a full second.

We migrated that workload from a cross-region serverless Postgres setup to a co-located Docker PostgreSQL 18 instance sitting behind PgBouncer 1.26 in transaction mode. The result: our p95 latency dropped from 914ms to 2ms.

Before jumping to "serverless is slow, self-host everything," it is important to isolate where those 912 milliseconds actually went—because most of the win comes from fixing geography and lifecycle states, while the final drop from 9ms to 2ms comes from local NVMe and connection pooling.


Step 1: Auditing Where the 914ms Actually Went

Blaming the database engine for a 914ms p95 is almost always wrong. PostgreSQL itself executes a simple primary-key lookup in under a millisecond. When we broke down our request traces, three compounding bottlenecks emerged:

  1. Scale-to-Zero Cold Starts (Auto-Suspend): When a serverless compute endpoint suspends after inactivity, the first incoming request pays the VM wake-up and storage-attachment penalty (often 400ms–800ms+).
  2. Cross-Region Network Round-Trips (RTT): Our Node.js API and the serverless database weren't pinned to the exact same availability zone. Every TCP + TLS handshake plus query round-trip multiplied the geographical latency.
  3. Ephemeral Connection Overhead: Without a persistent local pooler keeping warm backend connections open, bursts of concurrent Node.js requests triggered expensive PostgreSQL process forks and TLS negotiations.

The Fair Comparison: Fixing Serverless First (~9ms p95)

If you simply move your serverless Postgres instance into the exact same region as your Node.js API and disable auto-suspend (scale-to-zero), the 914ms nightmare disappears immediately.

In our testing, a properly configured, always-on, same-region serverless database brought p95 latency down to ~9ms. For many teams, stopping right there is the right engineering trade-off.

However, we wanted predictable sub-3ms performance, zero per-compute-hour anxiety, and direct local NVMe throughput without network hops. That meant co-locating PostgreSQL and PgBouncer alongside the application.

Architecture Stage Connection Path Auto-Suspend p95 Latency Primary Bottleneck
1. Initial Serverless Cross-Region + TLS Enabled 914ms Cold starts + cross-region RTT
2. Tuned Serverless Same-Region + Pooled Disabled ~9ms External network hop + proxy overhead
3. Self-Hosted Docker Co-located + PgBouncer (Local NVMe) N/A (Always On) 2ms Local IPC / NVMe I/O only

Step 2: The Co-Located Architecture (PostgreSQL 18 + PgBouncer 1.26)

When you run your Node.js API and PostgreSQL on the same host (or local private network backed by NVMe storage), network latency drops to near zero.

However, pointing a high-concurrency Node.js application (using Prisma or Drizzle) directly at PostgreSQL on port 5432 creates a different problem: connection exhaustion and memory bloat. Each idle Postgres backend process consumes significant RAM, and spinning up new connections under traffic spikes degrades latency.

To solve this, we placed PgBouncer 1.26 in front of PostgreSQL 18 using Transaction Mode.

How the Dual-Port Routing Works (6432 vs. 5432)

A critical mistake teams make when adopting PgBouncer in transaction mode is routing everything through it—including schema migrations. Because transaction mode returns the server connection to the pool immediately after a transaction completes, session-level features (like advisory locks used by Prisma Migrate or Drizzle Kit) will fail or behave unpredictably.

We split our database routing into two explicit paths:

Workload Type Target Service Port Pooling Mode Why?
Application Queries (Node.js API) PgBouncer 1.26 6432 transaction Multiplexes hundreds of app connections over a small pool of real Postgres backends.
Schema Migrations (Prisma / Drizzle) Direct to PostgreSQL 18 5432 session (Direct) Requires session-level advisory locks and persistent state during DDL changes.

Step 3: Minimal Production Docker Compose & Pooler Setup

Here is a simplified version of how the services communicate inside Docker Compose so the Node.js application hits PgBouncer over the internal bridge network with zero external TLS handshake overhead:

services:
  postgres:
    image: postgres:18-alpine
    container_name: db_postgres
    restart: unless-stopped
    shm_size: 1gb
    environment:
      POSTGRES_USER: ${DB_USER}
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: ${DB_NAME}
    volumes:
      - pg_nvme_data:/var/lib/postgresql/data
    command: >
      postgres
      -c shared_buffers=1GB
      -c effective_cache_size=3GB
      -c work_mem=16MB
      -c max_connections=100
    networks:
      - backend_net

  pgbouncer:
    image: edoburu/pgbouncer:v1.26.0
    container_name: db_pgbouncer
    restart: unless-stopped
    depends_on:
      - postgres
    environment:
      DATABASE_URL: "postgres://${DB_USER}:${DB_PASSWORD}@postgres:5432/${DB_NAME}"
      POOL_MODE: transaction
      MAX_CLIENT_CONN: 1000
      DEFAULT_POOL_SIZE: 20
      MIN_POOL_SIZE: 5
      RESERVE_POOL_SIZE: 5
    ports:
      - "127.0.0.1:6432:6432"
    networks:
      - backend_net

networks:
  backend_net:
    driver: bridge

volumes:
  pg_nvme_data:
Enter fullscreen mode Exit fullscreen mode

And in our ORM configuration (.env), we explicitly separate the pooled runtime URL from the direct migration URL:

# Used by the Node.js runtime (via PgBouncer in transaction mode)
DATABASE_URL="postgresql://user:password@pgbouncer:6432/app_db?pgbouncer=true"

# Used exclusively by Prisma / Drizzle CLI for schema migrations
DIRECT_URL="postgresql://user:password@postgres:5432/app_db"
Enter fullscreen mode Exit fullscreen mode

If you want to inspect the exact postgresql.conf memory tuning parameters, prepared statement handling in PgBouncer 1.26, and the full latency percentiles (p50, p95, p99), we documented our complete serverless Postgres vs. self-hosted Docker and PgBouncer benchmark with all raw config files.


When Should You Actually Do This? (The Trade-Offs)

Moving from a managed serverless database to self-hosted Docker + PgBouncer is not free—you trade cloud vendor margin for operational responsibility.

Stay on Serverless Postgres (Same-Region, Auto-Suspend Off) if:

  • You rely heavily on instant database branching for preview deployments.
  • You don't want to manage automated backups (pgBackRest / WAL archiving to S3/R2), disk monitoring, or minor version upgrades.
  • A ~9ms p95 latency easily satisfies your SLA.

Move to Co-Located Docker + PgBouncer if:

  • Your API executes multiple sequential queries per request where 8–10ms per round-trip compounds into 50–100ms of wait time.
  • You want consistent 1–2ms p95 latency backed directly by local NVMe storage.
  • You want predictable flat-rate infrastructure costs under sustained 24/7 traffic.

Wrapping Up

Most "serverless database latency" horror stories start with a misconfigured region or an aggressive scale-to-zero policy. Fix those first. But if your workload demands single-digit millisecond tail latencies at fraction-of-a-cent compute costs, a co-located PostgreSQL 18 container paired with PgBouncer 1.26 remains unbeatable.

For the complete architectural breakdown, automated backup scripts, and query telemetry charts, check out the original engineering write-up on xSypher.

Have you hit connection pooling or cold-start bottlenecks with serverless Postgres in production, or are you running your own pooler stack? Drop your setup in the comments!

Top comments (0)