← posts / ai integrations

Postgres feels slow on your VPS? Benchmark the disk before you touch postgresql.conf

ClickHouse measured the same Postgres box at 16,030 TPS on local NVMe and 1,734 TPS on a baseline gp3 volume, with most of the gap spent waiting on storage. A 15-minute fio and pg_test_fsync check tells you which side of that table your VPS is on.

if.codesOct 13, 2026 · 5 min read#postgres#self-hosting#performance#databasesAI-assisted

When Postgres gets slow, the usual reflex is to open postgresql.conf and start turning knobs: shared_buffers, work_mem, effective_io_concurrency. Sometimes that helps. But if the database spends its time waiting for the disk, no setting will make the disk faster. Before you tune anything, measure the storage underneath it. It takes about 15 minutes.

What ClickHouse measured

In a recap of their POSETTE 2026 talk, ClickHouse ran the same pgbench workload on two kinds of storage. The machines were identical m6id.4xlarge instances (16 vCPUs, 64 GiB RAM, shared_buffers set to 16 GiB). Half the clusters used the instance's local NVMe; the other half used a baseline EBS gp3 volume provisioned at 3,000 IOPS. The data set was a 482 GiB pgbench_accounts table with 3.3 billion rows, far bigger than RAM, and the workload was 64 clients running a random single-row update for five minutes:

Storage hardware differences can create massive performance gaps in database throughput.
Storage hardware differences can create massive performance gaps in database throughput.
UPDATE pgbench_accounts SET abalance = abalance + 1 WHERE aid = :aid;

Same CPU, same memory, same Postgres. The only difference was the disk, and the result was not close:

0 TPS5,000 TPS10,000 TPS15,000 TPS20,000 TPSLocal NVMe · Median TPS: 16,030 TPS16,030 TPSLocal NVMeEBS gp3 · Median TPS: 1,734 TPS1,734 TPSEBS gp3
pgbench median throughput: local NVMe vs EBS gp3 (3,000 IOPS) Source: ClickHouse, Postgres isn’t slow, your storage is
pgbench median throughput: local NVMe vs EBS gp3 (3,000 IOPS)
Median TPS
Local NVMe16,030 TPS
EBS gp31,734 TPS

NVMe ran 9.24 times more transactions per second, and median latency dropped from 36.9 ms to 4.0 ms. Maintenance work moved too: a VACUUM of 10 GB of bloat took 366 seconds on NVMe and 964 on EBS, and draining 10 GB of logical replication took 139 versus 238 seconds.

The useful part is where those 36.9 ms went. ClickHouse broke the latency down, and most of it was waiting on storage, not Postgres doing more CPU work:

Page readsWAL fsync at commitLock and scheduler0 ms10 ms20 ms30 ms40 msEBS gp3 · Page reads: 19 msEBS gp3 · WAL fsync at commit: 11 msEBS gp3 · Lock and scheduler: 5 msEBS gp3Local NVMe · Page reads: 0.3 msLocal NVMe · WAL fsync at commit: 1.5 msLocal NVMe · Lock and scheduler: 0.2 msLocal NVMe
Where a transaction’s latency went (approx. ms) Approximate figures as stated in the post; they do not add up exactly to the median latency. Source: ClickHouse, Postgres isn’t slow, your storage is
Where a transaction’s latency went (approx. ms)
Page readsWAL fsync at commitLock and scheduler
EBS gp319 ms11 ms5 ms
Local NVMe0.3 ms1.5 ms0.2 ms

A pg_stat_activity snapshot told the same story: 29 of 64 backends were waiting on IO:DataFileRead on EBS, against 9 on NVMe. Two numbers matter for your own box, then: how fast the disk serves small random reads (page reads that miss the cache), and how long an fsync takes (every commit waits for the WAL to reach the disk).

Step 1: see whether Postgres is waiting on I/O

Before benchmarking, check that I/O is the problem at all. Run this a few times while the app is under its normal load:

Identifying whether the system is waiting on I/O is the first step in troubleshooting.
Identifying whether the system is waiting on I/O is the first step in troubleshooting.
SELECT wait_event_type, wait_event, count(*)
FROM pg_stat_activity
WHERE state = 'active'
GROUP BY 1, 2
ORDER BY 3 DESC;

If IO waits such as DataFileRead or WALSync keep showing up near the top, storage is in the path. If most active sessions are waiting on Lock, or are not waiting at all (CPU), a faster disk will not fix it. Look at queries and indexes instead.

Step 2: random reads with fio

Install fio (apt install fio or dnf install fio) and run it on the same filesystem as your data directory. Postgres reads 8 KB pages, so test 8 KB random reads with the page cache bypassed. Use a file larger than you can afford to fill, and delete it afterwards.

sudo fio --name=pg-randread \
  --filename=/var/lib/postgresql/fio-test.bin --size=4G \
  --rw=randread --bs=8k --direct=1 --ioengine=libaio \
  --iodepth=32 --numjobs=4 --runtime=60 --time_based \
  --group_reporting

Read three lines in the output: IOPS=, the average clat (completion latency), and the 99th percentile in the clat percentiles block. Then run the same command with --iodepth=1 --numjobs=1: that shows what a single query waits for one page that is not in memory, which is closer to what one user feels.

Compare the IOPS with what your provider sells you. A gp3 volume at its baseline is provisioned at 3,000 IOPS, the same volume as in the ClickHouse test. Many VPS plans cap disk I/O well below what the hardware can do, and the plan page or the provider's docs usually state it. If fio lands right at a round number and stays there, you have found the cap, not the disk.

Want AI wired into the systems you already run?I build LLM integrations with costs and quality you can see. The estimate is free.

Step 3: commit latency with pg_test_fsync

Every commit waits for its WAL record to be flushed. Postgres ships a tool for exactly this. On Debian and Ubuntu it lives in the version's bin directory; point it at a file on the WAL disk:

sudo -u postgres /usr/lib/postgresql/16/bin/pg_test_fsync \
  -f /var/lib/postgresql/16/main/pg_test_fsync.tmp -s 5

It prints ops/sec and microseconds per operation for each sync method. Look at the line for your wal_sync_method (on Linux the default is fdatasync; check with SHOW wal_sync_method;). The ClickHouse numbers give you a reference: about 1.5 ms per commit on local NVMe and about 11 ms on EBS. One client committing in a loop cannot do better than one commit per fsync, so 11 ms caps a single connection at about 90 commits a second (1,000 / 11). Postgres groups concurrent commits into one flush, which is why many clients still get more done, but the floor per transaction stays.

Delete the test files when you are done:

sudo rm /var/lib/postgresql/fio-test.bin /var/lib/postgresql/16/main/pg_test_fsync.tmp

Step 4: decide what to change

  • Slow random reads, data bigger than RAM: more RAM (so more pages stay cached) or faster storage. Raising shared_buffers on a box that has no spare memory only moves pages between two caches.
  • Slow fsync: this is the commit floor. Batching writes in the app helps. synchronous_commit = off also hides it, but it means a crash can lose the last few commits, so only use it where that is acceptable, never as a speed fix for money or orders.
  • A provisioned cap: on network block storage you can usually buy more IOPS and throughput; on a VPS plan, a bigger plan or a different host.
  • Local NVMe: the biggest jump in the ClickHouse test, with a catch. Instance-store disks on AWS do not survive the instance being stopped. Postgres on local NVMe needs a replica and real backups; ClickHouse mentions WAL-G for base backups and continuous WAL archiving. A dedicated server or VPS with local NVMe has the same rule: the disk can die, so the backups must live elsewhere.

If storage turns out to be fine and Postgres is still slow, then it is time for postgresql.conf, EXPLAIN (ANALYZE, BUFFERS) and indexes. You will be tuning the right thing, with numbers to compare against.

Disk IOPS and fsync latency act as a hard ceiling for database performance.
Disk IOPS and fsync latency act as a hard ceiling for database performance.

Sources

if.codesI build RAG, AI integrations and agent pipelines on Go and Python backends — and write about it here.
// keep reading

More posts on AI and backends.

// free quote

Read something you need? I’ll quote it for free.

RAG, AI integrations, agents or the backend underneath — tell me what you have and what should change. I read every request myself.

Free · no commitment

Tell me what you have. I’ll tell you what it takes.

1Describe the projectA few sentences is enough — about two minutes.
2I review itI read it myself and may ask a follow-up question.
3You get a free quoteScope, approach and estimate — yours to keep, no strings.
What kind of project is it?
Free and without obligation. Your details are used only to reply — see the privacy policy.