# Neon Serverless Postgres Architecture: Branching, Scale-to-Zero, and the Lakebase Stack

> Neon lakebase architecture: compute and storage separation, fast cold starts, copy on write branching and serverless Postgres tradeoffs.

- Canonical: https://thearchitecturedeepdive.blog/articles/neon-serverless-postgres-architecture.html
- Series: The Architecture DeepDive, No. 004
- Author: Barnabas Waweru
- Published: 2026-08-10
- Tags: Systems
- Cite as: Barnabas Waweru, "Neon Serverless Postgres Architecture: Branching, Scale-to-Zero, and the Lakebase Stack", The Architecture DeepDive, https://thearchitecturedeepdive.blog/articles/neon-serverless-postgres-architecture.html

---

Neon · Lakebase Postgres

Neon is not a managed Postgres wrapper. It is a full re-architecture of where Postgres lives, how durability works, and what a "database" even means for serverless and edge workloads. The compute is ephemeral. The storage is independent. And branching your database is now as cheap as branching your Git repo.

📅 **August 10, 2026** 🕐 **14 min read** ✍️ **Barnabas Waweru** 🏷 **Systems · Serverless · Postgres**

## What Neon Really Is

### Not Managed Postgres: Redesigned Postgres

Every other "managed Postgres" provider takes a standard Postgres installation and runs it for you inside a VM, with an EBS volume attached and a read-replica or two for HA. Scale means a bigger VM. Backup means a periodic snapshot. Branching is science fiction.

Neon takes a different path entirely. They disassembled Postgres's storage assumptions, replaced them with a cloud-native storage layer, and wrapped the compute in an ephemeral execution environment that can spin up or down in under a second. The result: branching is a metadata operation, scale-to-zero is automatic, and cold starts are measured in milliseconds, not seconds.

Neon calls this the **lakebase architecture**, a category of OLTP database where compute and storage are decoupled, storage is built on object storage for infinite scale, and the database can serve both transactional workloads and analytics without forklift upgrades.

Key Distinction: Neon and Databricks Lakebase

Neon and Databricks run the same underlying database engine, Lakebase Postgres, on the same infrastructure. What differs is the surrounding platform: on Neon it anchors a complete backend for apps and AI agents; on Databricks it integrates with the Data Intelligence Platform for combined OLTP + analytics. The architecture described here applies to both.

#### Compute/Storage Split

Postgres compute is ephemeral. Storage is durable, independent, and replicated by Paxos quorum, never tied to a single VM.

#### Copy-on-Write Branching

Branch your database like a Git repo. Branches are instant, isolated, and carry all parent data without copying a single byte at creation time.

#### Scale to Zero

Idle computes suspend automatically after 5 minutes. Reactivation is sub-second. Pay only for active CU-time.

#### Serverless Driver

HTTP and WebSocket transports replace TCP, enabling Postgres queries from Vercel Edge Functions, Cloudflare Workers, and other edge runtimes that ban raw sockets.

#### 10,000 Connections

PgBouncer-based connection pooling absorbs serverless connection storms. Each invocation doesn't need its own Postgres backend process.

#### pgvector Native

Vector embeddings via the `pgvector` extension. HNSW indexing on up to 2,000 dimensions; switch to `halfvec` for 3072-dim models.

## The Lakebase Architecture: Two Independent Layers

### How Postgres Was Split

Traditional Postgres is a monolith: one process owns the WAL, the buffer manager, the heap files, and the connection sockets. Storage lives on the same server that computes queries. This works until you try to scale elastically, run zero-downtime branching, or pay only for what you use.

Neon solves this with a strict split: a **compute layer** that runs Postgres and is entirely ephemeral, and a **storage layer** that holds all durability and is entirely independent. They communicate via a stream of WAL records over the network. Compute can restart, scale, or disappear, the storage layer doesn't care.

Object storage (S3-compatible) holds the long-term, immutable page history. But critically, **object storage is never on the critical query path**. Latency-sensitive reads stay in the compute layer's RAM and NVMe cache. Object storage only handles writes from the pageserver and long-term recovery.

```
╔══════════════════════════════════════════════════════════════════════════╗ ║ NEON LAKEBASE ARCHITECTURE ║ ╠══════════════════════════════════════════════════════════════════════════╣ ║ ║ ║ CLIENT APPLICATION ║ ║ (Vercel / Cloudflare / Node.js) ║ ║ │ HTTP (neon driver) │ WebSocket (Pool) │ TCP (direct pg) ║ ║ ▼ ▼ ▼ ║ ║ ┌────────────────────────────────────────────────────────────────────┐ ║ ║ │ COMPUTE LAYER (Ephemeral, starts/stops freely) │ ║ ║ │ │ ║ ║ │ ┌──────────────────────────────────────────────────────────┐ │ ║ ║ │ │ Postgres Process (standard SQL, MVCC, query planner) │ │ ║ ║ │ │ RAM: shared_buffers, session state, hot pages │ │ ║ ║ │ │ NVMe: local page cache (avoids network reads on hits) │ │ ║ ║ │ └──────────────────────────────────────────────────────────┘ │ ║ ║ │ │ WAL stream (writes) ↑ Page fetch (cache miss) │ ║ ║ └──────────┼───────────────────────────┼────────────────────────────┘ ║ ║ │ │ ║ ║ ┌──────────┼───────────────────────────┼────────────────────────────┐ ║ ║ │ │ STORAGE LAYER (Durable, always on) │ │ ║ ║ │ ▼ │ │ ║ ║ │ ┌───────────────────┐ ┌──────────┴──────────┐ │ ║ ║ │ │ Safekeepers (3x) │ │ Pageserver │ │ ║ ║ │ │ WAL quorum │───▶│ WAL → page recon. │ │ ║ ║ │ │ Paxos consensus │ │ Serves page reads │ │ ║ ║ │ │ Defines commit │ │ Manages branches │ │ ║ ║ │ └───────────────────┘ └──────────┬──────────┘ │ ║ ║ │ │ async upload │ ║ ║ │ ┌──────────▼──────────┐ │ ║ ║ │ │ Object Storage │ │ ║ ║ │ │ S3 / immutable log │ │ ║ ║ │ │ Long-term history │ │ ║ ║ │ │ NOT on query path │ │ ║ ║ │ └─────────────────────┘ │ ║ ║ └────────────────────────────────────────────────────────────────────┘ ║ ╚══════════════════════════════════════════════════════════════════════════╝
```

### Resource Hierarchy

Neon organizes resources into a logical hierarchy that maps onto the physical architecture. An **Organization** contains one or more **Projects**. Each Project holds **Branches** (copy-on-write database clones). Each Branch has an attached **Compute Endpoint**, a running Postgres instance, plus **Databases** and **Roles**. Branching is a property of the storage layer; computes are attached to branches on demand.

## Safekeepers: WAL Quorum and the Definition of Commit

### Replacing the Filesystem with Consensus

In standard Postgres, a commit is durable when WAL has been flushed to disk. In Neon, the compute node doesn't have persistent disk in the traditional sense. So where does durability come from?

The answer is the **safekeeper fleet**. When a transaction commits, the compute node streams its WAL records to three safekeepers in parallel. A transaction is considered committed only once a quorum of safekeepers (2 of 3) has acknowledged receipt, using the **Paxos consensus protocol**. This is mathematically equivalent to the durability guarantee of flushing to a redundant RAID array, except it's network-distributed and survives the loss of an entire AZ.

1

Transaction Executes

Postgres compute executes the transaction in memory. Writes applied to shared buffers. WAL records generated.

2

WAL Streamed to Safekeepers

Compute node streams WAL records to all three safekeepers simultaneously. Each safekeeper writes to its own durable log.

3

Paxos Quorum Ack

Once 2 of 3 safekeepers acknowledge, the WAL LSN is committed. The compute node sends the commit confirmation to the client.

4

Pageserver Ingests WAL

The pageserver reads from the safekeeper quorum and applies WAL records to reconstruct page versions asynchronously.

5

Object Storage Upload

Pageserver periodically offloads immutable page history to S3-compatible object storage. Not on the commit critical path.

Why Paxos Beats a Local Disk Here

Local disk dies with the server. A Paxos quorum across three safekeepers in three zones is durable even if an entire AZ goes dark. The commit latency tradeoff is real, safekeeper replication adds a network round-trip, but Neon optimizes this with **message pipelining** that batches WAL records before the round-trip, keeping p99 commit latency competitive with single-AZ setups.

## The Pageserver: On-Demand Page Reconstruction

### The Storage Engine Postgres Never Had

Standard Postgres stores heap files as fixed-size pages (8KB by default) on local disk. The buffer manager fetches pages from disk into `shared_buffers`, manages dirty pages, and fsyncs them back. This entire model assumes co-location of compute and storage.

Neon replaces the local disk with the **pageserver**. When a compute node needs a page that isn't in its local RAM or NVMe cache, it sends a page request to the pageserver with the requested page number and LSN. The pageserver reconstructs the correct version of that page by replaying WAL records from the relevant base image forward to that LSN, on demand, in real time.

This is what makes branching possible. A branch is just a pointer to an LSN in the WAL history. When you create a branch, the pageserver doesn't copy any pages. It just records: "branch B diverges from main at LSN X." Subsequent writes to branch B produce their own WAL stream; the pageserver tracks which WAL to apply for which branch independently.

### Read Path: Local Cache First, Pageserver on Miss

The compute node's local NVMe acts as a **hot page cache**. For pages that haven't changed recently (most of a production OLTP database), reads are served at NVMe speed without touching the network. Only cache misses hit the pageserver. This design keeps p99 read latency within a tight band for workloads with reasonable data locality.

-   **L1: RAM (shared\_buffers)**, sub-microsecond, hot working set
-   **L2: Local NVMe**, microseconds, recently accessed pages
-   **L3: Pageserver (network)**, single-digit milliseconds, cold pages
-   **Never: direct S3 read**, object storage never appears on the read critical path

## Scale to Zero: How Cold Starts Actually Work

### The Compute Goes Away. The Database Doesn't.

After 5 minutes of inactivity, Neon suspends the Postgres compute endpoint entirely. The process terminates. RAM is freed. The VM slot is released. From the cloud provider's billing perspective, nothing is running, which is why you pay zero compute cost during the idle period.

The storage layer doesn't care. Safekeepers hold the latest WAL. The pageserver holds its page cache and branch state. Object storage holds the immutable history. All of it continues humming along, waiting for the next query.

When the next query arrives, Neon's control plane spins up a fresh compute node, attaches it to the storage layer for the right branch, and lets Postgres initialize. The compute knows where the WAL left off (the LSN is stored in the storage layer) and picks up without a full base backup restore. Cold start to first query: **a few hundred milliseconds**, not minutes.

Cold Start Gotchas in Serverless Frameworks

Prisma users sometimes see **P1001 connection timeout** errors on the first query after a cold start, the compute is spinning up while the query arrives. Mitigate with:

-   Add `connect_timeout=10` to the connection string as a query param
-   Implement a short exponential-backoff retry at the application layer (2-3 attempts, 200ms initial delay)
-   On paid plans, disable scale-to-zero for production endpoints that can't tolerate the latency spike
-   Scale-to-zero is only available for computes ≤ 16 CU; larger computes remain always active

| Scenario | Compute State | First Query Latency | Notes |
| --- | --- | --- | --- |
| Active (recent queries) | Running | Normal (RAM warm) | No cold start; pages likely cached |
| Scale-to-zero wake | Suspended → Starting | ~200 to 500 ms overhead | Compute restarts; NVMe cold |
| Free plan (forced STZ) | Always suspended when idle | ~200 to 500 ms on wake | Cannot disable; plan accordingly |
| Large compute (>16 CU) | Always active | Normal | Not eligible for scale-to-zero |
| Logical replication active | Kept active | Normal | Active subscribers prevent STZ |

## Branching: Git for Your Database

### Copy-on-Write Without Copying Anything

Database branching is the Neon feature that sounds too good to be true until you understand the architecture. Creating a branch from your production database is an O(1) metadata operation, no data is copied, no dump/restore happens, no seed script runs. The branch is immediately ready with all parent data at the point of creation, and completely isolated for writes.

The mechanism: a branch is a pointer to an LSN in the WAL stream. The pageserver tracks branches as a DAG of LSN ranges. When branch B writes new data, the WAL for those writes is tagged to branch B's WAL stream. When the pageserver serves a read from branch B, it applies: parent's page history up to the branch point LSN, then branch B's own WAL deltas from that point forward.

Parent branches are completely unaffected, zero load, zero performance impact. You can create 10 branches simultaneously on a production database and the production compute won't notice.

### Branch Per Pull Request: The Developer Superpower

The canonical pattern: each GitHub PR gets its own Neon branch via the `neondatabase/create-branch-action` GitHub Action. The branch is spun up when the PR opens, its connection string is injected as a Vercel preview environment variable, and the branch is deleted when the PR closes. Developers get a full copy of the production schema and data, isolated, throwaway, always fresh.

```
# .github/workflows/preview.yml (simplified)
- uses: neondatabase/create-branch-action@v6
  id: neon
  with:
    project_id: ${{ secrets.NEON_PROJECT_ID }}
    api_key: ${{ secrets.NEON_API_KEY }}
    branch_name: preview/pr-${{ github.event.pull_request.number }}

- uses: vercel/action@v1
  with:
    env: DATABASE_URL=${{ steps.neon.outputs.db_url_with_pooler }}

# On PR close — delete the branch
- uses: neondatabase/delete-branch-action@v3
  with:
    project_id: ${{ secrets.NEON_PROJECT_ID }}
    api_key: ${{ secrets.NEON_API_KEY }}
    branch: preview/pr-${{ github.event.pull_request.number }}
```

Schema-Only Branching + Managed Auth Branching

Neon supports **schema-only branches** for sensitive data environments, the branch inherits the schema but not the row data, useful for teams that can't replicate production PII into every developer environment. Additionally, if you're using **Managed Better Auth**, the `neon_auth` schema (users, sessions, auth config) branches with your data automatically, each branch gets isolated authentication state, not shared production auth.

### Migrations: Always Run on the Direct (Unpooled) URL

This is the most common Neon misconfiguration. DDL migrations hold session state. They open a transaction, apply schema changes, and commit. PgBouncer in transaction-pooling mode cannot safely route session-state queries; it may route different statements in the same migration to different backend connections.

-   **Prisma:** set `directUrl = env("DIRECT_URL")` for `prisma migrate`; use pooled `DATABASE_URL` for runtime queries
-   **Drizzle:** point `drizzle.config.ts` at the unpooled URL; runtime queries use pooled
-   **Claude Code / MCP:** use the two-phase pattern: `prepare_database_migration` rehearses on a throwaway branch; `complete_database_migration` runs on main

## The Serverless Driver: Why TCP Doesn't Work at the Edge

### The Edge Runtime Problem

Standard Postgres uses TCP, with a persistent connection handshake that includes auth, parameter negotiation, and session setup. This works fine in a long-lived Node.js process. It breaks completely in edge runtimes (Vercel Edge Functions, Cloudflare Workers) that prohibit raw TCP sockets, have no persistent connections, and may run in dozens of data centers simultaneously.

The `@neondatabase/serverless` driver solves this by replacing TCP with HTTP or WebSockets, both universally available in edge environments. The driver is a GA package (v1.0+), requires Node.js 19+, and ships TypeScript types bundled (no `@types/pg` needed).

| Transport | API | Transactions | Session State | Best For |
| --- | --- | --- | --- | --- |
| HTTP | `neon()` template literal | No (single statements) | No | Edge reads/writes, route handlers, server actions |
| WebSocket | `Pool` / `Client` | Yes | Yes | Multi-statement transactions, node-postgres compat |
| TCP (standard pg) | `pg` Pool/Client | Yes | Yes | Traditional Node.js, non-edge runtimes only |

```
// HTTP transport — edge-safe, lowest cold-start overhead
import { neon } from '@neondatabase/serverless'
const sql = neon(process.env.DATABASE_URL!)

// Template literal — ${}values are auto-parameterized, safe from SQLi
const rows = await sql`
  SELECT id, email FROM users WHERE id = ${userId}
`

// WebSocket transport — for transactions (create Pool inside handler)
import { Pool } from '@neondatabase/serverless'
const pool = new Pool({ connectionString: process.env.DATABASE_URL })
const client = await pool.connect()
try {
  await client.query('BEGIN')
  // ... transaction statements ...
  await client.query('COMMIT')
} finally {
  client.release()
  ctx.waitUntil(pool.end()) // drain after response, don't block
}
```

HTTP Transport Performance: Message Pipelining

The HTTP transport achieves lower cold-start overhead than WebSockets because there's no WebSocket handshake. Neon also applies **message pipelining**, batching multiple Postgres protocol messages into a single HTTP request, which cuts the number of round-trips significantly. For typical single-query route handlers, the neon() function is the right default. Only drop down to `Pool` when you genuinely need multi-statement transactions.

## Connection Pooling: PgBouncer Under the Hood

### Why Serverless Destroys Postgres Connection Limits

Every Postgres connection forks a new OS process. Memory per connection scales linearly. On a 1 CU (1 vCPU, 4GB RAM) Neon compute, `max_connections` is capped at 419. A medium-sized Next.js deployment on Vercel can easily create hundreds of concurrent Lambda invocations, each trying to open its own Postgres connection, slamming into that limit immediately.

Neon's solution: a managed **PgBouncer** instance running in **transaction-pooling mode** sits between client connections and the actual Postgres backend. PgBouncer accepts up to **10,000 client connections** and routes them through a much smaller pool of real Postgres connections. Client connections are held only for the duration of a transaction, then returned to the pool.

```
CLIENT CONNECTIONS (up to 10,000) ┌──────────────────────────────────────────────────────────────────┐ │ Lambda#1 Lambda#2 Lambda#3 ... Lambda#5000 │ │ │ │ │ │ │ └─────┼───────────┼───────────┼────────────────┼──────────────────┘ │ │ │ │ └───────────┴─────┬─────┴────────────────┘ │ ▼ POOLED ENDPOINT (-pooler hostname) ┌─────────────────────────────────────────────────────────┐ │ PgBouncer (transaction-pooling mode) │ │ Per-user, per-database pools │ │ Pool size = 90% of max_connections │ │ SET, TEMP TABLES, LISTEN/NOTIFY → not supported │ └────────────────────────┬────────────────────────────────┘ │ ~419 real connections (1 CU) ▼ POSTGRES BACKEND ┌─────────────────────────────────────────────────────────┐ │ Neon Compute (Postgres process) │ │ max_connections determined by RAM/CU size │ │ 7 connections reserved for Neon superuser │ └─────────────────────────────────────────────────────────┘
```

### Pooled vs Direct URL: The Most Common Neon Mistake

Neon provides two connection string variants. The pooled string adds `-pooler` to the endpoint hostname:

-   **Pooled** (`ep-cool-name-pooler.us-east-2.aws.neon.tech`): Use for all runtime queries in serverless/edge. Handles connection storms. PgBouncer in transaction mode.
-   **Direct / Unpooled** (`ep-cool-name.us-east-2.aws.neon.tech`): Use only for migrations (`prisma migrate`, `drizzle-kit`), `pg_dump`, logical replication, and anything requiring session state (`SET`, `LISTEN`, `PREPARE`).

Transaction-mode pooling specifically **breaks**: `SET` (session params), temporary tables, SQL-level `PREPARE`/`DEALLOCATE`, and `LISTEN`/`NOTIFY`. Protocol-level prepared statements (e.g. libpq extended query protocol) are supported.

## Cost Reality Check

### 💜 Cost Reality Check

Neon bills on two dimensions: **compute units (CU)** and **storage (GiB-month)**. Understanding both prevents bill surprises.

**Compute:** 1 CU = 1 vCPU + 4 GB RAM. You pay per CU-hour of active compute time only. Scale-to-zero means idle databases cost $0 in compute. The Free plan includes 191.9 compute hours/month (equivalent to a 0.25 CU compute running ~32 hours). Paid plans unlock autoscaling, bigger computes, and disabling scale-to-zero.

**Storage:** Billed on logical data size plus WAL history retained for branching and PITR. The Free plan includes 0.5 GiB. Paid plans start with 10 GiB included. Branches are cheap. They only store _deltas_, but long-running branches on active databases accumulate WAL history.

-   **Free Plan:** 191.9 compute hours, 0.5 GiB storage, 10 branches, 1 project, for solo dev and low-traffic apps
-   **Scale Plan ($69/mo base):** 750 compute hours, 50 GiB storage, unlimited projects, autoscaling up to 16 CU
-   **Business Plan ($700/mo base):** 1000 hours, 500 GiB, up to 56 CU, SLA, IP Allow
-   **Branch limit:** Free tier caps at 10 branches per project, delete PR branches on close or you'll hit this
-   **pgvector cost:** `halfvec` (for 3072-dim models like OpenAI `text-embedding-3-large`) stores at half precision, roughly halving storage vs `vector`
-   **Cold start trade-off:** Disabling scale-to-zero on a small CU size costs ~$2 to 5/month per compute endpoint, usually worth it for production

## Key Takeaways

### ✅ Key Takeaways

1.  **The lakebase split is the foundation of every Neon capability.** Compute is ephemeral; storage is durable. Without this separation, branching, scale-to-zero, and instant clones would be impossible or prohibitively expensive.
2.  **Commit durability is defined by safekeeper quorum, not local disk.** Three safekeepers, Paxos consensus, 2-of-3 acknowledgement before commit confirmation. This is why Neon can terminate a compute node mid-flight without data loss.
3.  **The pageserver reconstructs pages on demand, branching is metadata-only.** A branch is a pointer to an LSN. No data is copied at branch creation time. Writes to a branch produce deltas; the parent branch is unaffected in performance and data.
4.  **Use HTTP (`neon()`) for most edge queries; WebSocket (`Pool`) only for transactions.** The neon() function is edge-safe, has the lowest cold-start overhead, and auto-parameterizes template literal values against SQL injection.
5.  **Two connection strings, two purposes, never mix them.** Pooled for runtime queries, direct for migrations and session-state operations. Mixing them causes silent data errors or migration failures.
6.  **Scale-to-zero is a cost lever, not a reliability feature.** It eliminates idle compute cost but adds ~200 to 500ms latency on the first wake query. For production endpoints, evaluate whether the cost saving (typically $2 to 5/month) is worth the latency tradeoff before disabling it.
7.  **Delete PR branches when the PR closes.** Free tier caps at 10 branches per project. Even on paid plans, branches accumulate WAL history over time if they stay open, automate cleanup with `neondatabase/delete-branch-action`.

### Official Documentation

[Neon · Architecture Overview (Lakebase)](https://neon.com/docs/introduction/architecture-overview) [Neon · Branching](https://neon.com/docs/introduction/branching) [Neon · Scale to Zero](https://neon.com/docs/introduction/scale-to-zero) [Neon · Serverless Driver (@neondatabase/serverless)](https://neon.com/docs/serverless/serverless-driver) [Neon · Connection Pooling (PgBouncer)](https://neon.com/docs/connect/connection-pooling) [Neon · How Safekeepers Use Paxos for WAL Quorum](https://neon.com/blog/paxos) [Neon · Serverless Driver Performance: Message Pipelining](https://neon.com/blog/quicker-serverless-postgres) [Neon · pgvector Extension](https://neon.com/docs/extensions/pgvector) [Neon · Read Replicas](https://neon.com/docs/introduction/read-replicas) [Neon · Glossary](https://neon.com/docs/reference/glossary)
