QUEST LOG · December 20, 2025· UPDATED October 29, 2026
Watch Your Connection Pool: A Small Trap When Connecting to a Database from Serverless
The connection pool is common sense on a traditional backend. Move it to serverless and it turns into a landmine.
I touched on this briefly in "Survival Rules for a Hakka Engineer," but after finishing that post I felt I'd only said "it blows up" and never got into why it blows up or how to write code that doesn't. So this post is dedicated to digging up that pit properly, and while I'm at it, collecting patterns you can copy straight into your own code.
# The premise of a connection pool: your program stays alive
On a traditional Node / Go / Java backend, a connection pool looks roughly like this:
// server.ts —— 開機時建一次,之後所有 request 共用
const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 10 })
app.get('/posts', async () => {
const { rows } = await pool.query('select * from posts')
return rows
})
It works thanks to an unwritten premise: this process is going to live for a long time. You spend a little time at startup opening ten TCP connections, then ten thousand requests take turns borrowing and returning them, and the DB only ever sees ten connections.
Serverless tears that premise down completely.
# How it breaks on Workers
The first time I moved that kind of code to Cloudflare Workers, my instinct was "create the pool once at module scope, same as always." Then I ran into three things:
1. Connections can't be reused across requests
On Workers, I/O objects (TCP sockets included) are bound to the request that created them. If the next request tries to use a connection the previous one opened, it blows up immediately:
Error: Cannot perform I/O on behalf of a different request.
In other words, a module-scope pool on Workers simply can't be shared the way it used to be.
2. You don't get to decide how many instances there are
Even on serverless platforms that can keep connections alive across requests, once traffic hits, the platform spins up a bunch of instances at once, and each one has its own pool. Ten instances × 10 connections each is a hundred connections. Postgres's default max_connections is 100, and that's before you count your admin panel, migrations, and cron jobs.
3. When an instance gets recycled, its connections aren't necessarily closed properly
Instances get torn down whenever the platform feels like it. Any connection that never got end() stays open on the DB side until it times out. After a traffic peak, the DB is left holding a pile of zombie connections, and the next wave of traffic gets rejected outright.
The worst part: everything works fine normally, and it only blows up under heavy traffic. And heavy traffic is exactly when you most need it to stay alive.
# Solution 1: Hyperdrive, moving the pool next to the DB
If you have to use Postgres, Cloudflare's Hyperdrive is the lowest-effort fix. It maintains a connection pool for you on Cloudflare's side, so each Worker request only connects to Hyperdrive instead of hitting the DB directly.
The key: create the client inside the request, and close it inside waitUntil when you're done.
import postgres from 'postgres'
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
// 每個 request 自己建 client;真正的連接池在 Hyperdrive 那一側
const sql = postgres(env.HYPERDRIVE.connectionString, { max: 5, fetch_types: false })
const posts = await sql`select id, title from posts order by published_at desc limit 20`
// 回應送出後再關連線,不擋住使用者
ctx.waitUntil(sql.end())
return Response.json(posts)
},
}
Add a binding to wrangler.jsonc:
"hyperdrive": [{ "binding": "HYPERDRIVE", "id": "<your-hyperdrive-id>" }]
Now the number of connections the DB sees is just the size of Hyperdrive's pool, and it no longer balloons along with the number of Worker instances.
# Solution 2: A database driver that speaks HTTP
The other route is to give up on long-lived connections altogether. Drivers like Turso (libSQL) and Neon's serverless driver let every query be a single HTTP request:
import { createClient } from '@libsql/client/web'
export default {
async fetch(request: Request, env: Env) {
const db = createClient({ url: env.TURSO_URL, authToken: env.TURSO_TOKEN })
const { rows } = await db.execute('select id, title from posts limit 20')
return Response.json(rows)
},
}
No TCP connections to manage means no connection pool problem. The price is a bit of extra HTTP latency per query, and you have to rewrite your transactions in whatever style that driver supports. This blog went down this route back when it was hooked up to Turso.
# Solution 3: Don't use a database at all
The last route is the most brute-force, but for a lot of personal projects it's actually the best fit: if your data is small and you rarely write, skip the database.
That's how this blog works right now: the post list is a single index.json on R2, the post bodies are a bunch of Markdown files, and the frontend only has read-only access to R2. No connections, no pool, no max_connections, and therefore none of the pitfalls this post is about.
# How to choose
| Scenario | Recommendation |
|---|---|
| Already on Postgres, need relational queries and transactions | Hyperdrive + a new client per request |
| New project, simple data, want to manage less infrastructure | An HTTP driver like Turso / Neon |
| Personal site, a few writes a month | No database; KV or R2 + JSON is plenty |
# The one-sentence summary
Connection pools are designed for long-lived processes. Serverless processes aren't long-lived, and you don't control how many of them there are, so either hand the pool to the platform (Hyperdrive), switch to a connection style that doesn't need a pool (an HTTP driver), or drop the database altogether.
Get this straight, and you won't end up staring at too many connections on your busiest night, questioning all your life choices.
Apps I ship
Free on iOS and Android.


