Minecraft Runs on One Thread Because Nobody Wrote It for Two
Every entity, every block update, every redstone pulse — one thread, twenty times a second, 50 milliseconds a pass. Folia's answer is to stop sharing the kitchen. Here is the tick budget and the ownership rules, read from the PaperMC source.
Article Updated August 26, 2026
Rebuilt against the Folia source at commit14b7fee, with the tick constants and threading rules read out of the repository rather than summarised from forum posts. Region threading has been shipping long enough now that the interesting question is no longer "does it work" but "what did it cost."
Your server has 32 cores. Your server is using one of them.
Not one mostly. One. There is a thread called the main thread, and it is doing every entity, every block update, every redstone pulse, every mob pathfind, every chunk tick, every plugin event, in sequence, twenty times a second — while thirty-one other cores sit there costing you money and doing absolutely nothing.
People find this out during their first bad Saturday, usually with a farm running and forty players online, watching TPS slide and wondering which flag fixes it. None of them do. You are not looking at a tuning problem. You are looking at an architecture that started in 2009 as one person's project, written by somebody who was not thinking about your 32-core dedicated box, and every line written since has assumed that decision was permanent.
One Cook, Every Station, Twenty Times a Second
Here is the shape of it, and the kitchen version is exact rather than cute.
A restaurant line is stations. Grill, sauté, garde manger, pastry. Four cooks, four stations, working simultaneously — the grill cook doesn't wait for the sauté cook to finish before searing, because they're separate people touching separate equipment. That parallelism is the entire reason a kitchen can put out three hundred covers.
Minecraft's server is one cook doing all four stations, in order, and starting over every fifty milliseconds.
Fifty milliseconds is not a number I picked. From TickRegionScheduler in the PaperMC/Folia repository, commit 14b7fee:
public static final int TICK_RATE = 20;
public static final long TIME_BETWEEN_TICKS = 1_000_000_000L / TICK_RATE; // ns
A billion nanoseconds divided by twenty. Do the arithmetic:
TIME_BETWEEN_TICKS = 50000000 ns = 50 ms
That is the whole budget. Everything the server does in a tick has to fit in fifty milliseconds, or the tick runs long and the next one starts late. What that looks like as the work grows:
tick work 10 ms -> 20.0 TPS
tick work 45 ms -> 20.0 TPS
tick work 50 ms -> 20.0 TPS
tick work 62 ms -> 16.1 TPS (behind)
tick work 100 ms -> 10.0 TPS (behind)
tick work 200 ms -> 5.0 TPS (behind)
Note what that table actually says, because people misread it constantly. Finishing in 10ms buys you nothing. There is no bonus for being fast. Twenty TPS is the ceiling and the target, and the only thing your headroom does is give you room to absorb a spike.
The cliff is on the other side. Go one millisecond over and you are not at 19.9 TPS — you are at whatever 1000 / your_tick_time comes out to, because the next tick cannot start until this one finishes. Sixty-two milliseconds of work is 16 TPS. That's a fifth of your game speed gone for 24% more work.
And that is a kitchen with one cook and a ticket printing every fifty milliseconds regardless of whether he's ready. He doesn't get to say "hang on." The tickets just stack.
Why Nobody Just Fixed It
The obvious question is why fifteen years of very capable engineers didn't thread it.
They tried. Repeatedly. The reason it doesn't work is not that threading is hard in the abstract — it's that Minecraft's world state is one enormous mutable graph where everything can reach everything, and none of it was written with a lock discipline because it never needed one.
A hopper pulls from a chest two chunks away. A piston pushes an entity across a chunk boundary. A player in the Nether triggers a chunk load that spawns a mob that pathfinds toward a village that ticks its own AI. A single redstone pulse can cascade through dozens of block entities across multiple chunks in one tick. None of that code checks what thread it's on, because for fifteen years there was only one answer.
So bolting threads onto that is not "add synchronized." It's auditing every mutable access in a codebase where the assumption of single-threadedness is baked into code nobody has read since 2012. Get it wrong and you don't get a crash — you get a duplicated diamond block, a corrupted chunk, an entity that exists in two places. Silent data corruption in a game whose entire economy is player-built state.
In kitchen terms: you can't just hire a second cook and point him at the line. Every recipe in the book was written assuming one person doing things in a specific order. Two people working from those recipes will both reach into the same lowboy for the same container, and now you have two half-made sauces and no idea which one is right.
Folia's Answer: Stop Sharing the Kitchen
Folia is PaperMC's fork that does the thing everyone said couldn't be done, and the trick is that it doesn't make Minecraft thread-safe. It partitions the world so that threads never touch the same data.
From Folia's own README:
Folia groups nearby loaded chunks to form an "independent region." [...] Each independent region has its own tick loop, which is ticked at the regular Minecraft tickrate (20TPS). The tick loops are executed on a thread pool in parallel. There is no main thread anymore, as each region effectively has its own "main thread" that executes the entire tick loop.
Read that last clause again. There is no main thread anymore. Not "the main thread got faster." It's gone, replaced by N of them, each owning a slice of the world.
Now the kitchen actually is a kitchen. Real stations, each with its own mise, its own board, its own lowboy. The grill cook and the sauté cook never reach for the same container because they don't have the same containers. Players spread across a map are players in different regions on different threads, genuinely running at the same time.
The rule that makes it safe is stated in the README with unusual bluntness:
The other important rule is that the regions tick in parallel, and not concurrently. They do not share data, they do not expect to share data, and sharing of data will cause data corruption.
Parallel, not concurrent. Those are not synonyms and the distinction is the whole design. Concurrent means multiple threads coordinating over shared state with locks. Parallel here means the state is carved up so there is nothing to coordinate over. No locks, because no sharing. It is the difference between two cooks negotiating over one container and two cooks who each have their own.
How a Region Actually Gets Drawn
"Nearby chunks form a region" is where every explanation stops. The mechanism underneath is more interesting, and you can read it.
Regions aren't built out of chunks. They're built out of sections — square blocks of chunks — and the size comes from a config exponent:
int gridExponent = config.gridExponent;
gridExponent = Math.max(0, gridExponent);
gridExponent = Math.min(31, gridExponent);
regionShift = gridExponent;
Default gridExponent = 4, so 1 << 4 = 16, meaning each section is a 16×16 block of chunks. That's the granularity of the whole system. Regions are unions of these squares, never partial ones, which is what makes ownership testable with a bit shift instead of a search:
return (chunkX & this.regionChunkMask) | ((chunkZ & this.regionChunkMask) << this.regionChunkShift);
Bitmask and shift. Answering "which region owns this chunk" happens millions of times a tick, so it has to be nearly free, and it is.
The regioniser itself is parameterised on five numbers that describe how regions form, grow, and come apart:
public ThreadedRegionizer(final int minSectionRecalcCount, final double maxDeadRegionPercent,
final int emptySectionCreateRadius, final int regionSectionMergeRadius,
final int regionSectionChunkShift, final ServerLevel world,
final RegionCallbacks<R, S> callbacks) {
emptySectionCreateRadius is the buffer. When a chunk loads, Folia doesn't just claim that section — it claims a ring of empty sections around it. That padding is what stops a player walking two chunks east from triggering a region rebuild on every step. regionSectionMergeRadius is the distance at which two regions are considered close enough to become one.
That's your station with elbow room. You don't set up a board exactly the width of the board — you claim a bit of counter either side, because you're going to move.
Merging is the easy direction. Two players wander toward each other, their padded sections touch, and the regions combine. Cheap, because you're taking two sets of owned chunks and unioning them. The bubble metaphor PaperMC uses is accurate for this half.
Splitting is the hard direction, and it's where the design gets careful.
Why Regions Don't Split the Moment You Walk Away
Merging a region is trivial. Tearing one apart is not — you have to prove nothing in the leftover piece still needs to talk to anything in the other piece, then hand it a new tick loop, a new task queue, and a new thread.
So Folia doesn't split eagerly. It tracks dead sections — sections that no longer have loaded chunks — and only reconsiders the shape when there are enough of them:
private final boolean hasNoAliveSections() {
return this.deadSections.size() == this.sectionByKey.size();
}
private final double getDeadSectionPercent() {
return (double)this.deadSections.size() / (double)this.sectionByKey.size();
}
That percentage gets tested against maxDeadRegionPercent, alongside minSectionRecalcCount as a floor. Both exist for the same reason: recalculating a region's shape costs real work, and doing it every time a chunk unloads would burn more CPU than the split saves.
And the hard constraint on all of it:
boolean killAndMergeInto(final ThreadedRegion<R, S> mergeTarget) {
if (this.state == STATE_TICKING) {
return false;
}
A region that is currently ticking cannot be restructured. It returns false and the merge gets deferred. That's the whole safety model in one guard: you never rewire the world underneath a thread that's mid-tick, because that thread believes it owns this data for the duration.
You do not reorganise a station while somebody is working it. You wait for the ticket to go out.
The practical consequence for anyone running this: region shape lags player movement, deliberately. Two groups that just separated may share a thread for a while. A region bloated by a player who logged off keeps its shape until enough sections die. If you're watching /tps and wondering why a region is bigger than it looks like it should be, that's why — and it's a considered trade, not a bug.
The Default Thread Count Is Lower Than You Think
Now the finding that should change what you do this afternoon.
Folia's default region thread count — what you get with threads: -1, the recommended setting — is computed like this:
private static int getTickThreads(final GlobalConfiguration.ThreadedRegions config) {
int tickThreads;
if (config.threads <= 0) {
tickThreads = OSNuma.getNativeInstance().getTotalCores() / 2;
if (tickThreads <= 4) {
tickThreads = 1;
} else {
tickThreads = tickThreads / 4;
}
} else {
tickThreads = config.threads;
}
return tickThreads;
}
Halve your cores. If that's four or fewer, take one thread. Otherwise divide by four again.
Cores over eight. Run the numbers against Folia's own "allocate up to 80% of cores" guidance:
| Cores | Default tick threads | 80% guidance |
|---|---|---|
| 8 | 1 | 6 |
| 16 | 2 | 12 |
| 24 | 3 | 19 |
| 32 | 4 | 25 |
| 64 | 8 | 51 |
| 96 | 12 | 76 |
On the 16-physical-core machine Folia's README calls the minimum viable hardware, the default gives you two region threads. Two. On the box you bought specifically to run more than one.
This reconciles the tests. Cubxity's June run was configured with six region threads and saturated them; the retune to twelve is what produced 630 players at a flat 20 TPS. Had they left it on default, that 16-core Ryzen would have ticked the entire world on two threads — barely better than Paper, with scheduling overhead on top.
The conservatism is defensible. A default that grabs 80% of the machine would be catastrophic on shared hosting, and Spottedleaf can't know whether you have GC threads, Netty, chunk workers, and three other services on the same box. Defaults protect the worst case.
But it means the single most important number in Folia is one you have to set yourself, and the software will not tell you that you got it wrong. It'll just quietly tick your world on two threads while thirty cores idle, and you'll conclude multithreading was overrated.
That's a kitchen that hired twelve cooks and put two on the line because nobody updated the schedule. The building is full of people. The tickets still stack.
The Rule Is Enforced in Code, Loudly
A convention nobody checks is a convention that gets violated at 2am on a Saturday. Folia doesn't rely on discipline. It asserts.
Throughout the region threading patch you find calls like this:
TickThread.ensureTickThread("Cannot tick player chunk loader async");
and ownership tests before touching anything:
if (!checkRegion && !TickThread.isTickThreadFor(this.world, holder.chunkX, holder.chunkZ)) {
and when the invariant breaks, it does not log a warning and continue:
throw new IllegalStateException("Not running tick() while on a region");
The README explains the philosophy behind that, and it's a line I'd hang in any engineering office:
As time goes on, the number of thread context checks will only grow, even if it comes at a performance penalty - nobody is going to use or develop for a server platform that is buggy as hell, and the only way to prevent and find these bugs is to make bad accesses fail hard at the source of the bad access.
They are deliberately spending performance to buy loud failure. That is the right trade and almost nobody makes it. A wrong-thread access that throws immediately costs you a stack trace pointing at the exact line. A wrong-thread access that silently succeeds costs you three weeks and a corrupted world nobody can reproduce.
This is the allergen rule. You don't check the ticket when it's convenient. You check it every single time, even during a rush, especially during a rush, because the one time you skip it is the time it matters.
The Scheduler Is a Real-Time Algorithm
Here's the detail that made me sit up. Folia's scheduler offers two strategies:
public static enum SchedulerType {
EDF,
WORK_STEALING;
}
EDF is Earliest Deadline First, introduced by Liu and Layland in 1973 and one of the two algorithms the hard real-time field has argued about ever since — the other being Rate Monotonic. The premise is a single sentence: every task carries a deadline, and you always run whichever one is closest to missing it.
It has a property worth knowing. On a single processor, EDF is provably optimal — if any algorithm can schedule a set of periodic tasks without missing a deadline, EDF can too. That's not a marketing claim, it's a theorem, and it's why the algorithm keeps turning up wherever missing a deadline is the actual failure mode.
Which is correct, because a Minecraft tick genuinely is a real-time deadline. Every region owes a completed tick every 50ms. A region that's about to blow its window should run before one with 40ms of slack, regardless of which was queued first.
That's the expediter calling the pass by which ticket is closest to going out late — not first-in-first-out, not whoever shouted loudest. Whichever plate is about to die under the heat lamp.
The scheduler also measures where the time actually went:
MEASURE_CPU_TIME = THREAD_MX_BEAN.isThreadCpuTimeSupported();
Per-thread CPU time, via JMX, so a slow region can be attributed rather than guessed at.
What It Costs You: Every Plugin You Own
Now the part that doesn't make the marketing material. From the README, and I'm quoting it whole because paraphrasing would soften it:
There is no more main thread. I expect every single plugin that exists to require some level of modification to function in Folia. Additionally, multithreading of any kind introduces possible race conditions in plugin held data - so, there are bound to be changes that need to be made.
So, have your expectations for compatibility at 0.
Zero. Written by the people who built it.
That's not modesty, it's arithmetic. Every plugin written in the last fifteen years assumes BukkitScheduler schedules onto the main thread. There isn't one. Folia replaces it with RegionScheduler and EntityScheduler, which schedule onto whichever thread owns a given location or entity — and every plugin has to be rewritten to use them.
Folia won't even load your plugins unless the author opted in, via folia-supported: true in plugin.yml. Refusing to run unmarked code is the same instinct as the thread assertions: fail at the door, loudly, rather than halfway through service.
And the guarantee has an explicit edge:
The only guarantee of thread-safety comes from the fact that a single region owns data in certain chunks - and if that region is ticking, then it has full access to that data. This data is specifically entity/chunk/poi data, and is entirely unrelated to ANY plugin data.
Folia protects its state. Your plugin's HashMap is your problem, and events now fire in parallel across regions. The README is blunt about the trap, too — a ConcurrentHashMap used carelessly "will only hide threading issues, which then become near impossible to debug." Reaching for a concurrent collection to make a race go away usually just makes it quieter.
Fifteen Years of Plugin Code, Deprecated in One Annotation
The plugin problem is usually described as "plugins need updating." The API patch is blunter than that. From folia-api:
/**
* @deprecated Use one of {@link io.papermc.paper.threadedregions.scheduler.RegionScheduler},
* {@link io.papermc.paper.threadedregions.scheduler.AsyncScheduler},
* {@link io.papermc.paper.threadedregions.scheduler.EntityScheduler},
* or {@link io.papermc.paper.threadedregions.scheduler.GlobalRegionScheduler}
*/
@Deprecated
public interface BukkitScheduler {
BukkitScheduler — the interface every Bukkit, Spigot and Paper plugin written since 2011 uses to schedule work — is deprecated in its entirety. Not a method. The type.
Four things replace it, and choosing between them is the actual porting work:
RegionScheduler— run this at a location, on whichever thread owns it.EntityScheduler— run this for a specific entity, wherever that entity currently lives.AsyncScheduler— genuinely off-thread work, touching no world state.GlobalRegionScheduler— server-wide things with no location: weather, time, shutdown.EntityScheduleris the one that reveals how deep this goes. An entity is not pinned to a thread. It walks, gets pushed by a piston, rides a boat across a region boundary — and any task queued against it has to follow it to whatever thread now owns it. There's machinery for exactly that in the server patch:
public final io.papermc.paper.threadedregions.EntityScheduler.EntitySchedulerTickList entitySchedulerTickList = new io.papermc.paper.threadedregions.EntityScheduler.EntitySchedulerTickList();
Your task doesn't run on a thread. It runs near a thing, and the runtime works out where that thing is when the time comes.
This is why "add folia-supported: true and ship it" is nonsense for anything non-trivial. A plugin holding a HashMap keyed by player and mutating it from an event handler was correct for fifteen years. On Folia that handler now fires on multiple threads at once, and nothing will tell you — until the map corrupts under concurrent writes and you're debugging a data structure that has been fine since 2013.
Fail Hard, Even at the Player
The thread assertions apply to plugins. This one applies to your users. From the patch dated 17 July 2026:
if (!ca.spottedleaf.moonrise.common.util.TickThread.isTickThreadFor(
this.player.level(),
ca.spottedleaf.moonrise.common.util.CoordinateUtils.getChunkCoordinate(this.awaitingPositionFromClient.x),
ca.spottedleaf.moonrise.common.util.CoordinateUtils.getChunkCoordinate(this.awaitingPositionFromClient.z),
1)
) {
LOGGER.warn("Disconnecting '" + this.player.getScoreboardName() + "' for out of region teleport accept");
this.disconnect(Component.translatable("multiplayer.disconnect.invalid_player_movement"), org.bukkit.event.player.PlayerKickEvent.Cause.INVALID_PLAYER_MOVEMENT);
return;
}
A teleport confirmation arrives for a position the current thread doesn't own — which the commit message attributes to passenger desync — and the server kicks the player. Logs it, disconnects them, done.
Kicking a paying customer rather than accepting a packet you can't safely process is a hell of a stance. It's also correct. The alternative is writing an entity's position from a thread that doesn't own that chunk, and now you have a duplicated entity or a corrupted region, and the player who caused it has no idea and no way to reproduce it.
The date matters too: July 2026. This isn't a 2023 experiment somebody abandoned. Patches were landing weeks before this article was written.
When a Region Hangs, Something Is Watching
One more piece, because "no main thread" breaks the traditional watchdog. Paper's watchdog watches the main thread. Folia has N of them, any one of which can wedge while the rest tick happily.
So there's a dedicated thread doing nothing but checking:
public void run() {
for (;;) {
try {
Thread.sleep(1000L);
} catch (final InterruptedException ex) {}
Wakes every second, walks the currently-running ticks, and past a five-second threshold:
final long elapsed = now - tick.lastPrint;
if (elapsed <= TimeUnit.SECONDS.toNanos(5L)) {
continue;
}
it names the offender by world and chunk coordinate:
LOGGER.error(
"Tick region located in world '" + region.region.world.getWorld().getName() + "' around chunk '"
+ region.region.region.getCenterChunk() + "' has not responded in " + totalElapsedS + "s:"
);
then dumps that thread's stack.
Read what that gives you operationally. A region hangs, and instead of a server-wide freeze and a generic watchdog dump, you get a log line saying this world, these coordinates, this long, here's the stack. You can fly there and look at it. Five seconds is a hundred missed ticks — well past salvage, but the point isn't rescue, it's telling you exactly where the body is.
That's a pass where the expediter calls out which station is dying, by name, instead of everyone slowly realising the tickets stopped moving.
The Hardware Bill
Folia's own configuration guidance, from a test server that peaked around 330 players:
Ideally, at least 16 cores (not threads).
Cores, not threads, italicised in the original. Your 8-core/16-thread VPS does not qualify.
And the thread budget is not "give it everything." Their rough allocation, per 200–300 players: about 4 threads for netty IO, about 3 for chunk system IO, about 2 for chunk system workers if the world is pre-generated — and if it isn't, they gave 16 threads to chunk generation on the test server and it was still slow at 300 players.
Then GC. The README's own answer on GC settings is ????, followed by the actually useful part: concurrent GC threads count against your budget and you need to know exactly how many, via -XX:ConcGCThreads=n. Not ParallelGCThreads, which only runs during a pause and shouldn't be counted.
Whatever's left, up to 80% of total cores, goes to tick threads. Not 100%:
The reason you should not allocate more than 80% of the cores is due to the fact that plugins or even the server may make use of additional threads that you cannot configure or even predict.
That's headroom, and it's the same reason you don't fill every inch of counter space. You need somewhere to put the thing you didn't plan for.
Three Public Tests, and Why the Biggest Machine Did Worst
Folia has been stress-tested in public three times, with writeups, hardware specs, configs and Grafana snapshots. Not vibes. Numbers, from the people who built it — PaperMC's own launch test, and two run by Cubxity alongside Spottedleaf, the person who wrote Folia.
| Test 1 (Mar 2023) | Test 2 (Jun 2023) | Test 3 (Jul 2023) | |
|---|---|---|---|
| CPU | 2× EPYC 7713 — 128c / 256t | Ryzen 9 7950X3D — 16c / 32t | EPYC 9654 — 96c / 192t |
| RAM | 2 TB | 128 GB | ~1.5 TB |
| Heap | 500 GB | 100 GB | 500 GB |
| World | ungenerated | 100k × 100k pre-gen | 200k × 200k pre-gen |
| Peak players | ~320 | ~630 at 20 TPS | ~501, no lag |
Look at that table until it bothers you.
The machine with 256 threads and two terabytes of RAM managed 320 players. A consumer Ryzen with 32 threads did nearly twice that. The 192-thread EPYC landed in between. More hardware, fewer players, twice.
That is not a bug in Folia and it is not sampling noise. It's three different bottlenecks, and untangling them tells you more about running a server than any TPS chart will.
Test 1 ran on an ungenerated map. Every player walking outward was forcing terrain generation in real time. Chunk generation is expensive, it is not what region threading parallelises, and no number of cores fixes it. PaperMC's own writeup notes the regioniser fully saturated 32 CPU threads with no apparent bottleneck — the regioniser was fine. The world generator was the wall. Folia's README says it flatly: pre-generate your world, or your chunk workers eat the machine.
Test 2 — the one with Spottedleaf on it — was the only one that actually hit a ceiling. Pre-generated world, thousand players, and it lagged, then crashed, then stabilised at 630 on re-tuned threads. That's a real limit.
Test 3 didn't hit a ceiling at all — it ran out of people. 501 players on a 96-core EPYC, no lag, and the telling detail:
Folia used around 37 threads at its peak.
Thirty-seven, on a machine with 192. They configured 70 region threads and Folia never needed them. The event ended; the hardware never broke a sweat. Cubxity's extrapolation — if it scaled linearly — put the box somewhere north of 1,300 players, and he labels it hypothetical rather than dressing it up as a benchmark.
So the honest read across all three: the number nobody published is Folia's actual ceiling, because nobody has managed to gather enough players to find it. What got measured instead was chunk generation, a patch bug, and the size of a Twitch audience.
The Thread Ratios Somebody Actually Measured
Test 3 is the useful one for capacity planning, because it correlated thread usage against player count with real metrics rather than guesses:
- 1 tick thread per 30–50 players
- 1 netty thread per 300–400 players — with network compression disabled
- 1 chunk IO thread per 200–300 players
And immediately after, the caveat, which I'm keeping because removing it would be dishonest:
Be aware that these numbers are specific to this test and the hardware used. This is not a general recommendation and your mileage may vary.
That netty ratio in particular is doing heavy lifting under a condition most people won't replicate — compression off, because a Velocity proxy was handling it downstream. Turn compression back on and that number changes.
One more detail worth stealing. Tick threads peaked around 20 and chunk workers peaked around 10 — on a fully pre-generated world, where in theory there's nothing to generate. Cubxity's read is that it was region saving, not loading. Writing the world back to disk is not free, it scales with how much world is being touched, and it is invisible in every capacity estimate I've ever seen anyone write.
For scale, here's how EterNity framed the baseline in PaperMC's Folia announcement writeup:
The current maximum capacity for a Minecraft server with Vanilla-like configuration is around 60 - 80 players.
Sixty to eighty, vanilla-ish, single thread. Against 630 on a consumer Ryzen. That's the story — not the four-figure headline.
The Garbage Collector Becomes the Story
Test 2 produced the number that should worry you most:
During the period when 1,000 players were online, we reached a maximum of ~7.9GB/s heap allocation and our GC was hovering around 2-3GB/s when averaged over a minute.
7.9 gigabytes per second of heap allocation. Sustained multiple gigabytes per second. At that rate you churn a 100GB heap in well under a minute, over and over, forever.
Both large tests reached for the same answer, trimmed to what matters:
-Xms100G
-Xmx100G
-XX:+AlwaysPreTouch
-XX:+UseLargePages
-XX:LargePageSizeInBytes=2M
-XX:+UseShenandoahGC
-XX:ShenandoahGCMode=generational
-XX:ParallelGCThreads=10
-XX:ConcGCThreads=3
Every flag is doing work. -Xms equal to -Xmx with AlwaysPreTouch makes the JVM claim and touch the entire heap at startup instead of growing it during service — you pay once, cold, before anyone is online. Large pages cut TLB pressure on a heap that size.
Then there's ShenandoahGCMode=generational, and this is the part that should tell you what kind of test this was. Generational Shenandoah did not exist in Java 21. JEP 404 was targeted at JDK 21 and then pulled in June 2023 — two weeks before this test — because the Shenandoah team decided the review risk was too high and they'd rather ship it properly later. It landed as experimental in JDK 24, and only became a production feature in JDK 25 under JEP 521.
So what were they running? Read their own version string:
openjdk version "21-testing" 2023-09-19
OpenJDK Runtime Environment (build 21-testing-builds.shipilev.net-openjdk-jdk-shenandoah-b110-20230615)
A personal build server. builds.shipilev.net, Aleksey Shipilëv's own CI, on the jdk-shenandoah branch, built 15 June 2023 — a garbage collector that had just been yanked from the release everybody else would get in September.
That is a spectacular thing to put a thousand people on. It is also exactly the right call for what they were doing: the workload is enormous heap, brutal allocation rate, hard pause budget, and generational Shenandoah is purpose-built for precisely that shape. They knew what they were reaching for.
The result:
The median GC pause duration was ~3 ms.
Three milliseconds against a 50ms tick budget. Six percent of a tick.
Now the part where I stop you from copying those flags. PaperMC's own guidance, in the same breath as publishing the 500GB Shenandoah config:
No, it was just a rare chance for us to test Shenandoah GC as this is the perfect environment it is made for. G1GC with Aikar's flags is still recommended and the most suitable GC for the majority of Minecraft servers.
The people who ran a half-terabyte heap on Shenandoah are telling you to use G1. Those flags were tuned for a 96-core box with 1.5TB of RAM and an audience of 500 people. On your 16-core dedicated they are not "the pro settings," they are cargo cult.
And note ConcGCThreads in both configs — 3 in test 2, 5 in test 3. Those threads are counted in the budget, exactly as Folia's README insists, because concurrent GC runs while your regions are ticking, competing for the same cores. Leave them out of your arithmetic and you've over-allocated the machine without knowing it.
That's your porter working the same floor as the line. Not optional, not free, and if you pretend they aren't there you'll wonder why the line keeps stalling.
It Is Actually in Production
Stress tests are stress tests. The more convincing evidence is servers with genuinely hostile workloads running this daily — with one caveat I'll get to.
PaperMC's own writeup names two. 2b2t — founded December 2010, the oldest anarchy server there is, a place defined by lag machines, absurd entity counts and fifteen years of accumulated griefing — is described as having moved to Folia, with the 1.19 update thought impossible beforehand and smooth sailing since. DonutSMP is described as running multiple Folia instances across its network, with over 460,000 entities on its clusters and thousands of concurrent players daily.
Here's the caveat, and it matters: both of those claims come from PaperMC's own announcement writeup. Neither server has published its own numbers, and I could not find independent confirmation from 2b2t or DonutSMP directly. Treat them as the project's characterisation of its own adoption rather than audited third-party figures. They are almost certainly broadly true — a claim that specific about named servers would get corrected fast — but "the vendor says two big servers use it" is a weaker sentence than it first appears, and you deserve to know which one you're reading.
Worth avoiding a trap here too: 2b2t.es, a Spanish anarchy server, publicly announced its own Folia migration in 2025. Different server, different operators, easy to conflate with 2b2t if you're skimming search results.
Anarchy servers are the hardest possible test of the region model, because lag machines are deliberate attempts to blow the tick budget. And that's exactly where regionalisation pays: a lag machine in one region lags that region. The griefer succeeds at wrecking their own corner of the map and nobody else notices. Fifteen years of a whole-server denial-of-service technique, defeated by partitioning.
Who This Is Actually For
Folia's win comes from players being spread out. Separate regions tick on separate threads. Skyblock, large SMPs, anything where people occupy their own corners of the map — that's the shape that scales.
Now picture the opposite. Forty players standing in spawn. That's one region, because they're all in the same cluster of chunks. One region is one thread. You just installed a multithreaded server and got single-threaded performance, plus scheduler overhead, plus a plugin ecosystem you had to rebuild.
Folia is not a performance patch. It is a different architecture with different failure modes, and it pays off for exactly one workload shape. If your players cluster, you have a spatial problem, not a threading problem, and the fix is somewhere else entirely.
Which is the honest summary of fifteen years of this. Minecraft runs on one thread because the code was written that way before anyone knew it would matter, and the only way anybody found to change it was to stop making threads share the world at all. Folia's answer isn't a smarter lock. It's the oldest trick in professional kitchens: give everybody their own station, their own mise, and a rule that you never reach into somebody else's.
It works. It just means rewriting every recipe in the book.