Neon Serverless Postgres — Database Branching, Scale-to-Zero, and When to Use It

Sanjeev SharmaSanjeev Sharma
10 min read

Advertisement

Introduction

Neon is PostgreSQL-as-a-service with branching that enables preview databases per PR and scale-to-zero for development environments. Yet the cold start penalty and pricing model surprise teams unfamiliar with serverless databases. This post covers what Neon does well, its trade-offs, and how it compares to managed PostgreSQL and PlanetScale.

Neon's Branching Model

Neon enables git-like branches: instant database clones for previews:

#!/bin/bash

# Neon CLI: create branch from main
neon branch create preview-feature-x --from main

# Creates:
# - New PostgreSQL instance with main's data snapshot
# - Isolated connection string
# - Instant (100ms), no data copy needed
# - Auto-deleted after 7 days (or manual delete)

# Preview environment benefits
# - Each PR gets own database
# - No test data contamination
# - Parallel testing (10 PRs = 10 databases)
# - Realistic schema and data for E2E tests

# In CI/CD pipeline
BRANCH_NAME=$(git branch --show-current)
NEON_BRANCH=$(neon branch create "$BRANCH_NAME" --from main --format json | jq -r '.id')
NEON_DATABASE_URL=$(neon connection_string "$NEON_BRANCH")

# Set environment variable for tests
export DATABASE_URL="$NEON_DATABASE_URL"

# Run tests
npm test

# Cleanup (automatic after 7 days)
neon branch delete "$NEON_BRANCH"

Branching solves the preview environment database problem: instant, isolated, auto-cleanup.

Scale-to-Zero Cold Starts

Neon scales compute to zero when unused, saving cost at the trade-off of latency:

// Cold start scenario:
// 1. Function idles for 30 minutes
// 2. Request comes in
// 3. Cold start: database compute boots (2-5 seconds)
// 4. Query executes
// 5. Response sent (total 3-6 seconds)

// Without cold starts (always-warm), requests are sub-100ms
// With cold starts, cold requests are 2-5 seconds

// Neon cold start typical timeline:
// 0ms: Request received
// 500ms: Compute container boots
// 1500ms: PostgreSQL starts
// 2000ms: Database ready, query can execute
// 2100ms: Query result returned
// Total: ~2.1 seconds (vs 100ms warm)

// Strategies to minimize cold start impact:

// 1. Keep connection warm with pings
const pool = new Pool({
  idleTimeoutMillis: 30000,  // Don't close connections under 30s idle
});

setInterval(async () => {
  try {
    const client = await pool.connect();
    await client.query('SELECT 1');
    client.release();
  } catch (err) {
    console.warn('Connection keep-alive ping failed:', err.message);
  }
}, 20000);  // Ping every 20 seconds

// 2. Use connection pooling (Neon serverless driver)
import { neon } from '@neondatabase/serverless';

const sql = neon(process.env.DATABASE_URL);

// Serverless driver reuses connections across requests
export async function handler(event) {
  const result = await sql`SELECT * FROM users LIMIT 1`;
  return { statusCode: 200, body: JSON.stringify(result) };
}

// 3. Accept cold start latency for non-critical operations
// Critical path: warm pools or always-on servers
// Analytics/background: accept 2-5s latency

// 4. Configure Neon for faster cold starts
// Neon settings:
// - Smaller compute size (0.5 vCPU = faster boot than 2 vCPU)
// - Fewer connections (connection overhead on boot)
// - Simpler schema (faster schema loading)

Cold starts are acceptable for background jobs, acceptable-to-bad for critical user-facing paths.

Connection Pooling with Neon Serverless Driver

Neon's serverless driver solves the connection explosion problem in serverless:

// PROBLEM WITHOUT POOLING:
// 100 concurrent Lambda functions = 100 connections
// PostgreSQL max_connections = 100
// 100% capacity used immediately

// SOLUTION: Neon serverless driver
import { neon } from '@neondatabase/serverless';

const sql = neon(process.env.DATABASE_URL, {
  fetchConnectionCache: true,  // Reuse connections
});

// Lambda 1
export async function handler1(event) {
  const result = await sql`SELECT * FROM users`;
  return result;
}

// Lambda 2 (same pool)
export async function handler2(event) {
  const result = await sql`SELECT * FROM orders`;
  return result;
}

// Serverless driver automatically:
// - Multiplexes 1000s of connections to 10-100 physical connections
// - Caches connections between invocations
// - Closes stale connections
// - Handles disconnects transparently

// Configure connection limits
const sql = neon(process.env.DATABASE_URL, {
  fetchConnectionCache: true,
  maxConnections: 50,  // Max physical connections to PostgreSQL
  connectionTimeoutMillis: 5000,  // Timeout waiting for connection
});

// Comparison: Prisma Data Proxy vs Neon Serverless Driver
// Both solve the same problem (connection pooling for serverless)
// Prisma: language-agnostic, integrates with Prisma ORM
// Neon: tighter integration, native PostgreSQL protocol

Serverless driver enables 1000+ concurrent Lambda functions with minimal connections.

Branching for Preview Environments

Real-world CI/CD integration:

# GitHub Actions example
name: Create Neon Preview Database

on:
  pull_request:
    types: [opened, synchronize]

jobs:
  setup-database:
    runs-on: ubuntu-latest
    outputs:
      database-url: ${{ steps.neon.outputs.database-url }}
    steps:
      - name: Create Neon branch
        id: neon
        run: |
          BRANCH_NAME="preview-${{ github.event.pull_request.number }}-${{ github.run_number }}"

          # Create branch from main
          BRANCH_ID=$(curl -X POST \
            -H "Authorization: Bearer ${{ secrets.NEON_API_KEY }}" \
            -H "Content-Type: application/json" \
            -d "{
              \"branch\": {
                \"name\": \"$BRANCH_NAME\",
                \"parent_id\": \"main\"
              }
            }" \
            https://api.neon.tech/v2/projects/${{ secrets.NEON_PROJECT_ID }}/branches \
            | jq -r '.branch.id')

          # Get connection string
          DATABASE_URL=$(curl -X GET \
            -H "Authorization: Bearer ${{ secrets.NEON_API_KEY }}" \
            https://api.neon.tech/v2/projects/${{ secrets.NEON_PROJECT_ID }}/branches/$BRANCH_ID/connection_string \
            | jq -r '.connection_string')

          echo "database-url=$DATABASE_URL" >> $GITHUB_OUTPUT

  test:
    needs: setup-database
    runs-on: ubuntu-latest
    env:
      DATABASE_URL: ${{ needs.setup-database.outputs.database-url }}
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
      - run: npm install
      - run: npm test

  cleanup:
    if: always()
    needs: setup-database
    runs-on: ubuntu-latest
    steps:
      - name: Delete Neon branch
        run: |
          BRANCH_NAME="preview-${{ github.event.pull_request.number }}-${{ github.run_number }}"

          # Get branch ID
          BRANCH_ID=$(curl -X GET \
            -H "Authorization: Bearer ${{ secrets.NEON_API_KEY }}" \
            https://api.neon.tech/v2/projects/${{ secrets.NEON_PROJECT_ID }}/branches \
            | jq -r ".branches[] | select(.name == \"$BRANCH_NAME\") | .id")

          # Delete branch
          curl -X DELETE \
            -H "Authorization: Bearer ${{ secrets.NEON_API_KEY }}" \
            https://api.neon.tech/v2/projects/${{ secrets.NEON_PROJECT_ID }}/branches/$BRANCH_ID

Branching automation provides isolated preview databases per PR.

Point-in-Time Restore

Neon's underlying architecture enables instant backup recovery:

#!/bin/bash

# Neon PITR: restore to any point in last 7 days
# (or 30/60 days on higher plans)

# List available restore points
neon branch get main --format json | jq '.branch'

# Restore to 2 hours ago
neon branch create restore-point-2h-ago \
  --from main \
  --backup-point "2h"  # 2 hours ago

# Restore to specific timestamp
neon branch create restore-point-2026-03-15 \
  --from main \
  --backup-point "2026-03-15T14:30:00Z"

# Create database before problematic migration
BACKUP_BRANCH=$(neon branch create backup-before-migration --from main)

# Run migrations on main
flyway migrate

# If migration fails, restore from backup
neon branch create restored-main --from backup-before-migration

# PITR vs traditional backups:
# Traditional: write full backup daily (hours) + incremental backups
# Neon PITR: continuous WAL archival, instant restore to any point
# Recovery time: 2-10 seconds (clone a branch)

PITR enables safe experimentation: backup before changes, restore instantly if needed.

Cost Analysis: Neon vs PlanetScale vs Supabase

Compare pricing and features:

┌─────────────────┬──────────────┬──────────────┬──────────────┐
FeatureNeonPlanetScaleSupabase├─────────────────┼──────────────┼──────────────┼──────────────┤
DatabasePostgreSQLMySQLPostgreSQLBranchingYes (free)NoNoScale-to-zero   │ YesNoNoPITR7-60 days    │ No (binary)LimitedPrice/month     │ $20+         │ $28+         │ $25+Storage         │ $0.12/GB     │ $1.00/GB     │ $2.50/GBComputeAuto-scale   │ FixedFixedCold start      │ 2-5s         │ <100ms       │ <100ms       │
Max connections │ UnlimitedUnlimitedUnlimited (pooled)      (native)      (pooled)└─────────────────┴──────────────┴──────────────┴──────────────┘

Cost example (1TB storage, 100 req/sec average):

Neon:
- Storage: 1000 GB * $0.12 = $120/month
- Compute: auto-scale 0.5-4 vCPU = $20-80/month
- Branching: free (includes preview environments)
- Total: $140-200/month

PlanetScale:
- Storage: 1000 GB * $1.00 = $1000/month
- Compute: 2x DigitalOcean: $980/month
- Total: $1980/month (10x more expensive for scale-to-zero)

When Neon is cheaper:
- Development/preview environments (branching free)
- Bursty workloads (scale-to-zero saves)
- Large storage (cheaper per GB)
- Low query throughput

When PlanetScale is better:
- Always-on production (no cold starts)
- MySQL required
- Complex transactions (MySQL better ACID)
- High write throughput (better connection handling)

Neon excels for development and low-traffic production. PlanetScale better for always-on, high-traffic.

When to Use Neon

Ideal use cases:

// GOOD for Neon:

// 1. Staging/preview environments
// - Branching creates isolated test databases
// - Auto-delete after 7 days (no manual cleanup)
// - Free (included in plan)
const sql = neon(process.env.STAGING_DATABASE_URL);

// 2. Development (scale-to-zero saves cost)
// - Developers don't run always-on PostgreSQL
// - Each developer gets own branch
// - Reset to main in seconds
const devDb = neon(process.env.DEV_DATABASE_URL);

// 3. Low-traffic background jobs
// - Analytics, batch processing
// - Cold start latency acceptable
// - Irregular usage (scale-to-zero saves)
const analyticsDb = neon(process.env.ANALYTICS_DATABASE_URL);

// 4. Bursty production traffic
// - Traffic spikes 10x normal
// - Database scales automatically
// - No over-provisioning
const spikyDb = neon(process.env.PROD_DATABASE_URL);

// BAD for Neon:

// 1. Customer-facing APIs expecting <100ms latency
// - Cold starts add 2-5 seconds
// - Unacceptable for web/mobile
// const userDb = neon(process.env.API_DATABASE_URL);  // DON'T DO THIS

// 2. MySQL required (Neon is PostgreSQL only)
// - Existing MySQL application
// - Use PlanetScale instead

// 3. Always-on, predictable load
// - No scaling benefit
// - Always-warm servers (no cold starts)
// - Managed PostgreSQL (RDS, Azure) cheaper

// Best practice: hybrid approach
// - Production API: PlanetScale MySQL (always-on, fast)
// - Staging: Neon branch (isolated, free)
// - Analytics: Neon (cold start acceptable)
// - Development: Neon (free branching)

Neon Configuration Best Practices

// .env
NEON_DATABASE_URL="postgresql://user:password@ep-name.neon.tech/dbname"
NEON_SERVERLESS_DRIVER=true

// Configure Neon for production
const sql = neon(process.env.NEON_DATABASE_URL, {
  // Connection pooling
  fetchConnectionCache: true,
  maxConnections: 100,
  connectionTimeoutMillis: 5000,

  // Timeout settings
  queryTimeoutMillis: 30000,
  idleTimeoutMillis: 60000,

  // SSL
  ssl: true,
});

// Handle cold start gracefully
export async function apiHandler(event) {
  try {
    const startTime = Date.now();
    const result = await sql`SELECT * FROM users LIMIT 1`;
    const duration = Date.now() - startTime;

    // Log cold starts (>1000ms)
    if (duration > 1000) {
      console.warn(`Cold start detected: ${duration}ms`);
    }

    return { statusCode: 200, body: JSON.stringify(result) };
  } catch (err) {
    console.error('Database error:', err);
    return { statusCode: 500, body: 'Internal error' };
  }
}

Neon Checklist

  • Neon evaluated for preview environments (branching saves setup)
  • Cold start impact measured for production workloads
  • Serverless driver configured for Lambda/Vercel (connection pooling)
  • PITR window verified (7/30/60 days for your plan)
  • Backup branches created before schema migrations
  • Scale-to-zero enabled for non-critical environments
  • Monthly cost calculated and compared to alternatives
  • Always-on compute reserved for critical paths (if needed)
  • Replication lag monitored (Neon handles transparently)
  • Metrics tracked: cold start frequency, query latency, connection count

Conclusion

Neon solves specific problems brilliantly: preview environment provisioning via branching, cost savings through scale-to-zero, and instant PITR. However, cold starts (2-5s) disqualify it for latency-sensitive customer-facing APIs. The ideal architecture: Neon for development, staging, and analytics; managed PostgreSQL or PlanetScale for production APIs. Branching is Neon's killer feature—use it to give every PR its own database, eliminating test data conflicts and environment setup complexity.

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro