Skip to content

Serverless

A serverless function is a normal Node process with one difference that breaks the usual pooling advice: it is frozen between invocations and killed without warning. Only the pool changes; entities and queries do not.

Every platform reuses a warm instance for consecutive requests, and module scope is evaluated once per instance rather than once per request:

db.ts
import { setQuerierPool } from 'uql-orm';
import { PgQuerierPool } from 'uql-orm/postgres';
import './entities.js';
export const pool = new PgQuerierPool({ connectionString: process.env.DATABASE_URL, max: 2 });
setQuerierPool(pool);

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.

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: 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, which for most runtimes is one request at a time, so 1 or 2. The exceptions are runtimes that multiplex requests onto one instance (Vercel Fluid compute, Lambda with in-handler concurrency), where a handful is better.

When instance count alone can exhaust the server, the fix is a pooler in front of the database, not a smaller max: RDS Proxy or PgBouncer for self-managed Postgres, Supavisor transaction mode, or Hyperdrive on Workers. All are transaction-mode, so session state does not survive between statements: SET LOCAL, advisory locks and temp tables go inside one transaction callback, and migrations run against the direct endpoint from CI.

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.

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.

import type { APIGatewayProxyHandlerV2 } from 'aws-lambda';
import { pool } from './db.js';
import { Order } from './entities.js';
export const handler: APIGatewayProxyHandlerV2 = async () => {
const orders = await pool.findMany(Order, { $where: { status: 'open' }, $limit: 20 });
return { statusCode: 200, body: JSON.stringify(orders) };
};

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, exposed on the pg, mysql2 and mariadb pools for exactly this. Keep the default Node.js runtime: pg needs TCP.

Cloudflare Workers, Vercel Edge and Deno Deploy cannot open a raw socket, so pooling is beside the point: change the driver. D1 or Turso over fetch(), or Postgres through Hyperdrive.

Where a driver holds a socket rather than a pool, it cannot outlive the request that opened it. Build it in the handler and let the response go out first:

export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
const pool = makePool(env);
try {
const products = await pool.findMany(Product, { $limit: 20 });
return Response.json(products);
} finally {
ctx.waitUntil(pool.end());
}
},
};

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.