A Minecraft server can look healthy — TPS pinned at 20.0, MSPT under 15 ms, plenty of RAM free — and still stall the entire player base for four seconds every time someone crosses into a new region. The cause is almost never CPU. It is chunk generation write I/O: a burst of random reads and synchronous writes to region files, amplified by plugin saves, backups and log flushes landing in the same window. On shared storage that burst is invisible until you graph disk latency against chunk-load events. Fixing it means separating the I/O paths and understanding exactly how .mca region files behave. Start with a bare-metal game server with a dedicated NVMe device so world I/O is not competing with another tenant’s batch job.
What a Chunk Load Actually Costs on Disk
Minecraft stores 32×32 chunks per region file, each chunk occupying one or more 4 KiB sectors. A new region file is 128 KB of headers plus 4 KiB-aligned chunk blocks. When a player walks into ungenerated terrain, the server:
- reads the region header (4 KiB) and the timestamps table (4 KiB);
- generates the chunk in CPU (biome noise, structures, carvers);
- writes the chunk payload — typically 8–40 KiB compressed for overworld terrain, 60–120 KiB for complex biomes with entities;
- fsyncs or relies on page cache flush, depending on
sync-chunk-writesinserver.properties.
Multiply by a party of six flying on elytra at 30 blocks/s: ~120 chunks per second needing generation. That is 1–5 MB/s of write traffic with a highly random 4 KiB access pattern plus CPU on the main thread for generation and compression.
Measured Baseline: Fresh World Flyover
Numbers from a 1.20.x Paper server, 12 threads, NVMe, using iostat -x 1 and Paper’s --timings during a 60-second elytra flight by four players:
| Metric | SATA SSD | Gen3 NVMe | Gen4 NVMe |
|---|---|---|---|
| Chunks generated/s | 95 | 210 | 290 |
| Write throughput | 38 MB/s | 41 MB/s | 42 MB/s |
| Avg write latency | 6.8 ms | 0.9 ms | 0.5 ms |
| p99 write latency | 74 ms | 11 ms | 4 ms |
| Worst MSPT | 180 ms | 64 ms | 31 ms |
| TPS dip | 14.2 | 18.6 | 19.7 |
Throughput barely changes across devices — it is queue depth that separates them. The SATA drive’s high p99 latency serialises chunk saves behind the world thread, which is why TPS dipped even though the CPU was only 40% busy.
Separating the I/O Paths
Three classes of writes hit the same device on a default install: world region writes, plugin data (database, flat files), and backups. Put them on different physical devices or at least different filesystems with different priorities:
- World: local NVMe, mounted with
noatime. This is latency-critical. - Backups: a second SSD or the network path. A 20 GB world snapshot taken at 3 AM can still starve chunk writes if it shares the same device queue.
- Plugins and logs:
logs/and plugin folders on a separate volume withrelatime. Log rotation withcopytruncatecauses write amplification — usecreateand reopen instead.
If you have only one NVMe, at minimum move backups off it and cap backup throughput with ionice -c2 -n7 plus pv -L 80m so the copy cannot saturate queue depth.
Filesystem and Mount Settings
# /etc/fstab
/dev/nvme0n1p1 /srv/minecraft ext4 noatime,nodiratime,commit=120,data=ordered 0 2
commit=120 reduces periodic flush frequency, but only raise it if you have a UPS — it widens the data-loss window on power failure. data=ordered keeps metadata consistent without the performance penalty of journal mode. Do not use data=writeback for world data; a crash can leave region files with valid headers pointing at stale chunk offsets, which is exactly the corruption you are trying to avoid.
Keep at least 15% of the NVMe unallocated. Region file rewrites are in-place-ish and an SSD with no free blocks drops to a fraction of its write throughput, which shows up directly as MSPT spikes.
Pre-Generation and Pregeneration Limits
The cheapest fix for chunk-load stalls is not generating chunks during play. Chunky pregeneration at 8,000 blocks radius on a Gen4 NVMe takes about 40 minutes per 1,000 chunks; do it before launch and set view-distance to a value your hardware can serve from cache. Watch the interaction with simulation-distance: players can see 12 chunks but only need 6 simulated, which cuts entity tick cost without touching disk I/O.
Two caveats:
- Pregeneration itself hammers the device — run it with a write limit and outside player hours, or you will trade one stall for another.
- Region files grow to 512 MB max; large worlds end up with the hottest region files (spawn, highways) written constantly. Splitting the world across dimensions and pruning unused regions annually reduces random write pressure.
Sync Settings and Durability Tradeoffs
sync-chunk-writes=true forces an fsync per chunk save. On NVMe that costs 0.3–1 ms per chunk and is affordable at normal play; during a flyover it becomes the dominant cost. Turning it off trades a small crash-loss window (last few minutes of block changes) for a large reduction in p99 latency. For most survival servers with hourly backups, off is the right call — verify that your backup script snapshots the world directory consistently, or you will capture half-written region files.
Measure the effect rather than guessing: run iostat -x 1 alongside /paper timings and look for correlated spikes in w_await and MSPT. If they track, it is I/O. If MSPT spikes without disk latency moving, look at GC or entity ticking instead.
Choosing Hardware for I/O-Heavy Worlds
Separately from throughput, look at what else shares the device. A common pattern is a Minecraft world on the same NVMe as the host’s swap file, the Docker layer store, and the OS journal. Under memory pressure, the kernel starts writing swap pages, the drive queue fills, and chunk saves queue behind them. Put swap on a separate device or cap it hard, and move /var/lib/docker off the world volume if you run containers.
Queue Depth and Scheduler Choice
NVMe drives deliver their advantage through parallelism: a Gen4 device sustains 500k+ IOPS only when the queue is deep. A game server thread issuing synchronous 16 KiB chunk writes one at a time never reaches that depth, so the controller stays mostly idle while the thread waits. Two things improve this in practice:
- Async chunk saving: Paper and its forks can write chunks off the main thread. Enable the relevant config and verify with timings that chunk-save time no longer appears in the main-thread breakdown.
- I/O scheduler: set
nonefor NVMe (echo none > /sys/block/nvme0n1/queue/scheduler). The kernel scheduler adds ordering logic for spinning disks that costs latency on flash with no benefit.
Also check the read-ahead setting. blockdev --getra /dev/nvme0n1 often reports 256 KiB, which is fine for sequential region reads during chunk loading but wasteful for header-only access. We have measured a 15% reduction in p99 chunk-load time going from 256 to 128 KiB on a paper server with a large world; the effect is workload-specific, so benchmark before committing.
You need one fast NVMe per two or three world instances, a CPU whose single-thread performance handles generation, and enough RAM to keep the region cache warm — 8 GB heap for a 60-player vanilla world, more for modpacks. A dedicated host with isolated NVMe and tunable mount options removes the shared-tenant variance that makes this problem so hard to diagnose on cheap VPS plans. Graph disk latency next to TPS and the next flyover stall will explain itself in one chart.

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