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:

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:
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:
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:

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.



