Serverless
A serverless function is a normal Node process with two differences that break the usual pooling advice: it is frozen between invocations and killed without warning, and there are as many processes as there is traffic. Only the pool changes; entities and queries do not.
Put the pool at module scope
Section titled “Put the pool at module scope”Every platform reuses a warm instance for consecutive requests, and module scope is evaluated once per instance rather than once per request:
import { PgQuerierPool } from 'uql-orm/postgres';import './entities.js';
export const pool = new PgQuerierPool({ connectionString: process.env.DATABASE_URL, max: 2, connectionTimeoutMillis: 5_000,});Building the pool inside the handler is the common mistake: it pays the TCP and TLS handshake every request and leaves the previous pool’s sockets to time out. Nothing connects until the first query, so a cold start is not charged for a pool it never uses.
connectionTimeoutMillis is the one setting worth adding by default. Without it, a database that is asleep, unreachable or out of connections leaves the request waiting until the platform kills it, and you are billed for every second of that wait; with it, the query throws while there is still time to return a 503.
A framework dev server is the opposite case: it re-runs the module on every hot reload, so cache the pool on globalThis in development (see Next.js).
max is per instance, not per app
Section titled “max is per instance, not per app”max: 10 on a platform that scales to 200 instances asks for 2000 connections; a small Postgres accepts about 100.
What max should track is concurrency inside one instance. Lambda runs one invocation at a time per execution environment, so the only concurrency there is the one your handler starts itself: a Promise.all over three queries wants max: 3, and everything else wants 1 or 2. The exceptions are runtimes that put several requests on one instance, such as Vercel Fluid compute, where a handful is right. Sizing that low has one trap: at max: 1 a pool call made inside a pool.transaction callback deadlocks against the connection it is already holding.
When instance count alone can exhaust the server, put a pooler in front of the database instead of shrinking max, and run migrations against the direct endpoint.
The first query after a thaw is the one that fails
Section titled “The first query after a thaw is the one that fails”A frozen instance runs no timers, so nothing on your side observes a connection going away: idleTimeoutMillis cannot reap it, and the keepAlive that PgQuerierPool turns on by default sends no probes. The other end is under no such freeze. A database idle_session_timeout, a NAT, a load balancer or a pooler drops the socket while you are suspended, and the loss shows up as ECONNRESET or Connection terminated unexpectedly on the next invocation’s first query.
UQL discards the dead client and the next acquire opens a fresh one. The request in flight is yours to retry: on a connection error only, and never a non-idempotent write outside a transaction.
AWS Lambda
Section titled “AWS Lambda”Module scope survives between invocations for the life of the execution environment, so the snippet above is the whole setup.
Do not call pool.end() per invocation: Lambda freezes the process as soon as the handler’s promise settles, so it would not finish. There is no shutdown hook worth wiring either, since the environment is torn down without running one and the database reclaims the connections when the sockets die.
import type { APIGatewayProxyHandlerV2 } from 'aws-lambda';import { pool } from './db.js';import { Invoice } from './entities.js';
export const handler: APIGatewayProxyHandlerV2 = async () => { const invoices = await pool.findMany(Invoice, { $where: { paid: false }, $limit: 20, }); return { statusCode: 200, body: JSON.stringify(invoices) };};A callback-style handler needs one flag that an async one does not: context.callbackWaitsForEmptyEventLoop = false. Otherwise Lambda holds the response until the event loop empties, and a pool with an idle socket in it never empties, so the invocation hangs to its timeout.
If the function sits in a VPC to reach RDS, put RDS Proxy in that VPC too, or the instance count is the connection count.
Vercel
Section titled “Vercel”On Fluid compute an instance handles several concurrent requests and then suspends. attachDatabasePool releases idle clients before that, keeping the connection count proportional to traffic rather than to instance count:
import { attachDatabasePool } from '@vercel/functions';import { PgQuerierPool } from 'uql-orm/postgres';
export const pool = new PgQuerierPool({ connectionString: process.env.DATABASE_URL, max: 5,});
attachDatabasePool(pool.pool);pool.pool is the underlying driver pool. The higher max covers the concurrent requests one instance serves. Keep the default Node.js runtime, since pg needs TCP.
An Astro app on this adapter has a cheaper option above the pool: with cacheVercel() as its cache provider, the repeat request is answered at the edge and never reaches a function or a connection.
uql-orm/neon swaps pg for @neondatabase/serverless, which carries the Postgres protocol over a WebSocket to Neon’s own proxy. That is what makes it work on runtimes where pg will not load at all, and it takes the same PoolConfig:
import { NeonQuerierPool } from 'uql-orm/neon';import './entities.js';
export const pool = new NeonQuerierPool({ connectionString: process.env.DATABASE_URL, max: 2,});Everything above the pool is unchanged: same entities, same queries, same transactions. The driver takes the runtime’s global WebSocket, which Node 24, UQL’s floor, already has. A Neon compute that has scaled to zero still wakes on the first query, so the timeout budget from the first section applies here more than anywhere.
Runtimes where a connection cannot outlive the request
Section titled “Runtimes where a connection cannot outlive the request”Cloudflare Workers, Vercel Edge and Deno Deploy hand out isolates, not processes. A socket opened while serving one request cannot be used to serve the next, and Workers enforces that: touching it from another request’s context throws. Module scope still runs once, so keep configuration and entity registration there, but build the pool inside the handler.
import { PgQuerierPool } from 'uql-orm/postgres';import { Invoice } from './entities.js';
export default { async fetch(request: Request, env: Env, ctx: ExecutionContext) { const pool = new PgQuerierPool({ connectionString: env.HYPERDRIVE.connectionString, max: 5, }); try { const invoices = await pool.findMany(Invoice, { $where: { paid: false }, $limit: 20, }); return Response.json(invoices); } finally { ctx.waitUntil(pool.end()); } },};ctx.waitUntil rather than await: the client is not waiting for a socket to close, so the response should not be either. Which driver goes in there is the real decision, and Hyperdrive is only one answer: D1 and Turso reach their database over fetch(), where a pool holds nothing, end() is a no-op and the whole question disappears.
Serving entities over the HTTP transport works the same way here: the handler takes its pool as an option, and a function form picks one per request, so a Worker that serves a database per tenant needs nothing special.
Cold starts
Section titled “Cold starts”The first request to a new instance pays for the module graph, the first connection, and, on a database that scales to zero, the wake-up. Only the first is yours to shrink: import the entry point you use (uql-orm/postgres, not a barrel that pulls in every dialect) and register only the entities the function needs.