How Neon Works Internally: Pageserver Architecture, Branch-Based Storage, and the Compute-Storage Separation That Makes Serverless Postgres Possible
A deep dive into Neon's serverless Postgres architecture covering the Pageserver's LSN-indexed layer storage, Safekeeper WAL quorum, compute-storage separation with vanilla Postgres, copy-on-write branching, autosuspend mechanics, and production considerations for connection pooling and cost control.
Most databases treat compute and storage as a single unit. The server that runs your queries is the same server that holds your data files. That coupling keeps things simple but creates a hard constraint: you cannot scale one without the other, and idle compute still costs you money even when no queries are running. Neon breaks that coupling entirely. Its architecture separates Postgres compute nodes from a custom storage layer, adds a quorum-based WAL service in between, and builds a branching model on top of page-level versioned storage. Understanding how those pieces fit together explains both why Neon can offer serverless Postgres and what you actually give up to get it.
The Core Separation: Compute vs Storage
In standard Postgres, the postmaster owns the data directory. It reads and writes 8 KB pages directly to disk. Checkpoints flush dirty buffer pool pages to that directory. WAL is written locally and optionally streamed to replicas. The storage and the process are colocated and tightly coupled.
Neon replaces that local disk with a remote storage layer. The compute node still runs a mostly unmodified Postgres binary, but when Postgres needs a page, it calls out to the Pageserver over the network instead of reading from a local file. When Postgres writes WAL, it streams that WAL to the Safekeeper quorum instead of appending to a local WAL segment.
The result is that compute nodes become stateless. They hold a buffer pool (in-memory, as always) but own no durable data. You can suspend a compute node when it is idle, spin up a new one when traffic arrives, and both nodes connect to the same underlying storage. That is the foundation of serverless behavior.
Safekeeper: WAL Durability Before the Pageserver
Before a transaction commits in standard Postgres, WAL must be flushed to durable storage. In Neon, that durability comes from the Safekeeper tier rather than a local disk.
A Safekeeper is a lightweight process whose only job is to receive WAL from the compute node, persist it, and acknowledge receipt. Neon runs three Safekeepers per tenant, and the compute node uses a Paxos-based protocol (derived from the Raft family) to ensure that WAL is durable on a majority quorum before returning success to the client. Two out of three is sufficient.
// Simplified representation of a WAL record acknowledged by Safekeepers
interface WalRecord {
lsn: bigint; // Log Sequence Number: byte offset in the WAL stream
data: Uint8Array; // Raw WAL bytes
timelineId: string; // Which branch this WAL belongs to
tenantId: string;
}
interface SafekeeperAck {
lsn: bigint; // Highest LSN persisted on this Safekeeper
nodeId: string;
}
// Compute waits for acks from at least (quorum = floor(n/2) + 1) Safekeepers
function computeQuorumLsn(acks: SafekeeperAck[], quorum: number): bigint {
const sorted = acks.map((a) => a.lsn).sort((a, b) => (a < b ? 1 : -1));
return sorted[quorum - 1] ?? 0n;
}
The Pageserver also tails WAL from the Safekeepers asynchronously. Once the Pageserver has processed WAL up to a given LSN, the Safekeepers can trim the corresponding WAL segments. This means Safekeepers are not long-term storage. They hold only the recent tail of the WAL stream, enough to survive a short Pageserver outage. The authoritative historical record lives in object storage (S3-compatible) via the Pageserver.
Pageserver: LSN-Indexed Layer Storage
The Pageserver is the most architecturally interesting component. Its job is to answer one question: given a page number and an LSN, what does that page look like? This is the fundamental primitive that enables both branching and point-in-time restore.
Internally, the Pageserver stores data in immutable layers. Each layer covers a range of page numbers (key range) and a range of LSNs. There are two layer types:
- Image layers: a snapshot of all pages in the key range at a specific LSN. Dense, cheap to read, expensive to produce.
- Delta layers: a set of WAL records in the key range between two LSNs. Sparse, cheap to append, require replay to reconstruct a page.
When a compute node requests a page at a given LSN, the Pageserver walks its layer map to find the most recent image layer for that page below the requested LSN, then replays any delta layers above that image up to the target LSN. The result is the exact byte content of that 8 KB Postgres page.
interface Layer {
type: "image" | "delta";
keyRange: { start: number; end: number }; // page number range
lsnRange: { start: bigint; end: bigint }; // LSN range
fileRef: string; // path in object storage
}
interface LayerMap {
layers: Layer[];
}
function getPage(
map: LayerMap,
pageNumber: number,
targetLsn: bigint
): { baseImage: Layer; deltas: Layer[] } {
// Find the most recent image layer covering this page below targetLsn
const images = map.layers
.filter(
(l) =>
l.type === "image" &&
l.keyRange.start <= pageNumber &&
l.keyRange.end > pageNumber &&
l.lsnRange.end <= targetLsn
)
.sort((a, b) => (a.lsnRange.end > b.lsnRange.end ? -1 : 1));
const baseImage = images[0];
// Find all delta layers above the base image up to targetLsn
const deltas = map.layers.filter(
(l) =>
l.type === "delta" &&
l.keyRange.start <= pageNumber &&
l.keyRange.end > pageNumber &&
l.lsnRange.start >= baseImage.lsnRange.end &&
l.lsnRange.end <= targetLsn
);
return { baseImage, deltas };
}
The Pageserver writes new delta layers as WAL arrives from the Safekeepers. Periodically, a background compaction process merges delta layers into image layers and evicts superseded deltas, keeping the layer map from growing unbounded and bounding the read amplification on page lookups.
The cold layer files live in object storage. The Pageserver maintains a local SSD cache of recently accessed layers. A cache miss causes the Pageserver to download the layer file from S3 before serving the page. This is the primary source of Neon’s cold-start latency beyond compute startup time.
Compute Nodes: Vanilla Postgres with a Custom Storage Backend
The compute node is Postgres with one significant patch: the smgr (storage manager) interface is replaced with a network client that talks to the Pageserver. The smgr API in Postgres is the abstraction between the buffer manager and the actual page storage. Replacing it is the minimal invasive change needed to redirect all page I/O to a remote service.
When a Postgres buffer pool miss occurs, instead of reading from a local file descriptor, the patched smgr sends a GetPage@LSN RPC to the Pageserver. The LSN attached to the request is the current xlog_insert_lsn, ensuring the Pageserver returns a view of the page consistent with the current transaction’s snapshot.
// Simplified GetPage request sent from compute to Pageserver
interface GetPageRequest {
tenantId: string;
timelineId: string; // Neon's term for a branch
pageNumber: number;
lsn: bigint; // Must see state as of at least this LSN
}
interface GetPageResponse {
page: Uint8Array; // 8192 bytes: one Postgres page
}
WAL is streamed from the compute node to the Safekeeper trio using the standard Postgres streaming replication protocol. The Safekeepers present themselves as replicas. This means no patching is needed on the WAL sender side of Postgres.
Checkpoints still run in the compute node, but they do not flush pages to disk. Instead, they advance a checkpoint LSN that the Pageserver uses to know which delta layers can be safely consolidated into image layers. The local buffer pool is the only mutable state on the compute node.
Branching: Copy-on-Write at the LSN Level
The branching model is a direct consequence of how the Pageserver stores data. Every branch is a timeline identified by a UUID. A branch is nothing more than a pointer to a parent timeline and a branch LSN: the point in the parent’s history at which the branch diverges.
When you create a branch at a given LSN, the Pageserver does not copy any data. The child timeline simply inherits the parent’s layer files up to the branch LSN. The layer map for the child timeline includes all parent layers below the branch point. New writes on the child create new delta layers that are scoped to the child’s timeline ID. The parent is unaffected.
parent timeline: [image@0] ---- [delta 0→100] ---- [delta 100→200] ----►
▲
branch at LSN 200 │
child timeline: [delta 200→250] ----►
The Pageserver serves a page on the child timeline by looking at child-scoped layers first, then falling back to parent layers for the LSN range below the branch point. This lookup is recursive: if the parent is itself a branch of another timeline, the chain continues.
This model makes branching instantaneous regardless of database size. Creating a 100 GB branch takes milliseconds because no data is copied. The branch shares the underlying layer files with its ancestor until it diverges. This is the same principle as filesystem copy-on-write (ZFS, Btrfs) but applied at the database storage layer with LSN semantics rather than block addresses.
A common use case is per-pull-request preview databases. A CI pipeline creates a branch from main, runs migrations on it, runs tests, and deletes the branch when the PR closes. The branch costs nothing in storage until data is actually modified on it.
Autosuspend and Autoscale
Neon’s serverless compute behavior comes from two separate mechanisms: autosuspend and autoscale.
Autosuspend is straightforward. After a configurable idle period (the default is 5 minutes, the minimum is 1 minute on paid plans), the Neon control plane instructs the compute node to shut down. The shutdown is clean: Postgres runs a checkpoint, WAL is flushed to the Safekeepers, and the process exits. Because all durable state lives in the Pageserver, nothing is lost. When a new connection arrives, the control plane boots a new compute node, which connects to the same Pageserver timeline and resumes from the last checkpoint LSN.
The cold start penalty is the sum of: VM provisioning time, Postgres startup time, and the time to warm the buffer pool for the first few queries. Neon pre-provisions a pool of ready VMs to cut the first component down to roughly 500ms. On short queries with warm Pageserver caches, total cold start latency is typically 1-3 seconds. On large databases where the Pageserver cache is cold and the first query touches many pages, it can be higher.
Autoscale adjusts CPU and memory allocation dynamically without restarting the compute node. Neon uses a virtual machine monitor that can resize the VM’s memory allocation while Postgres is running. Postgres sees more or less addressable RAM. The buffer pool expands or shrinks accordingly. This avoids the overhead of a full restart when load increases.
// Autoscale signal: the autoscaler polls compute metrics and adjusts allocation
interface ComputeMetrics {
bufferCacheHitRate: number; // Low hit rate = more memory needed
cpuUsagePercent: number;
activeConnections: number;
lsnApplyLag: bigint; // How far behind the Pageserver this compute is
}
interface ScaleDecision {
targetMemoryMb: number;
targetCpuMillicores: number;
}
function shouldScaleUp(metrics: ComputeMetrics, current: ScaleDecision): boolean {
return (
metrics.bufferCacheHitRate < 0.85 ||
metrics.cpuUsagePercent > 80
);
}
Production Considerations
Connection pooling is not optional. Postgres connection limits apply to the compute node. Each connection consumes memory and a backend process. When the compute is suspended and resumes, those connections are gone. Applications must use a connection pooler (Neon ships PgBouncer as a managed sidecar) rather than long-lived connections to the compute node directly. Transaction-mode pooling works well for most OLTP workloads but breaks any code that relies on session-level state (temporary tables, advisory locks, SET variables).
Branch cleanup compounds storage costs. Branches are cheap to create but not free to maintain indefinitely. Each branch’s delta layers accumulate as writes happen. If you create many preview branches and never delete them, layer compaction runs across an expanding tree of timelines. Set automated branch expiry in your CI pipelines.
Read replica branches are not the same as Postgres replicas. A read-only compute node pointing at the same timeline is not a streaming replica in the traditional sense. It reads pages from the Pageserver, which has its own LSN cursor. There is no direct WAL stream between the primary compute and the read compute. Replication lag is the gap between the Pageserver’s WAL apply position and the primary’s current WAL position.
Cold starts are workload-dependent. A database with a 2 GB working set that fits entirely in the Pageserver’s in-memory cache and local SSD will have sub-second cold starts after the first warmup. A database with a 200 GB working set where the first query needs pages scattered across many layer files will have much higher cold start latency. Set autosuspend_delay_ms to a conservative value for latency-sensitive workloads.
WAL volume drives Safekeeper and object storage costs. Neon charges for storage and compute separately. Write-heavy workloads generate more WAL, which means more delta layers, more compaction work, and more object storage. Bulk loads should be batched and followed by a manual VACUUM to produce compact image layers rather than a long chain of deltas.
Point-in-time restore is inherent, not a backup feature. Because the Pageserver retains historical layers, you can create a branch at any LSN within the retention window without running a separate backup. The retention window is configurable. The tradeoff is storage cost: longer retention means more historical layers.
Tradeoffs
| Property | Neon | RDS / Aurora | Self-Hosted Postgres | PlanetScale (MySQL) |
|---|---|---|---|---|
| Storage model | Remote Pageserver, S3 layers | EBS / Aurora shared storage | Local disk or network volume | Vitess shards on local storage |
| Compute-storage coupling | Fully decoupled | Partially (Aurora) or fully coupled (RDS) | Tightly coupled | Tightly coupled |
| Cold start | 500ms-3s depending on cache | N/A (always-on) | N/A | N/A |
| Branching | Instant copy-on-write at LSN | Manual snapshot (minutes) | pg_basebackup (minutes to hours) | Not available |
| Connection pooling | PgBouncer managed sidecar | RDS Proxy (extra cost) | Self-managed | Built-in |
| Point-in-time restore | Inherent (branch at any LSN) | Automated backups (5-minute granularity) | Manual WAL archiving | Limited |
| Idle cost | Zero (autosuspend) | Minimum instance running | Instance running | Minimum instance running |
| Replication lag (read replicas) | Pageserver WAL apply lag | Standard streaming replication | Standard streaming replication | Vitess row-based replication |
| Wire protocol | Standard Postgres | Standard Postgres | Standard Postgres | MySQL |
| Open source | Neon codebase is open | Closed | Postgres is fully open | Vitess is open, PlanetScale is not |
Where the Model Falls Short
Neon’s architecture optimizes for serverless economics and developer workflow. It is not the right fit for every workload. The network round-trip on every buffer pool miss adds latency that is invisible on local-disk Postgres. Under sustained, high-throughput OLTP with a large working set and a hot buffer pool, a well-tuned local Postgres instance will outperform Neon compute because the local read path is a memory copy rather than a network call. The Pageserver introduces a second hop on cache misses, and that hop has a floor even on the fastest network.
The branching and autosuspend features matter most for teams with variable workloads, development and staging environments, or CI pipelines that need isolated database state per branch. For a continuously-loaded production database with predictable traffic, the traditional approach of keeping a tuned Postgres instance running with a local or network-attached volume remains simpler to reason about.
What Neon demonstrates is that compute-storage separation is not just a cloud vendor packaging trick. It requires rebuilding the storage manager interface, redesigning durability to route through a WAL quorum service, and building a versioned storage layer that can answer historical page queries. Those are non-trivial system design choices with real tradeoffs, and understanding them determines when the architecture actually earns its complexity.
More in System Design
How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL
A deep dive into Aurora's storage-compute separation, the log-is-the-database design that pushes redo log processing to storage nodes, quorum-based replication across 6 copies in 3 AZs, protection group architecture, fast cloning, Serverless v2 scaling mechanics, and honest tradeoffs versus RDS, self-managed PostgreSQL, CockroachDB, and Cloud Spanner.
How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale
A deep dive into CockroachDB's internals: the 512MB range-based data model with automatic splitting, per-range Raft replication, MVCC timestamp ordering with hybrid logical clocks, DistSQL physical planning, serializable transactions with timestamp refreshes, closed timestamps for follower reads, online schema changes using multi-version schema descriptors, and production considerations for hotspots and write amplification.
How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation
Containers are not virtual machines and they are not magic. They are a thin composition of Linux kernel primitives: namespaces, cgroups, and a layered filesystem. This article traces exactly what happens between docker run and a running process, covering the OCI spec, containerd, runc, overlay filesystems, and container networking at the kernel level.
How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally
A deep dive into YugabyteDB internals covering the DocDB storage engine built on RocksDB LSM trees, per-tablet Raft consensus, YSQL and YCQL dual query layers, distributed MVCC with hybrid logical clocks, automatic tablet splitting and rebalancing, xCluster multi-region replication, and production considerations for hotspots, connection pooling, and schema design.