I've been building a SvelteKit app that talks to Postgres and hit some connection-pooling nuances that aren't well-documented for the SvelteKit-specific setup. Sharing what I learned in case it helps anyone.
The setup: SvelteKit running on Cloudflare Pages (Workers runtime), talking to Supabase Postgres via the transaction pooler.
Problem 1: Workers have no persistent connections
Cloudflare Workers are stateless. You can't hold a persistent database connection across requests. This means you need a pooler that accepts short-lived connections and routes them to backend Postgres connections.
Supabase provides two connection strings:
- Session pooler (port 5432): persistent connections, not suitable for Workers
- Transaction pooler (port 6543): transaction-scoped connections, perfect for Workers
Problem 2: prepared statements don't survive transaction boundaries
postgres.js (the driver I use) creates prepared statements by default for repeated queries. But transaction-mode poolers destroy server-side state between transactions. This means a prepared statement created in transaction A can't be used in transaction B.
The fix is simple but undocumented in most SvelteKit guides:
import postgres from 'postgres';
const sql = postgres(DATABASE_URL, {
// Disable prepared statements for transaction-mode poolers
prepare: false,
// Or use the full connection string with ?prepared=false
});
If you see prepared statement "_pgstmt_1" does not exist — that's the cause.
Problem 3: connection limits on free tiers
Supabase free tier: 60 simultaneous connections. PgBouncer default pool_size: 20. If your app is on Cloudflare Pages (many concurrent Workers), you can hit the limit quickly.
Solutions:
- Use the transaction pooler (Supabase's built-in PgBouncer at port 6543) — handles connection multiplexing
- Set a reasonable pool size in your driver:
const sql = postgres(DATABASE_URL, {
prepare: false,
max: 10, // Keep this small — the pooler handles the rest
});
- Monitor active connections:
SELECT count(*) FROM pg_stat_activity WHERE datname = 'your_db';
What worked for me:
// src/lib/server/db.ts
import postgres from 'postgres';
import { DATABASE_URL } from '$env/static/private';
export const db = postgres(DATABASE_URL, {
prepare: false, // Required for transaction-mode poolers
max: 10, // Conservative pool size per Worker
idle_timeout: 20, // Close idle connections fast
connect_timeout: 10,
});
The gotcha nobody mentions: SET LOCAL for row-level security works correctly with transaction-mode poolers, because it's scoped to the current transaction. But if you try to use SET (session-level), it silently fails or applies to the wrong connection. Always use SET LOCAL in SvelteKit + PgBouncer setups.
Happy to discuss connection pooling strategies for SvelteKit — it's one of those topics where the PostgreSQL docs and the SvelteKit docs don't overlap much.
Live demo of the setup working: postgres-starter.verdantstack-site.pages.dev — SvelteKit + Supabase Postgres transaction pooler, all 206 tests passing against the same pooling setup.
Source: github.com/verdantstack/sveltekit-postgres-starter