Testing Neon Serverless Postgres: Branch-Per-Test Pattern and Cold Start Testing
Neon's branching API makes test isolation cheap — create a branch per PR in seconds, run tests, delete it. But serverless Postgres has specific behaviors around cold starts, connection pooling, and branch state that need explicit testing coverage.
Key Takeaways
Branches are the unit of test isolation. Neon branches are copy-on-write snapshots of the parent database — creating one takes under a second. Use one branch per PR, one branch per test suite, or even one per test run.
Cold start latency is a real user-facing concern. Neon suspends compute after inactivity. The first query after suspension can take 500ms–2000ms. Test this latency explicitly and decide whether to mitigate it with keep-alive pings.
The @neondatabase/serverless driver is not the standard pg driver. It proxies over HTTP/WebSocket, which changes connection semantics — connection pooling, transaction behavior, and timeout handling all differ. Test with the driver your production code uses.
Why Neon Changes How You Test
Neon is serverless Postgres — it looks like Postgres and speaks the Postgres protocol, but the compute suspends when idle and scales down between requests. This creates testing concerns that standard Postgres testing tools don't cover:
- Cold starts — the first connection after suspension is slow. How slow? That depends on your Neon plan and region. You need to measure and decide what your SLA is.
- Branching as isolation — Neon's copy-on-write branches let you create a fresh copy of your production schema in milliseconds. This is the killer feature for testing.
- Connection pooling differences — the
@neondatabase/serverlessdriver uses HTTP and WebSocket under the hood. Pooling works differently thanpg.
Setting Up the Neon Branching API
Get your API key from the Neon console and set it as an environment variable:
export NEON_API_KEY=<span class="hljs-string">"your-api-key"
<span class="hljs-built_in">export NEON_PROJECT_ID=<span class="hljs-string">"your-project-id"The Neon API is a standard REST API. Create a branch:
// test/helpers/neon-branch.ts
const NEON_API = "https://console.neon.tech/api/v2";
export interface Branch {
id: string;
name: string;
connectionString: string;
}
export async function createBranch(name: string, parentBranchId?: string): Promise<Branch> {
const response = await fetch(
`${NEON_API}/projects/${process.env.NEON_PROJECT_ID}/branches`,
{
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.NEON_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
branch: {
name,
parent_id: parentBranchId, // defaults to main if omitted
},
endpoints: [
{
type: "read_write",
},
],
}),
}
);
if (!response.ok) {
throw new Error(`Failed to create branch: ${await response.text()}`);
}
const data = await response.json();
const endpoint = data.endpoints[0];
return {
id: data.branch.id,
name: data.branch.name,
connectionString: `postgresql://${endpoint.host}/neondb?sslmode=require`,
};
}
export async function deleteBranch(branchId: string): Promise<void> {
await fetch(
`${NEON_API}/projects/${process.env.NEON_PROJECT_ID}/branches/${branchId}`,
{
method: "DELETE",
headers: { "Authorization": `Bearer ${process.env.NEON_API_KEY}` },
}
);
}Branch-Per-PR Pattern
In GitHub Actions, create a branch at the start of the workflow and clean it up at the end:
# .github/workflows/test.yml
name: Test
on: [pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Create Neon test branch
id: neon-branch
run: |
BRANCH_NAME="pr-${{ github.event.number }}-$(date +%s)"
RESPONSE=$(curl -s -X POST \
"https://console.neon.tech/api/v2/projects/${{ secrets.NEON_PROJECT_ID }}/branches" \
-H "Authorization: Bearer ${{ secrets.NEON_API_KEY }}" \
-H "Content-Type: application/json" \
-d "{\"branch\":{\"name\":\"$BRANCH_NAME\"},\"endpoints\":[{\"type\":\"read_write\"}]}")
BRANCH_ID=$(echo $RESPONSE | jq -r '.branch.id')
HOST=$(echo $RESPONSE | jq -r '.endpoints[0].host')
echo "branch_id=$BRANCH_ID" >> $GITHUB_OUTPUT
echo "database_url=postgresql://neondb_owner:${{ secrets.NEON_PASSWORD }}@$HOST/neondb?sslmode=require" >> $GITHUB_OUTPUT
- name: Run migrations
run: npx prisma migrate deploy
env:
DATABASE_URL: ${{ steps.neon-branch.outputs.database_url }}
- name: Run tests
run: npm test
env:
DATABASE_URL: ${{ steps.neon-branch.outputs.database_url }}
- name: Delete test branch
if: always()
run: |
curl -s -X DELETE \
"https://console.neon.tech/api/v2/projects/${{ secrets.NEON_PROJECT_ID }}/branches/${{ steps.neon-branch.outputs.branch_id }}" \
-H "Authorization: Bearer ${{ secrets.NEON_API_KEY }}"Testing with the @neondatabase/serverless Driver
The serverless driver proxies Postgres over HTTP and WebSocket. It has different connection semantics than pg:
// src/db.ts
import { neon, neonConfig } from "@neondatabase/serverless";
neonConfig.fetchConnectionCache = true; // reuse HTTP connections
export const sql = neon(process.env.DATABASE_URL!);
// Example query
export async function getUserById(id: string) {
const rows = await sql`SELECT * FROM users WHERE id = ${id}`;
return rows[0] ?? null;
}Test the driver directly in your integration tests:
// test/db.test.ts
import { neon } from "@neondatabase/serverless";
import { createBranch, deleteBranch } from "./helpers/neon-branch";
let branchId: string;
let sql: ReturnType<typeof neon>;
beforeAll(async () => {
const branch = await createBranch(`test-db-${Date.now()}`);
branchId = branch.id;
// The serverless driver takes a full connection string
const url = branch.connectionString.replace(
"postgresql://",
`postgresql://neondb_owner:${process.env.NEON_PASSWORD}@`
);
sql = neon(url);
// Apply schema
await sql`CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`;
}, 30000);
afterAll(async () => {
await deleteBranch(branchId);
});
afterEach(async () => {
await sql`TRUNCATE users`;
});
test("inserts and retrieves a user", async () => {
await sql`INSERT INTO users (email) VALUES ('alice@example.com')`;
const rows = await sql`SELECT * FROM users WHERE email = 'alice@example.com'`;
expect(rows).toHaveLength(1);
expect(rows[0].email).toBe("alice@example.com");
});
test("serverless driver handles transactions", async () => {
// The serverless driver supports transaction() for multi-statement transactions
const { transaction } = await import("@neondatabase/serverless");
const [user, _] = await transaction([
sql`INSERT INTO users (email) VALUES ('bob@example.com') RETURNING *`,
sql`INSERT INTO users (email) VALUES ('carol@example.com') RETURNING *`,
]);
expect(user[0].email).toBe("bob@example.com");
const all = await sql`SELECT COUNT(*) AS count FROM users`;
expect(parseInt(all[0].count)).toBe(2);
});Cold Start Latency Testing
Cold start behavior is unique to serverless databases. Measure it explicitly:
// test/cold-start.test.ts
import { Pool } from "pg";
const COLD_START_BUDGET_MS = 3000; // 3 seconds — adjust to your SLA
test("cold start completes within SLA", async () => {
// Use a fresh branch endpoint that has been idle (or was just created)
const pool = new Pool({
connectionString: process.env.NEON_COLD_BRANCH_URL, // a branch created but never queried
ssl: { rejectUnauthorized: false },
connectionTimeoutMillis: 10000,
max: 1,
});
const start = Date.now();
// This is the cold start query
const { rows } = await pool.query("SELECT 1 AS alive");
const elapsed = Date.now() - start;
expect(rows[0].alive).toBe(1);
expect(elapsed).toBeLessThan(COLD_START_BUDGET_MS);
console.log(`Cold start latency: ${elapsed}ms`);
await pool.end();
}, 15000);
test("warm queries are well under 100ms", async () => {
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl: { rejectUnauthorized: false },
max: 5,
});
// Warm up
await pool.query("SELECT 1");
const timings: number[] = [];
for (let i = 0; i < 10; i++) {
const start = Date.now();
await pool.query("SELECT 1");
timings.push(Date.now() - start);
}
const p99 = timings.sort((a, b) => a - b)[Math.floor(timings.length * 0.99)];
console.log(`Warm query p99: ${p99}ms, all: ${timings.join(", ")}ms`);
expect(p99).toBeLessThan(100);
await pool.end();
});Resetting Branch State Between Test Runs
For test suites that need a clean slate, you have two options:
Option 1: Truncate tables (fast, keeps schema):
// test/helpers/reset.ts
import { Pool } from "pg";
export async function resetDatabase(pool: Pool) {
// Get all tables in public schema, excluding migrations
const { rows } = await pool.query(`
SELECT tablename FROM pg_tables
WHERE schemaname = 'public'
AND tablename NOT IN ('_prisma_migrations', 'schema_migrations')
ORDER BY tablename
`);
if (rows.length === 0) return;
const tableNames = rows.map((r) => `"${r.tablename}"`).join(", ");
await pool.query(`TRUNCATE ${tableNames} RESTART IDENTITY CASCADE`);
}Option 2: Create a new branch for each test file (slowest but cleanest):
// jest.globalSetup.ts
import { createBranch } from "./test/helpers/neon-branch";
import { writeFileSync } from "fs";
export default async function globalSetup() {
const branch = await createBranch(`jest-${process.env.GITHUB_RUN_ID ?? Date.now()}`);
// Write connection string to a temp file for tests to pick up
writeFileSync("/tmp/neon-test-branch.json", JSON.stringify(branch));
process.env.DATABASE_URL = buildConnectionString(branch);
}Connection Pooling Behavior
Neon's connection pooler (PgBouncer) is available at a separate host. Test that your pooler configuration handles concurrent load:
// test/pooler.test.ts
import { Pool } from "pg";
test("connection pooler handles concurrent queries", async () => {
// Neon pooler URL ends in -pooler.region.aws.neon.tech
const pool = new Pool({
connectionString: process.env.NEON_POOLER_URL,
ssl: { rejectUnauthorized: false },
max: 20,
});
const queries = Array.from({ length: 50 }, (_, i) =>
pool.query("SELECT $1::int AS n", [i])
);
const results = await Promise.all(queries);
expect(results).toHaveLength(50);
expect(results.every((r) => r.rows[0].n !== undefined)).toBe(true);
await pool.end();
});
test("pooler mode disallows prepared statements", async () => {
// PgBouncer in transaction mode does not support prepared statements
const pool = new Pool({ connectionString: process.env.NEON_POOLER_URL, ssl: true });
// pg driver uses prepared statements by default — test that it falls back correctly
const { rows } = await pool.query({ text: "SELECT $1::int AS n", values: [42] });
expect(rows[0].n).toBe(42);
await pool.end();
});Branch State Verification
After creating a branch and running migrations, verify the schema is what you expect:
test("branch schema matches expected structure", async () => {
const { rows } = await pool.query(`
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'users'
ORDER BY ordinal_position
`);
const schema = Object.fromEntries(rows.map((r) => [r.column_name, r]));
expect(schema.id.data_type).toBe("uuid");
expect(schema.email.data_type).toBe("text");
expect(schema.email.is_nullable).toBe("NO");
expect(schema.created_at.data_type).toBe("timestamp with time zone");
});This test catches a common failure: migrations that ran fine on a developer's machine but didn't apply correctly to the test branch because of a naming conflict or idempotency issue.
HelpMeTest can run your Neon Postgres integration tests automatically on every pull request — sign up free.