Contents
layout: doc title: PostgreSQL cache benchmarks description: Measured pg_local_cache results with Node.js, Go and RESP on Apple M3 Max. Includes the machine, PostgreSQL CPU, memory and methodology. section: Benchmarks permalink: /docs/BENCHMARKS.html
last_modified_at: “2026-09-16”
PostgreSQL cache benchmarks
Recorded on an Apple M3 Max with PostgreSQL 16. Each comparison uses the same client, dataset and decoded row results for cached and ordinary SQL reads.
Where the cache helped—and where it did not
- Single-key SQL: prepared SQL beat SQL
mgetin both published client setups. At 256 Go connections it returned 253,790 requests/s versus 186,296 formget. Adding a row cache did not improve this SQL workload. - 64-key SQL batches: Go at 256 connections returned 43,647 requests/s
through
mgetversus 27,615 for prepared SQL, about 1.58× throughput. Node.js at 64 connections showed a smaller gain: 16,616 versus 15,577. Batch size and client overhead matter; check CPU and latency as well. - Single-key RESP: Go at 256 connections reached 839,678 requests/s versus the 253,790 SQL baseline. RESP workers use a configured database role and do not share the caller’s SQL transaction or snapshot.
The Node.js measurements use a macOS client and Docker server; Go and RESP measurements put both inside the Docker VM. Each page links raw repetitions, exact versions and server resource costs. These separate setups do not rank languages. For connection examples, see Node.js, Go or RESP.
Run the same comparison on every client
From the repository root, with Docker, Node.js 20+ and Go 1.25+:
./examples/benchmark.sh all > comparison.json
python3 scripts/benchmark_report.py comparison.json
The runner builds a disposable PostgreSQL server and runs this common matrix:
| Setting | Every client and read path |
|---|---|
| Clients | Node.js with node-postgres / node-redis; Go with pgx / standard-library RESP2 |
| Read paths | Prepared SQL ANY, SQL mget, RESP MGET |
| Keys per request | 1, 16, 64; the same fixed keys starting at 1 |
| Connections | 4, 64, 256; persistent, one outstanding request per connection |
| Samples | Three five-second repetitions per case; order rotates |
| Placement | Same separate Linux client container, sharing PostgreSQL’s network namespace |
| Before timing | Connect, compare decoded rows, then warm each connection |
| Result contract | Full JSON rows, input order, duplicates, nulls, missing keys, empty and all-null input |
| Measurements | Requests/s, latency percentiles, client CPU, server CPU/memory, cache counters |
There are 162 samples by default, about 14 minutes of timed work plus setup. The script removes its containers after success or failure. For a short correctness run:
CONNECTIONS=4 BATCHES=1,16,64 REPEATS=1 DURATION_SECONDS=1 \
./examples/benchmark.sh all > smoke.json
Use node or go instead of all to select one client with the same defaults.
CONNECTIONS, BATCHES, REPEATS, DURATION_SECONDS and Go’s GOMAXPROCS
can be overridden. Node.js uses one event-loop thread; Go defaults to eight
threads. Node’s SQL mget wraps the returned array in JSON, while pgx decodes
the PostgreSQL text array. Those client costs stay inside timing.
Identical workloads do not make the protocols interchangeable: RESP workers
use their configured database role and do not join a caller’s SQL transaction
or snapshot. See the RESP contract.
The common comparison measures warm reads. Cold, mixed-read/write and
write-overhead diagnostics remain separate in node-workload.
The published 14–15 September results below predate this common launcher. Their original environments and source revisions remain attached to the data; they are not new measurements from the unified matrix.
Test environment
| Component | Configuration |
|---|---|
| Host | MacBook Pro Mac15,10, Apple M3 Max: 10 performance + 4 efficiency cores, 36 GiB RAM |
| OS | macOS 26.5.2, build 25F84, arm64 |
| Docker VM | Engine 29.7.2, Linux 7.0.12-linuxkit, 14 CPUs, 7.65 GiB RAM; no container CPU or RAM quota |
| PostgreSQL | 16.15, Debian bookworm; 300 connections, 128 MiB shared buffers, 256 MiB /dev/shm |
| Data | 4,096 rows, 128-byte values; 1,024 cache entries; data and WAL on tmpfs |
The client and server share the Mac’s CPUs with ten other development containers. All clients encode requests and decode complete JSON rows, preserving input order, duplicates and missing positions. SQL uses prepared statements; connections, authentication and warmup are outside the timing. There is no TLS or pipelining.
Measurement method
Each connection waits for its response before sending another request:
a closed-loop workload, without correction for coordinated omission.
Query order rotates between repetitions; in-flight requests finish before
timing stops. Read-only comparisons use fixed keys already in the cache.
Recorded SQL plans use items_pkey, with zero shared-block reads.
Server CPU comes from the PostgreSQL container’s cgroup counters. One core means one CPU-second per elapsed second; capacity percentages divide by 14. CPU µs/request divides server CPU time by completed requests. The sampling window includes monitoring work and the short client-reporting gap.
Memory is cgroup memory.current, sampled every 500 ms plus endpoints.
Tables report the median of each repetition’s sampled peak, including shared
memory, tmpfs and page cache; this is not process RSS. JSON files also contain
block I/O, throttling, memory events and SQL state/wait snapshots. Network
counters exclude loopback and therefore omit the VM client’s traffic.
These short, warm-cache runs on a shared laptop are not production-capacity
estimates. Data and WAL use tmpfs with fsync, full_page_writes and
synchronous_commit enabled; disk performance is untested.
A failed run exits nonzero and preserves completed samples; the Markdown
renderer rejects partial results. For exact recorded code, use each JSON’s
harness_ref and extension_ref. Reproduction commands are on the client
pages; results are written to JSON files such as benchmark.json.