Testing Neon Serverless Postgres: Branch-Per-Test Pattern and Cold Start Testing

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:

  1. 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.
  2. 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.
  3. Connection pooling differences — the @neondatabase/serverless driver uses HTTP and WebSocket under the hood. Pooling works differently than pg.

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.

Read more