Connection pooling for SvelteKit + Postgres
Postgres has a hard limit on how many connections a server accepts, and every connection costs server-side memory and a handshake. This guide explains how the SvelteKit + Postgres Starter pools connections in-process, when a transaction-mode pooler becomes necessary, and how to size everything so your app neither starves requests nor exhausts the database.
Why a single instance does not need an external pooler at first
The starter uses postgres.js, an asynchronous driver that maintains a connection pool inside your Node process. When the app boots, it opens a pool sized by PG_MAX_CONNECTIONS (default 20); requests borrow a connection for the duration of a query and return it when done. Postgres.js handles this pooling internally — there is no separate pooler process, and the pool is shared across requests handled by that Node instance.
At realistic early-stage concurrency, a single SvelteKit instance talking directly to Postgres with 20 pooled connections is comfortable. Opening one connection per request (the naive "connect every time" antipattern) is what actually hurts — pooled in-process connections amortize the handshake cost across thousands of queries. The kit never opens its own connection per request: getDb() returns a memoized, process-wide handle, and services receive that Db and reuse it.
The pool is created once in src/lib/server/db/index.ts — getDb() parses PG_MAX_CONNECTIONS (falling back to 20 when unset or invalid) and opens the postgres() client with that max:
// src/lib/server/db/index.ts (abridged)
export async function getDb(): Promise<Db> {
if (!singleton) {
const url = process.env.DATABASE_URL;
if (!url) throw new Error('DATABASE_URL is not set; see .env.example');
const max = Number.parseInt(process.env.PG_MAX_CONNECTIONS ?? '', 10);
singleton = (await openDb(url, { max: Number.isFinite(max) && max > 0 ? max : 20 })).db;
}
return singleton;
}Every route, service, and test that needs the database uses this handle (or passes in its own, in the case of tests). Because the pool is process-scoped, all of an instance's traffic shares the same 20 connections — which is exactly why one instance rarely needs PgBouncer in front of it.
How the kit sizes the pool and tunes timeouts
Pool sizing and connection behavior are controlled by three environment variables, all with working defaults so the app runs with zero configuration:
PG_MAX_CONNECTIONS(default 20) — the in-process pool size for postgres.js. This is the upper bound on concurrent queries this Node instance can run against Postgres.PG_IDLE_TIMEOUT(default 20) — seconds before an idle pooled connection is closed. A lower value frees server resources faster; a higher value keeps warm connections around for bursts.PG_CONNECT_TIMEOUT(default 10) — seconds for a connection attempt before it times out. Useful to fail fast when the database or a pooler endpoint is unreachable.
These are documented in docs/deployment.md §5 and match the defaults postgres.js ships with. The practical rule: leave them alone until you have a reason (a provider's idle timeout, a pooler's size) and keep every instance configured identically so request handling is uniform across the fleet.
When to add a transaction-mode pooler
Two situations push you toward an external pooler: many instances and an unstructured connection ceiling.
- Multi-instance deployments: if you run N SvelteKit instances behind a load balancer, each opens its own 20-connection pool, so the database sees 20×N connections. At a few instances this is still fine; as N grows toward the server's connection limit, you need a single pooling layer to keep total connections flat.
- Serverless / edge functions: each cold start or concurrent invocation opens its own pool or connections, which easily blows past the connection limit. A pooler absorbs that churn.
- Small / sandboxed Postgres tiers: some managed providers cap total connections well below what pooled Node instances want. A transaction-mode pooler multiplexes many client connections onto a few server connections.
Postgres' two pooling modes matter here. Session pooling holds a client connection for the entire client session — it does not reduce the connection ceiling for a stateful app. Transaction pooling returns a server connection to the pool after each transaction/statement, so a large number of clients share a small number of real server connections. That is the mode you want for an HTTP request-per-connection SvelteKit app. The kit's guidance is deliberately generic ("transaction-mode pooler") because the right choice depends on your provider: PgBouncer if you self-host or run on Fly.io, Neon's built-in pooling (PgBouncer under the hood) if you host on Neon, or Supavisor for Supabase.
The exact PgBouncer config from deployment.md
For self-hosted Postgres or Fly.io, docs/deployment.md §5 ships the exact PgBouncer configuration to front a single app database with a transaction pool:
# docs/deployment.md — PgBouncer in front of Postgres
[databases]
app = host=postgres port=5432 dbname=app
[pgbouncer]
listen_port = 6432
pool_mode = transaction
max_client_conn = 200
default_pool_size = 20Then point the app at the pooler instead of Postgres directly:
DATABASE_URL=postgres://user:pass@host:6432/appThe key lines: pool_mode = transaction (so connections are returned to the pool after each transaction, letting many clients share few server connections), max_client_conn = 200 (how many client connections PgBouncer will accept), and default_pool_size = 20 (how many real server connections PgBouncer holds open to Postgres). Nothing in the app code changes — the pooler sits between postgres.js and Postgres, and the app only sees a different DATABASE_URL.
Neon pooled connection strings
Neon includes PgBouncer-based pooling built in, so no separate PgBouncer install is needed. The distinction lives entirely in the connection string's port: use the pooled endpoint (port 5432/5433) rather than the direct connection, and put that string in DATABASE_URL. A Neon pooled string looks like:
postgres://user:password@ep-xxxx-pooler.us-east-2.aws.neon.tech:5432/neondbThe important part is using Neon's pooler port in DATABASE_URL; from the kit's perspective it is just another connection string. Deploy section guidance in docs/deployment.md calls out Neon, Railway, Supabase-direct, Fly.io, and self-hosted as equally valid — the code only needs a reachable DATABASE_URL.
Matching PG_MAX_CONNECTIONS to the pooler
Once a pooler is in front of Postgres, your in-process pool and the pooler's server pool must not fight. The rule from docs/deployment.md §5: keep PG_MAX_CONNECTIONS roughly equal to the pooler's default_pool_size.
Why: PG_MAX_CONNECTIONS bounds how many connections your Node instance asks for, and default_pool_size bounds how many server connections the pooler keeps to Postgres. If the app tries to open far more connections than the pooler's server pool, client connections queue behind the default_pool_size limit (with transaction mode, they wait for a transaction slot), and in the worst case you burn connections instead of gaining concurrency. Setting them close together means your Node pool and the pooler's server pool agree on the ceiling — predictable latency and no exhaustion surprises. In the shipped config, PG_MAX_CONNECTIONS = 20 aligns with default_pool_size = 20.
Why the service layer stays unchanged
None of this touches your application code. The kit's design rule is that services are framework-free pure functions that accept a Db handle and never open their own connection:
- Routes parse forms and call services; they never touch the database directly except through services.
- Services take
Dbas an argument and perform queries against whatever handle is passed in. getDb()owns the one process-wide handle, so the pool (and therefore pooling strategy) is centralized in one place.
Switch from direct Postgres to a PgBouncer or Neon pooled string and back to direct, and the only thing that changes is DATABASE_URL. This is the same seam philosophy that lets the kit's service layer run unchanged on SQLite in the Multi-tenant SvelteKit Starter — swap the driver and the handle, keep the services. If you later add read replicas, Drizzle supports separate pooled handles for reads and writes with the same pattern (documented in docs/deployment.md §7).
Troubleshooting common connection issues
| Symptom | Likely cause | Fix |
|---|---|---|
FATAL: sorry, too many clients already / connection limit exceeded | The server's max_connections is exhausted — usually many instances × pool size, or connections opened without pooling (a new pool per request is the classic offender). | Open the pool once (the kit's getDb() singleton), reduce PG_MAX_CONNECTIONS, or front Postgres with a transaction-mode pooler so many clients share few server connections. |
| Requests succeed slowly then fail under load, with queueing at the pooler | PG_MAX_CONNECTIONS far exceeds the pooler's default_pool_size; client connections line up behind the smaller server pool in transaction mode. | Match PG_MAX_CONNECTIONS to the pooler's default_pool_size so both layers agree on the ceiling — the shipped default of 20 aligns with default_pool_size = 20. |
| Idle connections accumulate and the server reports high connection memory / open sockets | Idle connections are held open longer than the workload needs, or PG_IDLE_TIMEOUT is very high. | Reduce PG_IDLE_TIMEOUT (default 20s) so idle connections close sooner and free server resources between bursts. |
Connection timed out / slow boot when DB or pooler is unreachable | Connection attempts hang because PG_CONNECT_TIMEOUT is too high, or the endpoint/port is wrong (e.g. direct vs pooled Neon port). | Lower PG_CONNECT_TIMEOUT (default 10s) to fail fast, and confirm you are using the correct port in DATABASE_URL — port 5432/5433 for Neon's pooler. |
Production checklist
- Leave PG_MAX_CONNECTIONS at the default 20 unless you have measured a reason to change it. A single Node/SvelteKit instance rarely exhausts 20 connections; every connection you open has a memory and handshake cost on the server.
- When you add a transaction-mode pooler (PgBouncer, Supavisor, or Neon's built-in pooler), set PG_MAX_CONNECTIONS roughly equal to the pooler's default_pool_size. If the pooler allows 20 server connections, do not let postgres.js try to open 200.
- Use the pooler's pooled-port connection string in DATABASE_URL (in Neon, the port 5432/5433 pooler endpoint, not the direct port). The code only reads DATABASE_URL, so it works unchanged.
- Tune the in-process pool with PG_IDLE_TIMEOUT (default 20s) and PG_CONNECT_TIMEOUT (default 10s) to match your provider's idle and connection windows, and keep the deploy the same on every instance so all instances size identically.
- Watch for "too many connections" (server_more_connections / connection limit) and idle-exhaustion symptoms in logs — the troubleshooting table below covers both signs and fixes.
- Because services accept a Db handle and never open their own connection, adding or removing a pooler changes only DATABASE_URL and env — no service-layer code changes.
Related reading
- Session management with Postgres — token lifecycle, hashed sessions, revocation
- Drizzle migrations — how schema changes flow through drizzle-kit
- Read replicas — separate read/write handles with the same pooling pattern
- Multi-tenant DB design — the schema this pool serves
- Product page — features, pricing, FAQ
- All documentation — guides, deep dives, and evaluations
Get in touch
Questions about the product, team licenses, or anything else? We'll respond within 48 hours.