03 Chunk Savestate Analysis
This document was written by HackerRouter. Mechanism discovered by BFladderbean.
Reference video:
1 TL;DR
TACS (ThreadedAnvilChunkStorage) saves chunk A (old state) during incremental save in a normal tick and sets a 10-second cooldown →
States of chunks A and B are changed →
In subsequent save attempts, chunk A is skipped due to cooldown, chunk B is saved normally →
Main thread freezes, Watchdog force-exits →
After restart, chunk A is the old snapshot, chunk B is the new snapshot, items exist in both locations simultaneously.
2 TACS
TACS (ThreadedAnvilChunkStorage), corresponding to the ChunkMap class in Mojang mappings, is the core component for server-side chunk persistence.
It decides which chunks need saving, when to save them, and how to throttle saves.
TACS holds a ChunkHolder reference for every loaded chunk (one per loaded chunk).
These references live in visibleChunkMap, a Long2ObjectLinkedOpenHashMap<ChunkHolder> keyed by chunk coordinates encoded as a long.
TACS drives the serialization and write pipeline for chunks on every tick and during auto-saves.
All save logic discussed below (incremental saves, auto-saves, cooldown throttling) happens inside TACS.
3 How Chunk Saves Actually Trigger
Chunk saves don't just happen during the auto-save that runs every 6000 ticks.
The server also performs incremental saves every tick.
The call chain is as follows:
MinecraftServer.tickServer()
→ tickChildren()
→ serverLevel.tick()
→ chunkSource.tick() // ServerChunkCache.tick()
→ chunkMap.tick() // ChunkMap.tick()
→ processUnloads()
→ Iterate visibleChunkMap, save up to 20 chunks per tick
At the end of ChunkMap.processUnloads() (1.19.2 ChunkMap.java:502-509):
Each tick, the loop tries to save up to 20 chunks. This runs independently of auto-save, continuously in the background.
4 10-Second Save Cooldown
The 10-second save cooldown is how TACS throttles saves for individual chunks.
It prevents frequently changing chunks (such as those with ongoing redstone activity) from being serialized and written to disk every tick, reducing I/O overhead.
The specific implementation is in ChunkMap.saveChunkIfNeeded() (1.19.2 ChunkMap.java:762-786):
chunkSaveCooldowns is a Long2LongOpenHashMap keyed by chunk position (long-encoded), with values representing the timestamp (ms) when the next save is allowed.
After a successful save, the chunk's cooldown deadline is set to current time + 10000ms.
During the cooldown period, even if the chunk becomes dirty again from block changes (needsSaving == true), saveChunkIfNeeded() returns false and skips the save.
The dirty flag is necessary but not sufficient for saving. If the cooldown hasn't expired, the chunk is skipped regardless.
5 Serialization Completes Synchronously on Main Thread
Key sequence in ChunkMap.save() (1.19.2 ChunkMap.java:788-818):
ChunkSerializer.write() runs inline on the main thread, iterating through chunk.getBlockEntityPositions() and calling chunk.getPackedBlockEntityNbt() for each position.
The serializer captures the live chunk's logical state at the moment of the call.
The I/O thread never reads from the active chunk's memory.
6 Async Write Pipeline — Three-Layer Chain
The serialized NBT goes through three layers before reaching the disk:
Layer 1: Main thread → IOWorker.pendingWrites (JVM heap memory)
ChunkStorage.write() → IOWorker.store(), stores NBT into pendingWrites, a LinkedHashMap.
Layer 2: IOWorker background thread → RegionFile (OS file cache)
IOWorker.storePendingChunk() takes one entry at a time, calls RegionFile.write().
RegionFile.write() calls FileChannel.write() to write data to the file, but does not call force()/fsync() (OS-level forced flush system call).
Data enters the OS page cache (memory where the OS buffers file writes before flushing to physical disk), so it's not yet guaranteed to reach the physical disk.
Layer 3: OS page cache → Physical disk
Without fsync, flushing to disk depends on the OS's dirty page writeback strategy.
When the process terminates, unflushed data may be lost.
IOWorker writes chunks entry by entry, not as an atomic commit of the entire world at once.
7 Auto-Save Does Not Bypass Cooldown
Auto-save triggered every 6000 ticks (MinecraftServer.tickServer() 1.19.2:828):
The flush parameter controls how thoroughly the save runs:
flush=true: Forces serialization of all dirty chunks (bypassing the 10-second cooldown) and waits forIOWorkerto finish writing all queued data to disk before returning. Used for/save-all flushor server shutdown.flush=false: Uses the normalsaveChunkIfNeeded()path, subject to cooldown, and returns immediately after submitting toIOWorkerwithout waiting for the actual disk write.
Auto-save uses flush=false.
saveEverything → saveAllChunks → serverLevel.save() → serverChunkCache.save(false) → ChunkMap.saveAllChunks(false).
The non-flush branch of saveAllChunks(false) (1.19.2 ChunkMap.java:452-454):
Auto-save calls the same saveChunkIfNeeded().
If a chunk was just saved in a previous incremental save, auto-save will also skip it during the cooldown period.
8 When Chunks Become Dirty
When WorldChunk.setBlockState() executes on the main thread (1.19.2):
- Modify block state inside section
- Call
onStateReplacedon old state removeBlockEntityif necessary- Finally set
needsSaving = true
A chunk becomes dirty again as soon as a block change occurs.
However, the dirty state has no connection to the old NBT already queued in IOWorker — that old NBT is already an independent data object.
9 Watchdog Force Exit
DedicatedServerWatchdog monitors the main thread in a separate thread.
After detecting timeout:
- Write crash report
- Call
System.exit(1) - Schedule
Runtime.halt(1)as fallback after 10 seconds
System.exit(1) triggers the JVM shutdown process.
Before the process fully exits, the IOWorker background thread may keep running briefly, continuing to write out chunks already queued in pendingWrites.
This continues until the JVM exits normally or gets hard-killed by halt(1).
10 Process Walkthrough
Consider the example from Comedy's video: a dispenser shoots a shulker box into an adjacent chunk.
T=0: Incremental save writes chunk A
During a normal tick, the incremental save loop reaches chunk A (containing the dispenser + shulker box). Conditions are met:
- Cooldown has expired
needsSaving == true
Main thread synchronously runs ChunkSerializer.write(), generating A_old_nbt (dispenser contains shulker box).
This is submitted to IOWorker.
Then chunkSaveCooldowns[A] = now + 10000ms is set.
Chunk A then enters a 10-second cooldown period.
T=1: Chunks A and B are changed
Dispenser is activated by redstone; 4 ticks later ServerTickList.tick() executes the scheduled tick and the dispenser fires.
Shulker box moves from chunk A (dispenser) to chunk B (landing position).
Chunk A becomes dirty (dispenser becomes empty), chunk B becomes dirty (shulker box block appears).
T=2: Auto-save/incremental save runs again
Auto-save triggers, or the incremental save loop reaches these two chunks:
- Chunk A:
saveChunkIfNeeded()→ cooldown not expired (still within 10 seconds) → skipped - Chunk B: No cooldown or cooldown expired → save successful → generates
B_new_nbt(contains shulker box)
At this point, the persistent state has diverged:
- Chunk A: On disk is
A_old_nbt(dispenser contains shulker box) - Chunk B: On disk is
B_new_nbt(landing position has shulker box)
T=3: Main thread frozen by redstone + Watchdog force-exit
After main thread freezes, no new serialization occurs.
IOWorker background thread continues writing remaining NBT in pendingWrites.
Watchdog detects timeout, System.exit(1).
Note: The state divergence had already formed at T=2 — chunk A was skipped due to cooldown while chunk B was saved as a new snapshot.
The Watchdog force-exit at T=3 only prevents any subsequent save that might have corrected this divergence (such as the next incremental save after cooldown expiration).
Additionally, since RegionFile.write() doesn't call fsync (see Section 5), data in the OS page cache that hasn't been flushed to physical disk when the JVM exits may also be lost, but this is only a contributing factor, not the core cause.
After Restart
Chunk A loads from disk: dispenser contains shulker box (old snapshot).
Chunk B loads from disk: landing position has shulker box (new snapshot).
Shulker box is duplicated.

