All articles
Running a Minecraft Server Is Only Half the Hosting Problem
20 September 2025Minecraft, Hosting, JVM, Self-hosting

Running a Minecraft Server Is Only Half the Hosting Problem

From a school Minecraft event to tick budgets, JVM memory and the coordination needed to stop and wake hosted worlds.

The Minecraft event I helped organise for my school ran on my gaming PC and brought together more than 50 players, according to my project account. Preparing it meant checking game modes, maps and permissions before people arrived. Later, when I hosted independent servers on a dedicated machine, the problem changed: an empty world could still occupy resources that another instance needed.

That progression led me from tuning a running Java process to working on when it should run at all. The second problem introduces a new obligation. If I deliberately stop a world, something must still receive a returning player's request, start the right process once, and distinguish a world that is loading from one that can actually accept that player.

The event, hosting progression and lifecycle work are my reported experience. The calculations and execution traces below are illustrative analyses; the cited implementations are external examples. My account does not include historical profiles or orchestration code, so they cannot establish a measured capacity increase or a verified wake-up protocol.

The event needed a usable game, not a running process

The event included activities with different rules. A competitive arena, a building competition and a lobby cannot simply share every permission. I used LuckPerms to separate administrative capabilities from ordinary player permissions and spent time testing the activities before the event.

A useful operational check therefore asks whether an ordinary participant can perform the intended activity while administrative operations remain restricted. Seeing the server process in a task list answers a much smaller question. A successful administrator test can also conceal a problem that affects a player with fewer permissions.

The reported player count describes the scale of the event. It is not a hardware capacity benchmark. The supplied account has no concurrent-player trace, tick distribution or versioned configuration from which to reconstruct the load. The more defensible lesson is that preparation had to cover the experience of real participants, rather than the availability of the executable alone.

I had started with small worlds on my gaming PC and learned Linux and remote administration through a Raspberry Pi. Dedicated hosting later separated those workloads from my everyday computer. DNS and port forwarding made public access possible; multiple instances, a RAM upgrade and work on a hosting website introduced shared-resource and lifecycle questions.

Each stage widened the boundary of the service. For the event, I needed a prepared game at a scheduled time. For independent hosted worlds, I also needed a policy for the hours when nobody was playing.

A tick has a deadline that heap size cannot extend

Paper's command reference describes a target of 20 ticks per second and a corresponding 50 ms processing budget. The tick advances gameplay state. Its work can include entities, world updates and plugin callbacks, with asynchronous work and scheduling details depending on the implementation.

That budget makes the location of a delay important. Suppose an illustrative tick needs 38 ms of computation. During that same scheduled interval, a stop-the-world collection blocks its progress for another 18 ms. Assuming those durations do not overlap and ignoring other work, completion takes 56 ms: 6 ms beyond the next scheduled boundary.

This does not predict an average TPS value. It demonstrates that a pause which looks small in isolation can consume the remaining tick margin. Plugin work, chunk activity, storage waits and host scheduling can consume it too. More heap may alter collection behavior, but it does not grant a longer gameplay deadline.

A GC setting is not that deadline either. In the Java 21 command reference, MaxGCPauseMillis is a best-effort soft goal; G1's documented default is 200 ms. Setting a target does not guarantee that every pause meets it, or that the entire tick finishes within 50 ms. The runtime reference explains the setting without identifying the Java release used in my installation.

Diagnosis needs the workload while the problem occurs. Paper's profiling guidance explicitly makes that requirement and describes its bundled spark profiler for Paper 1.21 onward. A profile should help locate work and pauses; it does not turn every slow tick into a garbage-collection problem.

The distinction also changes how I would interpret the school event. Fifty players moving through a prepared arena and fifty players loading new terrain or exercising different plugins need not impose comparable work.

Player count is an input, not a complete workload

The peer-reviewed Yardstick paper, by Jerom van der Sar, Jesse Donkervliet and Alexandru Iosup, treats that problem as part of benchmark design. It varies world complexity, player behavior, join strategy and other settings while collecting application and system metrics.

The experiments separate client emulation from the server and test Minecraft 1.11.2 with 2017 Spigot and Glowstone builds. Their rankings cannot be carried forward as a recommendation for a current server.

One result is useful for reasoning about comparisons. In Figure 4, the fixed condition targeting 150 clients gives an approximate median of 15 Hz for Glowstone; the increasing-load condition at 150 measured connected clients gives about 20 Hz. The protocols have different histories: the fixed experiment ramps to a count and runs for an hour, whereas the increasing experiment adds clients over time. This is not an isolated causal estimate of join strategy. Client counts need their measurement definition as well as workload history.

The paper also changes what clients do, including walking and modifying blocks. Those choices are more informative than treating every connection as an interchangeable unit of load. An event's player count matters alongside its maps, actions, plugins, configuration and arrival pattern.

The heap and the process are different accounting boundaries

The memory problem appeared when I ran several Java instances. An idle world could leave a substantial process present even when its players had gone. Understanding that footprint requires more than reading one heap setting.

QuantityWhat it describesWhat it does not establish
Used Java heapHeap occupied by objects at an observation pointTotal resident memory of the process
Committed Java heapHeap capacity made available to the runtimeThat all of it contains live objects or is resident
Maximum heapThe configured upper bound on heap growthA complete cap on native and process overhead
Resident process mappingsPages currently resident for the processA unique attribution of every shared page

The Java 21 reference specifies -Xms as both the initial and minimum heap size and -Xmx as the maximum heap size. Native structures, thread stacks and other mappings exist outside that maximum. Paper's flag explanation also warns that the heap is not the entire process footprint.

At the operating-system boundary, Linux's smaps interface distinguishes resident size from proportional attribution of shared mappings. Summing process RSS can count shared pages more than once. A capacity argument needs an accounting method suited to the question, rather than adding heap limits and calling the result actual RAM consumption.

This matters when comparing “idle” configurations. A JVM may have little live application data but retain committed heap. Alternatively, resident mappings can change without the application having stopped. Neither observation alone explains how much additional work the host can safely admit.

What the flags I investigated actually control

I investigated Aikar-style startup arguments, including G1ReservePercent and DisableExplicitGC. Their mechanisms are more useful than treating the flag set as a general promise of faster Minecraft.

G1 divides the heap into regions. Collection may need space into which live objects can be evacuated. The Java 21 specification of the option describes G1ReservePercent as a false ceiling intended to reduce promotion-failure risk.

Setting it to 20 concerns headroom within the collector's heap policy. It does not tell the operating system to use 20% less physical memory. Increasing reserve changes the balance between usable allocation space and collection headroom; the appropriate balance depends on workload and heap sizing.

DisableExplicitGC concerns another path. It tells the JVM to ignore processing of System.gc() calls, while normal necessary collection continues. This can be relevant when a plugin or library requests collections, but it does not remove automatic pauses or every collection request from external tooling.

There is also a trade-off in fixing initial and maximum heap to the same value. Oracle's G1 tuning guide describes equal sizing as a way to avoid resizing work and potentially improve pause consistency. It also recommends starting with defaults and tuning against an observed problem.

A fixed large minimum is a different choice on a host with several intermittently used instances than on a machine dedicated to one busy world. Reducing resizing work and retaining flexibility are competing objectives. The collector can resize under suitable conditions; it is inaccurate to say that a running JVM can never return memory. It is equally inaccurate to assume an empty server promptly returns all its footprint.

Stopping changes the resource model and the service model

Three mechanisms are easy to confuse:

MechanismWhat remainsPrincipal trade-off
Reduce or reclaim heap within a running JVMProcess, runtime and application stateCollection and resizing work; possible later regrowth
Pause executionProcess address space and application stateLess execution, without inherently releasing the mapped memory
Terminate and later restartPersistent world/configuration plus an external startup pathRelease of process resources in exchange for cold-start work and interruption

The maintained Docker Minecraft project's auto-pause documentation describes a paused Java process, while its auto-stop documentation describes stopping. Their watchdog and restart-policy interactions reinforce that these are different lifecycle operations.

Termination removes the process address space. Shared backing and cached files can remain, so it need not appear as an equal immediate increase in a host's “free memory” display. The gain that matters is additional usable capacity under a consistent accounting method.

I worked on idle shutdown because tuning a JVM that remains alive cannot make that entire process cease to exist. But stopping also removes the listener that would ordinarily receive a player. Another component must remain available to recognise demand.

A restart-always policy can defeat intentional stopping by immediately recreating the backend. At the other extreme, stopping without a wake handler leaves players with a closed endpoint. The lifecycle policy has to agree across the application and its supervisor.

A returning player needs one startup, not several

A persistent proxy or gateway is one researched way to retain the public endpoint while a backend sleeps. The project's autoscaling overview distinguishes Minecraft-aware routing from generic traffic-triggered mechanisms. A packet threshold can react to pings or scans; it is not automatically evidence of a player intending to join.

lazymc is an external implementation of the proxy pattern. It is useful here as inspected source, not as a claim about the software I deployed.

Consider two requests arriving while one world is stopped:

StepRequest ARequest B
Separate checkObserves STOPPEDObserves STOPPED
Unguarded actionLaunches a processAlso launches a process
Required decisionOne request reserves STARTINGThe other observes the reservation and waits or receives a starting response

The problem is that observing a state and changing it are separate actions. A correct check must be coupled to the authority to perform the transition.

In the pinned lazymc server implementation, startup first attempts an atomic compare-and-exchange from STOPPED to STARTING. If the expected state no longer matches, that invocation returns without spawning. A successful reservation proceeds to the startup path.

This demonstrates one in-memory guard within one controller. It does not establish ownership across independent controllers, durable state after a crash or a full correctness proof of the program. Those require their own coordination and reconciliation rules.

The consequence of duplicate startup is also more serious than wasted CPU when two processes can write the same world. Resource optimisation must preserve single-writer ownership, not merely make launches less frequent.

Starting is not yet the player's success condition

The backend's state name needs interpretation. In the same pinned implementation, the monitor requests protocol status, and a received status can promote the lifecycle state to STARTED. That is stronger than process creation and narrower than proving the intended player can enter and use the world.

A separate login-probing path exists in the codebase, but the status-driven transition should not be described as waiting for every gameplay check. A service's readiness condition must name the operation it promises.

The hold implementation makes the waiting cost concrete: a client waits on lifecycle notifications until the backend becomes STARTED, stops, or reaches a configured timeout. Startup is therefore part of the connection experience. A held connection does not guarantee that a slow or incompatible backend will become transparent to the player.

My intended lifecycle work included reducing perceived delay. A useful measurement would cover demand recognition, startup, runtime and plugin/world loading, and actual admission. Reporting only successful cold joins would omit the users whose attempts timed out.

The last disconnect can race the next arrival

Idle shutdown adds a second transition that needs authority. A sampled player count of zero describes a moment; it does not establish that no new request is being admitted.

A proposed controller could treat demand and inactivity decisions as events processed by one authority. Before requesting shutdown, it would reserve STOPPING and stop admitting new players to that process. A new arrival during STOPPING would record pending demand and receive an explicit waiting or retry response. The replacement would start only after the old writer had exited.

Lifecycle cycle STOPPED, STARTING, STARTED, STOPPING with the transition that guards each stepEach transition is reserved by one authority, so a returning player cannot trigger a second launch. Diagram: Daniel Buttigieg

That is a proposed contract, not a reconstruction of my code. Its value is that the race has a defined result. “Zero players, therefore stop” leaves the interaction with a new arrival unspecified.

The inspected lazymc stop path sends an RCON stop command and enters STOPPING; the child-exit path later clears its PID and sets STOPPED. A command being accepted is therefore distinct from process termination. Neither observation alone is a proof of application-level durable saving, especially if a timeout leads to forced termination.

On controller restart, the desired state, observed process and persistent world must be reconciled before launching another writer. Host-wide startup admission needs a separate capacity check too: making one world start only once does not prevent several different worlds exhausting the same host during simultaneous cold starts.

The result I would measure next

For the running game, I would retain versions, maps, player behavior, plugin configuration and per-tick distributions alongside GC and process-memory observations. For the sleeping service, I would test cold joins, simultaneous arrivals, demand during shutdown, failed startup and controller restart. Each attempt would end in an observed admission, explicit failure or timeout.

The resource comparison would include the gateway and cold-start peaks, not just the memory absent while a JVM is stopped. It would also distinguish reduced process occupancy from a claim about electrical energy, which needs its own measurement.

The school event gave me a concrete experience of preparing a service for other people. Idle hosting widened that responsibility: deliberately withdrawing a backend makes its return path part of the system. The next result worth retaining is a trace that joins the resource decision to the player's outcome, including the attempts for which the world never became ready.


Image credits