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 mget in both published client setups. At 256 Go connections it returned 253,790 requests/s versus 186,296 for mget. Adding a row cache did not improve this SQL workload.
  • 64-key SQL batches: Go at 256 connections returned 43,647 requests/s through mget versus 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.