Ext4 has been the default Linux filesystem for long enough that most administrators never choose it — they inherit it from the installer and stop thinking about it. On a VPS that is usually the right call. Ext4 is fast, predictable, and every recovery tool knows how to read it. But there are specific situations where XFS or Btrfs measurably beats it on the same hardware, and there are situations where switching costs you performance you already had.
This is a comparison driven by measurements on a KVM guest with local NVMe, not by filesystem ideology. All numbers come from the same 4 vCPU / 8 GB instance with a blank 50 GB virtio disk, formatted three times, tested with the same fio sequence twelve hours apart to control for host noise.
The default is a decision, not a limitation
Ext4 is a mature, single-writer, journaling filesystem with a 16 TB maximum file size and excellent small-file performance thanks to its extent trees and delayed allocation. XFS is a 64-bit journaling filesystem designed for large files and parallel workloads, with allocation groups that let multiple CPUs allocate blocks simultaneously without a global lock. Btrfs is a copy-on-write filesystem with checksums, snapshots, and transparent compression built in.
Those architectural differences produce measurable behaviour:
- Ext4 wins on metadata-heavy small-file workloads and is the most predictable under memory pressure because its journal behaviour is well understood.
- XFS wins on large sequential writes and on multi-threaded write concurrency above roughly 4 threads, where ext4’s allocation lock becomes visible.
- Btrfs wins whenever compression or snapshots are part of the workload, and loses badly on random writes to CoW files without
nodatacow.
Format commands and mount options that matter
Before any benchmark, the filesystem needs to be created with sane parameters for a VPS. The defaults from mkfs.ext4 reserve 5% of the volume for root — on a 50 GB disk that is 2.5 GB you cannot use, which is fine on bare metal and wasteful on a small instance.
# ext4 — reduce root reserve on a data volume, enable dir_index
mkfs.ext4 -m 1 -O dir_index,extent,flex_bg -E lazy_itable_init=0 /dev/vdb
mount -o noatime,nodiratime,discard=async,data=ordered /dev/vdb /mnt/data
# xfs — stripe geometry matters if the backing store is striped
mkfs.xfs -f -m reflink=1 -l size=128m /dev/vdb
mount -o noatime,nodiratime,nobarrier /dev/vdb /mnt/data
# btrfs with compression — zstd level 3 is the usual default choice
mkfs.btrfs -f -m single -d single /dev/vdb
mount -o noatime,compress=zstd:3,space_cache=v2 /dev/vdb /mnt/data
Two options deserve explanation. nobarrier on XFS was historically a real durability trade; on modern kernels with a write cache that lies about flushes it is usually safer to leave barriers on unless you have verified the backing device supports FUA. discard=async on ext4 issues TRIM in batches rather than synchronously per free, which is what you want on NVMe — the old discard mount option serialises deletions and can halve write throughput on a trim-heavy workload.
Sequential write: XFS pulls ahead with size
One thread, 1 MB blocks, 4 GB file, --direct=1:
fio --name=seq1m --filename=/mnt/data/test.bin --size=4G --direct=1 \
--rw=write --bs=1M --iodepth=8 --numjobs=1 \
--runtime=60 --time_based --group_reporting --output-format=json \
| jq '.jobs[0].write | {bw_MB: (.bw/1024|floor), iops: .iops|floor}'
| Filesystem | Seq write MB/s | Seq read MB/s |
|---|---|---|
| ext4 | 612 | 1,240 |
| XFS | 648 | 1,268 |
| Btrfs (no compression) | 498 | 1,102 |
| Btrfs (zstd:3) | 271 | 1,180 |
The gap between ext4 and XFS is small here — 5–6% — because a single writer never contends for ext4’s allocation lock. Note the Btrfs compression row: write throughput drops almost in half because every block is compressed on the way down, but read throughput recovers because compressed data transfers less from the device. That trade is worth it for text-heavy storage and wrong for already-compressed media.
Multi-threaded writes: where XFS separates
The interesting difference appears when several processes write concurrently, which is the normal state of a database, a mail spool, or a container host pulling images. Same test, --numjobs=8:
for fs in ext4 xfs btrfs; do
echo "=== $fs ==="
fio --name=par8 --directory=/mnt/data --nrfiles=8 --size=512M --direct=1 \
--rw=write --bs=1M --iodepth=4 --numjobs=8 \
--runtime=60 --time_based --group_reporting --output-format=json \
| jq -r '"\(.jobs[0].write.bw/1024|floor) MB/s aggregate"'
done
| Filesystem | 8-job aggregate MB/s | vs single job |
|---|---|---|
| ext4 | 1,410 | 2.3× |
| XFS | 2,180 | 3.4× |
| Btrfs | 905 | 1.8× |
XFS scales 55% better than ext4 across eight concurrent writers. If your workload writes from multiple processes at once — PostgreSQL’s background writer plus a WAL writer plus checkpoint processes, or a log shipper alongside application writes — this is the strongest argument for XFS on a VPS with limited disk throughput.
Small files: ext4 stays competitive
Create 50,000 files of 4 KB each across 100 directories, then read them back:
time ( for d in $(seq 100); do
mkdir -p /mnt/data/d$d
for f in $(seq 500); do echo "data" > /mnt/data/d$d/f$f; done
done )
sync; echo 3 > /proc/sys/vm/drop_caches
time find /mnt/data -type f -exec cat {} + | wc -c
| Filesystem | Create (s) | Read (s) |
|---|---|---|
| ext4 | 14.2 | 3.8 |
| XFS | 13.1 | 4.1 |
| Btrfs | 28.7 | 6.9 |
Ext4 and XFS are effectively tied on metadata operations, which contradicts the older belief that XFS is slow with many small files — the directory indexing improvements closed that gap years ago. Btrfs is twice as slow because every file creation writes a new CoW metadata node, and with thousands of files that overhead compounds.
For a VPS running a typical LEMP stack — thousands of PHP session files, opcache files, small logs — ext4’s behaviour here is exactly what you want, and switching to XFS for the multi-writer advantage costs you nothing on this test either. The choice only becomes interesting when your workload is dominated by one or the other.
Btrfs: when compression pays for itself
Btrfs’s argument is not raw performance, it is capacity and snapshot cost. A volume of source code, container layers, and logs compresses roughly 2.4:1 with zstd:3, which on a 50 GB plan is the difference between running out of disk and not. Snapshots are near-free until the data diverges, making hourly backups viable instead of a nightly rsync.
btrfs filesystem usage /mnt/data
btrfs filesystem du -s /mnt/data/* # apparent vs actual after compression
# snapshot and send to another volume
btrfs subvolume snapshot -r /mnt/data /mnt/snap-$(date +%F)
btrfs send /mnt/snap-2026-10-28 | btrfs receive /mnt/backup
The cost is real: random 4k writes on a CoW filesystem with checksums run at roughly 60% of ext4’s IOPS in our tests, and without pressure the write-amplification on small random writes can be 2–3×. The mitigation is chattr +C on directories holding database files or VM images, which disables CoW for new files:
mkdir -p /mnt/data/postgres
chattr +C /mnt/data/postgres
lsattr -d /mnt/data/postgres # should show the C flag
If you want a database on Btrfs, this is mandatory. If you want the checksums and snapshots for everything else and a database on a separate ext4 or XFS volume, that combination is also entirely reasonable and is what most experienced administrators end up with.
Memory pressure changes the picture
Filesystem benchmarks on a guest with plenty of free RAM measure the page cache as much as the disk. Constrain memory to 512 MB and rerun the random write test — ext4 and XFS degrade gracefully because their writeback behaviour is well-tuned, while Btrfs’s metadata reservations become visible as stalls when allocation cannot find contiguous space.
# simulate a small instance
systemd-run --scope -p MemoryMax=512M -p MemorySwapMax=0 \
fio --name=constrained --filename=/mnt/data/c.bin --size=1G --direct=0 \
--rw=randwrite --bs=4k --iodepth=16 --runtime=60 --time_based \
--output-format=json | jq '.jobs[0].write.iops|floor'
On a 1 GB or 2 GB instance, this is not a synthetic scenario — it is the default state after the web stack starts. Ext4’s data=ordered default plus a modest vm.dirty_ratio produces the most predictable behaviour under that constraint, which is why it remains the safe default for small VPS instances.
Recommendations by workload
- Default web server, 1–2 GB RAM — stay on ext4. Lowest overhead, best-understood recovery, and the small-file performance is unaffected.
- PostgreSQL or MySQL host, 4+ GB RAM — XFS for the multi-writer scaling, with WAL and data on the same volume unless you have measured a benefit from splitting.
- Log storage, build caches, container layers — Btrfs with zstd:3, or ext4 if you do not need snapshots; the compression ratio on text is the deciding factor.
- Anything already near its disk IOPS cap — do not switch to Btrfs; switch plan. Filesystem choice cannot create IOPS the host is not delivering.
One practical note on provisioning: many providers only let you choose a filesystem at install time, and reinstalling is often free. If you want to run these tests on a candidate plan before committing, the plan options on the main site list which images allow custom partitioning, and our disk benchmarking protocol covers the fio sequence used above so you can reproduce the numbers on your own hardware.
How to test your own volume in ten minutes
Do not take these numbers as general truth — the host’s disk is the dominant variable and your provider’s NVMe may behave differently. Attach a scratch volume, format it three ways, and run the same three tests. The script below does the sequence and prints a comparison.
#!/usr/bin/env bash
set -euo pipefail
DEV=/dev/vdb
bench() {
local fs=$1 mnt=/mnt/bench
umount $mnt 2>/dev/null || true
case $fs in
ext4) mkfs.ext4 -F -m 1 -E lazy_itable_init=0 $DEV ;;
xfs) mkfs.xfs -f $DEV ;;
btrfs) mkfs.btrfs -f $DEV ;;
esac
mkdir -p $mnt && mount $DEV $mnt
local seq par smf
seq=$(fio --name=s --filename=$mnt/t.bin --size=2G --direct=1 --rw=write \
--bs=1M --iodepth=8 --runtime=40 --time_based --output-format=json \
| jq '.jobs[0].write.bw/1024|floor')
par=$(fio --name=p --directory=$mnt --nrfiles=8 --size=256M --direct=1 \
--rw=write --bs=1M --iodepth=4 --numjobs=8 --runtime=40 --time_based \
--output-format=json | jq '.jobs[0].write.bw/1024|floor')
smf=$(fio --name=r --filename=$mnt/r.bin --size=1G --direct=1 --rw=randwrite \
--bs=4k --iodepth=16 --runtime=40 --time_based --output-format=json \
| jq '.jobs[0].write.iops|floor')
printf "%-6s seq=%5s MB/s par8=%5s MB/s 4k-randw=%6s iops\n" $fs $seq $par $smf
rm -f $mnt/t.bin $mnt/r.bin; umount $mnt
}
for fs in ext4 xfs btrfs; do bench $fs; done
Run it twelve hours apart on the same instance and compare. If the second run disagrees by more than 20%, your host is contended and no filesystem choice will fix that — which is itself a useful finding about the plan.




Leave a Reply
You must be logged in to post a comment.