Most game servers run their simulation in a single main loop, so lag usually comes from one saturated CPU core, memory pressure, dropped UDP packets or game settings that ask for more work than the hardware can do. In this tutorial you will measure where the bottleneck is on an Ubuntu 24.04 server and then apply targeted fixes: process priority and CPU pinning through systemd, memory limits and swap behaviour, UDP buffer sizes and game-level settings. Tuning without measuring first often makes things worse, so every change here is paired with a way to check it.

Prerequisites

To follow this guide you need:

  • A server running Ubuntu 24.04 LTS, for example a CubePath VPS, with at least 2 vCPU.
  • A non-root user with sudo privileges.
  • A game server that already runs as a systemd service. This guide calls it gameserver.service; replace the name with yours. Our SteamCMD guide shows how to set one up.

Step 1 - Installing the measurement tools

sysstat provides mpstat, pidstat and iostat, which show per-core, per-process and per-disk usage over time. htop gives an interactive view:

sudo apt update
sudo apt install sysstat htop

Store the main PID of your game server in a shell variable, since several commands below need it:

PID=$(systemctl show -p MainPID --value gameserver.service)
echo "$PID"

If this prints 0, the service is not running. Start it before continuing.

Step 2 - Finding the bottleneck

Run these checks while players are online and the server feels slow. Numbers taken on an idle server tell you nothing.

CPU: look per core, not in total

A 4-core server at 25% total usage can still lag if one core is at 100%. Show usage per core every second:

mpstat -P ALL 1 5
CPU    %usr   %nice    %sys %iowait    %irq   %soft  %steal  %idle
all   27.10    0.00    2.30    0.10    0.00    0.40    0.20   69.90
  0   96.00    0.00    3.00    0.00    0.00    1.00    0.00    0.00
  1    6.00    0.00    2.00    0.00    0.00    0.00    0.50   91.50

Here core 0 is saturated: the game's main thread is CPU bound and more cores will not help. Faster cores, a lighter configuration (Step 6) or fewer players will.

Also watch the %steal column. Steal time is CPU time the hypervisor gave to other guests. Sustained values above a few percent mean the host is oversubscribed, and no tuning inside the VM will fix it.

Check the game process and its threads:

pidstat -t -u -p "$PID" 1 5

One thread close to 100% confirms a main-loop bottleneck.

Memory: check for swapping

free -h
vmstat 1 5

In vmstat, non-zero values in the si and so (swap in, swap out) columns during play mean the server is paging to disk, which causes stutter every time the game touches a swapped-out page.

Network: check for dropped UDP packets

Most games use UDP. When packets arrive faster than the server reads them, the kernel drops them silently and players see rubber-banding. Check the counters:

nstat -az | grep -E 'UdpInErrors|UdpRcvbufErrors'
UdpInErrors                     0                  0.0
UdpRcvbufErrors                 0                  0.0

Run it twice a few minutes apart. If UdpRcvbufErrors keeps growing, the socket receive buffer is too small (fixed in Step 5).

Disk: check for I/O waits

iostat -x 1 5

High %util together with high %iowait in mpstat during autosaves means disk is the bottleneck. Keep world and save data on local SSD storage and avoid running backups at peak time.

Step 3 - Setting priority and CPU pinning with systemd

Only apply this step if other processes on the same server (backups, a database, a second game server) compete with the game for CPU. Set the options in a drop-in file so your changes survive updates to the original unit:

sudo systemctl edit gameserver.service

Add the following between the comment lines the editor shows:

[Service]
Nice=-5
CPUAffinity=1 2
IOSchedulingClass=best-effort
IOSchedulingPriority=2

What each line does:

  • Nice=-5 gives the game a higher CPU scheduling priority than normal processes (default is 0). Avoid values below -10; they can starve SSH and system services under load.
  • CPUAffinity=1 2 pins the process to cores 1 and 2, leaving core 0 for the kernel, SSH and other tasks. Pinning helps when you run several game servers and want to keep them off each other's cores. On a 2 vCPU server, skip it.
  • IOSchedulingPriority=2 gives the server's disk I/O slightly higher priority than normal (0 is highest, 7 lowest).

Restart the service to apply the changes:

sudo systemctl restart gameserver.service

Verify the new values on the running process:

PID=$(systemctl show -p MainPID --value gameserver.service)
ps -o pid,ni,comm -p "$PID"
taskset -cp "$PID"
    PID  NI COMMAND
   2481  -5 java
pid 2481's current affinity list: 1,2

Avoid real-time scheduling (chrt, CPUSchedulingPolicy=fifo) for game servers. A busy main loop with real-time priority can lock up the whole machine.

Step 4 - Controlling memory use and swapping

Ubuntu's default vm.swappiness of 60 lets the kernel swap out idle memory fairly early. A game server holds its world in RAM and touches it constantly, so lowering swappiness reduces stutter. Create a sysctl file:

sudo nano /etc/sysctl.d/60-gameserver.conf
vm.swappiness = 10

Apply it and check:

sudo sysctl --system
sysctl vm.swappiness
vm.swappiness = 10

Keep a small swap file anyway; it gives the kernel room to push out genuinely idle pages instead of invoking the OOM killer.

If several services share the server, cap the game with a hard memory limit so a memory leak kills the game rather than the whole system. Open the drop-in from Step 3 again:

sudo systemctl edit gameserver.service

Add MemoryMax under the existing [Service] section:

[Service]
Nice=-5
CPUAffinity=1 2
IOSchedulingClass=best-effort
IOSchedulingPriority=2
MemoryMax=6G

Restart and verify the limit:

sudo systemctl restart gameserver.service
systemctl show -p MemoryMax gameserver.service

For Java servers, set MemoryMax about 1 to 1.5 GB above the heap size (-Xmx), because the JVM uses memory outside the heap too.

JVM flags for Minecraft and other Java servers

For Java game servers, set the minimum and maximum heap to the same value and use the G1 garbage collector with a pause target. A solid starting point for a 4 GB heap:

java -Xms4G -Xmx4G -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:+ParallelRefProcEnabled -XX:+DisableExplicitGC -XX:+AlwaysPreTouch -jar server.jar --nogui

-XX:+AlwaysPreTouch makes the JVM reserve the whole heap at startup, which avoids page faults during play but means the process uses its full heap right away. Do not give the heap more than about 70% of the server's RAM.

Step 5 - Enlarging UDP buffers

If Step 2 showed a growing UdpRcvbufErrors counter, raise the maximum socket buffer sizes and the backlog of packets the kernel queues per interface. Add these lines to the same sysctl file:

sudo nano /etc/sysctl.d/60-gameserver.conf
vm.swappiness = 10
net.core.rmem_max = 8388608
net.core.wmem_max = 8388608
net.core.rmem_default = 1048576
net.core.wmem_default = 1048576
net.core.netdev_max_backlog = 5000

rmem_default and wmem_default raise the buffer size for sockets that do not request one explicitly, which is the case for many game servers. rmem_max and wmem_max are the ceiling for programs that do ask for bigger buffers.

Apply the settings:

sudo sysctl --system
sysctl net.core.rmem_default net.core.rmem_max
net.core.rmem_default = 1048576
net.core.rmem_max = 8388608

Restart the game server so it opens its sockets with the new defaults, then watch nstat -az | grep UdpRcvbufErrors again during play. The counter should stop increasing.

TCP congestion control settings such as BBR do not affect UDP game traffic, so there is no need to change them for the game itself.

Step 6 - Tuning the game settings

Kernel tuning gains a few percent; game settings often gain much more, because they decide how much work each tick does. The exact keys depend on the game. For a Minecraft server, the most effective options are in server.properties:

view-distance=8
simulation-distance=6
network-compression-threshold=256
  • view-distance sets how many chunks are sent to each player. Every step up increases chunk loading and bandwidth considerably.
  • simulation-distance sets how far from players entities and redstone are ticked. Lowering it is usually the single biggest CPU saving.
  • network-compression-threshold sets the packet size above which packets are compressed. The default of 256 is fine for most servers; raising it trades CPU for bandwidth.

Similar rules apply to other games:

  • Keep the tick rate at the game's default unless you know the client supports and benefits from more. A higher tick rate multiplies CPU use per player.
  • Cap the player count to what one core can simulate at full tick rate, based on your measurements from Step 2.
  • Remove unused plugins and mods, and pre-generate large worlds so the server does not generate terrain during play.

Restart the server after changing its configuration and repeat the Step 2 checks with players online to compare.

Step 7 - Checking the CPU frequency governor

On dedicated servers, the powersave governor can keep cores at a low clock and add latency. Check whether frequency scaling is exposed at all:

cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor

On most virtual machines, including cloud VPS instances, this file does not exist because the hypervisor manages CPU frequency. In that case there is nothing to change. On bare metal that reports powersave, install linux-tools-common and linux-tools-$(uname -r) and set the governor with sudo cpupower frequency-set -g performance.

Troubleshooting

The service fails to start after the drop-in: run sudo systemctl status gameserver.service and sudo journalctl -u gameserver.service -n 50. A typo in a directive name shows up as Unknown key name. Fix it with sudo systemctl edit gameserver.service.

The process is killed with oom-kill in the journal: MemoryMax is lower than what the game really uses. Raise it, or lower the game's own memory setting (for Java, -Xmx).

Lag remains while all counters look healthy: measure latency from the players' side. High latency or packet loss between the players and the server is a network path issue, not a server tuning one; run mtr your_server_ip from a client to see where it starts.

Conclusion

You measured per-core CPU usage, steal time, swapping, UDP drops and disk waits, and then applied fixes only where the data pointed: systemd priority and pinning, memory limits and swappiness, larger UDP buffers and lighter game settings. Keep the measurement commands handy and rerun them after every game update or player growth. As next steps, set up long-term metrics with a monitoring stack, add host-level flood filtering as described in our game server DDoS protection guide, and consider moving busy servers to a plan with faster dedicated cores.