How to Debug Minecraft Server Lag with Spark Profiler (2026 Guide)
Your server dropped to 12 TPS. You copied Aikar's flags. You upgraded Java. Nothing changed. That's because JVM flags don't fix plugin bugs. Here's how to find what's actually killing your tick budget.
Your server is sitting at 12 TPS. Players are rubber-banding. Chat is delayed. Someone in your Discord just posted a screenshot of /tps and it reads 12.4, 11.8, 10.2 and tagged you in it.
You've been through the checklist. You copied Aikar's G1GC flags. You read our breakdown of whether to upgrade from Java 21 to Java 26 and you're on Java 25. Nothing changed. TPS is still a disaster.
This is because JVM flags don't fix plugin bugs. A better garbage collector doesn't fix a shop plugin firing 40 synchronous database queries per tick. Java 26's G1GC throughput improvements don't fix an entity AI pathfinding loop that's iterating 800 mobs on your main thread. You tuned the wrong layer.
The way you find the actual problem is with Spark. This article covers how to read its output, what the numbers tell you, and what to do about them.
The Tick Loop at the Code Level
Before you can read a profiler, you need to understand what it's profiling. A Minecraft server's main thread runs a tick loop. The target is 20 ticks per second, which means each tick has a 50 millisecond budget. The main thread wakes up, does all the work for that tick, and ideally finishes within 50ms. If it takes longer, the next tick starts late. When enough ticks are late, TPS drops.
The tick loop in PaperMC lives in net.minecraft.server.MinecraftServer. This is the version in Paper's main branch as of early 2026:
// Source: PaperMC/Paper - patches/server/0001-PaperMC-changes.patch
// https://github.com/PaperMC/Paper/blob/master/patches/server/0001-PaperMC-changes.patch
//
// The core tick scheduling logic in MinecraftServer
protected void runServer() {
try {
long lastTick = System.nanoTime();
long catchupTime = 0L;
long curTime;
long wait;
long tickSection = lastTick;
while (this.running) {
curTime = System.nanoTime();
long elapsedNanos = curTime - lastTick;
wait = TICK_TIME - elapsedNanos - catchupTime;
if (wait > 0L) {
Thread.sleep(wait / 1000000L);
}
catchupTime = Math.max(0L, Math.abs(wait) - this.oversleepAmount);
lastTick = curTime = System.nanoTime();
this.tick(shouldKeepTicking);
}
} catch (Exception e) {
// crash handling
}
}
TICK_TIME is 50,000,000 nanoseconds (50 milliseconds). The loop wakes up, checks how long the previous tick took, and if the server is behind, tries to catch up. When this.tick() consistently runs over 50ms, the server falls behind and never catches up. That's your lag.
Spark's profiler attaches to the JVM and samples this thread at a configurable rate. It records what the stack looks like at each sample. Over thousands of samples, you get a statistical picture of where your main thread is spending its time.
Installing Spark
Download the Spark plugin from spark.lucko.me and drop the .jar into your plugins/ folder. Restart. That's the entire installation.
Spark supports Paper, Spigot, Purpur, Folia, Velocity, BungeeCord, and Sponge. One jar, one installation location, it detects the platform automatically.
The relevant commands, in order of how you'll actually use them:
/spark tps — current TPS and MSPT (milliseconds per tick)
/spark gcmonitor — real-time GC pause notifications in chat
/spark profiler — start a sampling profiler session
/spark profiler stop — stop the session and generate a report URL
/spark health — combined health snapshot
You need operator permissions or a permissions plugin entry for spark.*. If you're running a network with Velocity or BungeeCord, install Spark on the proxy too. Proxy lag is a separate problem from backend server lag and they show up differently in the profiler.
Reading /spark tps
Run /spark tps on a loaded server. You'll see something like this:
TPS from last 5s, 1m, 5m, 15m: 19.8, 18.3, 16.4, 14.1
Tick durations (last 5s) (min/med/95%ile/max): 10.2/38.4/58.9/142.6 ms
The TPS numbers you already know. The tick duration percentiles are what most admins ignore, and they're the useful part.
min — the fastest tick in that window. This is your server with almost nothing happening. Useful for establishing a baseline.
med — median tick time. Half your ticks are faster, half are slower. If this is 38ms you have comfortable headroom. If this is 48ms you're right at the edge.
95%ile — 95th percentile tick time. One in twenty ticks takes at least this long. A 95th percentile of 58ms means 5% of your ticks are already over budget. That's not catastrophic but it's where rubber-banding starts. Players notice individual late ticks at 100ms+.
max — the single worst tick in the measurement window. A max of 142ms means at some point in the last 5 seconds, your server's main thread was blocked for nearly 3x the tick budget. Three ticks worth of time, one tick. That's a GC pause or a plugin doing something slow synchronously.
The pattern you're looking for: min is low, med is manageable, 95%ile spikes. That's either GC or an infrequent plugin task. If med itself is high, you have a constant problem, not a spike problem.
Is Your Garbage Collector the Problem?
Run /spark gcmonitor in chat. This hooks into the JVM's GC notification system and prints a message every time a GC event completes. Leave it running while players are online. Walk away. Come back in 20 minutes and read what showed up.
Spark prints GC events in a format like this:
[Spark] GC | G1 Young Generation | 1245ms since last | 38M -> 18M | pause 2.34ms
[Spark] GC | G1 Old Generation | 8230ms since last | 3891M -> 2134M | pause 45.12ms
[Spark] GC | G1 Full Collection | pause 2341.45ms — MAJOR STOP-THE-WORLD
Read these left to right:
Collector name — G1 Young Generation, G1 Old Generation, ZGC Minor, etc. Young generation collections are cheap. Old generation collections are expensive. A "Full Collection" on G1GC is a disaster.
Time since last — how frequently GC is running. G1 Young collections every 1-2 seconds is normal on a loaded server. Old generation collections every 8-15 seconds with G1 is okay. Old generation running every 2 seconds means your heap configuration is wrong — too much allocation is surviving into old gen.
Heap before/after — how much memory was reclaimed. 3891M -> 2134M means GC freed 1757MB. If after every collection the heap springs back to near-max quickly, you don't have enough heap allocated.
Pause — the stop-the-world time. Any G1 Young collection under 50ms is acceptable. Any pause over 100ms is a problem. A "Pause Full" event is catastrophic — that's the JVM freezing every thread including your tick thread for however long it takes to compact the heap. A Pause Full of 2341ms is 46 missing ticks. The server just ceased to exist for 2.3 seconds. Everyone on the server gets hit at once.
A Pause Full (G1 Compaction Pause) in your GC monitor output means your G1GC configuration is wrong before anything else. No Java version upgrade fixes this. Fix the flags.If you're seeing Pause Full events, your G1GC configuration needs the G1HeapRegionSize and InitiatingHeapOccupancyPercent tuned, or you need more heap, or you need to switch to Generational ZGC. Our Java upgrade article covers both configurations in detail.
If GC pauses are clean — Young collections under 15ms, Old collections rare and under 50ms, no Full pauses — your lag is not GC. Move on to the profiler.
Running the Spark Profiler
Wait until your server is under real player load. Lag you can't reproduce with live players doesn't appear in the profiler. Start the profiler and let it run for 3-5 minutes:
/spark profiler --timeout 300
Or start it manually and stop it when you've caught a lag spike:
/spark profiler
# ... wait through some rubber-banding incidents ...
/spark profiler stop
Spark uploads the report to a URL, usually https://spark.lucko.me/XXXXXXXXXXX. Open it in a browser.
You'll see a flame graph.
Reading the Flame Graph
This is where most server admins close the tab and go back to Googling flag combinations. Don't.
A flame graph represents time, not a call sequence. The width of each box is proportional to how many samples recorded that method on the stack. Wider means more time spent there. The boxes stack vertically representing the call chain — the bottom is the oldest frame, the top is where execution actually was when Spark took the sample.
The Spark flame graph puts your main server thread at the root. Everything else sits on top of it.
Look at the top of the flame graph first, not the bottom. The top is where time is actually being consumed. Wide boxes near the top are your suspects.
A healthy server's flame graph looks like this at the top: lots of different moderate-width boxes from vanilla Minecraft systems — entity AI, chunk loading, redstone. No single box dominates. Pathfinding is always present because A* is expensive. That's fine, it's supposed to be there.
An unhealthy flame graph has one or two wide boxes completely dominating the top that don't belong to vanilla code. A shop plugin's database call method at 60% of all samples is not fine. A custom enchants plugin's ability tick handler at 35% of all samples is not fine.
What the Code Behind Spark's Sampling Looks Like
Spark uses Java's AsyncGetCallTrace JVMTI capability to sample the thread stack without stopping the world. This is significant — the profiler itself adds minimal overhead, unlike profilers that require safe points. The core sampling loop in Spark uses a separate profiler thread that fires at the configured interval (the default is 4ms between samples). Here's the relevant part of Spark's AsyncSampler:
// Source: lucko/spark
// https://github.com/lucko/spark/blob/master/spark-common/src/main/java/me/lucko/spark/common/sampler/async/AsyncSampler.java
@Override
public void start() {
this.task = this.workerPool.scheduleAtFixedRate(
this::tick,
0,
this.interval,
TimeUnit.MICROSECONDS
);
}
private void tick() {
long time = System.currentTimeMillis();
AsyncProfiler profiler = this.profilerFuture.join();
for (ThreadGrouper.Group group : this.threadGrouper.getGroups()) {
profiler.execute("start,event=cpu,interval=" + this.interval);
}
}
The interval parameter is configurable with --interval on the profiler command. Shorter intervals give more resolution but more overhead. The default 4000 microseconds (4ms) is the right balance for production profiling. Spark uses the async-profiler native agent under the hood on Linux, which uses perf_events to get CPU-time sampling that bypasses the safepoint bias problem entirely. On your Linux server, Spark's data is accurate.
Flame Graph Patterns and What They Mean
Wide box labeled something like CraftScheduler.mainThreadHeartbeat
This is Bukkit's scheduler. A wide box here means plugin tasks scheduled with Bukkit.getScheduler().runTask() are consuming meaningful tick time. Click into it. Spark will expand the frame and show you which plugin's tasks are underneath. Find the widest box under that and you've found the plugin running too much synchronous work on the main thread.
The fix depends on the plugin. Tasks that don't need to modify world state (database reads, API calls, disk I/O) should move to runTaskAsynchronously(). Tasks that do need to modify world state but could batch their work should run less frequently. A shop plugin that queries a database on every buy event with a synchronous call needs to either go async or switch to an in-memory cache with async persistence.
Wide box from PathNavigation or GroundPathNavigation
Entity pathfinding. Vanilla Minecraft pathfinding runs synchronously on the main thread and uses A* with a configurable search depth. At scale, hundreds of mobs all running pathfinding simultaneously kills the tick budget.
Your options: Paper's max-auto-save-chunks-per-tick, entity activation range configuration in paper.yml, and entity culling. In config/paper-world-defaults.yml:
entities:
spawning:
per-player-mob-spawns: true
behavior:
max-entity-collisions: 8
entity-per-chunk-save-limit:
experience_orb: 16
arrow: 16
fireball: 8
Reducing view-distance and simulation-distance independently (Paper supports separate values) lets you keep a large visible range while reducing the simulation radius where entities tick.
# In paper-world.yml
chunks:
auto-save-interval: 6000
delay-chunk-unloads-by: 10s
If pathfinding is still your bottleneck after entity culling, the mob counts in your world are too high for your hardware. Mob farms with thousands of entities in a small area show up as a wide GroundPathNavigation block in the profiler every time. Find the farm, put a mob cap on it.
Wide box from RedstoneTorch or redstone
You have a large redstone contraption ticking constantly. The vanilla redstone engine is single-threaded and synchronous. Paper includes an alternative redstone implementation; check whether use-faster-eigencraft-redstone: true is set in paper.yml. Alternate Current (a Paper addon) replaces the vanilla propagation algorithm with one that runs in O(1) per signal versus O(n) for certain contraption sizes.
For contraptions that can't be optimized, the only answers are moving them to a chunk that doesn't tick (force-unload the chunk, don't force-load it) or removing them.
Wide box from a plugin you recognize doing something with Connection or PreparedStatement
Synchronous database call on the main thread. Full stop. This is the single most common source of serious Minecraft server lag that isn't GC-related.
Any database call on the main thread blocks that thread until the database responds. Network latency to your database host, query execution time, connection pool acquisition time — all of that eats tick budget. A shop plugin making a SELECT on every purchase blocks the main thread for the round-trip time to your database. Under load with multiple concurrent purchases, that's multiple 50ms+ blocks per second.
The fix is async execution with a callback that re-synchronizes to the main thread only when it needs to modify server state. Using Paper's scheduler:
// Source: PaperMC documentation pattern for async database operations
// https://docs.papermc.io/paper/scheduler
// WRONG: Synchronous database call on main thread (do not do this)
@EventHandler
public void onPlayerPurchase(PlayerInteractEvent event) {
DatabaseResult result = database.query("SELECT balance FROM accounts WHERE uuid = ?",
event.getPlayer().getUniqueId());
// Main thread blocked during entire query execution
processResult(event.getPlayer(), result);
}
// RIGHT: Async query, sync callback for world modification
@EventHandler
public void onPlayerPurchase(PlayerInteractEvent event) {
UUID playerId = event.getPlayer().getUniqueId();
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
// This runs on a separate thread - safe to block here
DatabaseResult result = database.query(
"SELECT balance FROM accounts WHERE uuid = ?",
playerId
);
// Re-sync to main thread only for the world state change
Bukkit.getScheduler().runTask(plugin, () -> {
Player player = Bukkit.getPlayer(playerId);
if (player != null && player.isOnline()) {
processResult(player, result);
}
});
});
}
This pattern keeps the main thread unblocked during the database operation. The main thread only gets touched during processResult(), which should be fast — just applying inventory changes and sending packets.
Reading GC Logs Directly
Spark's GC monitor gives you real-time output. For a historical picture and the ability to find patterns across hours of operation, enable JVM GC logging:
-Xlog:gc*:logs/gc.log:time,uptime,level,tags:filecount=5,filesize=20m
Add this to your startup script. It creates rotating log files in logs/gc.log, keeping the last 5 files at 20MB each. It costs nothing measurable in performance.
A normal G1 young collection looks like this in the log:
[2026-04-10T14:23:45.123+0000][5.432s][info][gc] GC(142) Pause Young (Normal) (G1 Evacuation Pause) 3891M->2134M(8192M) 45.123ms
Breaking this down left to right: wall clock timestamp, JVM uptime, log level, GC event number, pause type Pause Young, GC reason G1 Evacuation Pause, heap before and after, total heap size, pause duration. A 45ms young pause on a loaded server with Aikar's flags is on the high end but acceptable.
A bad event looks like this:
[2026-04-10T14:24:02.456+0000][22.765s][warn][gc] GC(143) Pause Full (G1 Compaction Pause) 7943M->3211M(8192M) 2341.456ms
Pause Full (G1 Compaction Pause). That's 2.3 seconds of every thread in the JVM completely stopped. 46 missed ticks. Every player on the server experienced simultaneous rubber-banding for 2.3 seconds and you didn't know why until you checked the GC logs.
Pause Full on G1GC triggers when the collector can't keep up with allocation and needs to compact the entire heap to free space. Causes: heap too small for your workload, young generation regions too small so objects are getting promoted too fast, InitiatingHeapOccupancyPercent set too high (G1 waits too long before starting concurrent marking and then falls behind). Aikar's flags set InitiatingHeapOccupancyPercent=15 for exactly this reason — G1 starts concurrent marking when only 15% of heap is occupied rather than the default 45%, giving it far more runway before it needs to fall back to a full stop-the-world.
If you're seeing Pause Full events in your GC logs and you're already running Aikar's flags with appropriate heap sizing, switch to Generational ZGC. ZGC cannot produce a Pause Full event. Its concurrent collection model means old generation compaction happens while your server runs. For details on the ZGC startup flags, our Java version upgrade article covers the exact configuration.
A related CoderOasis deep dive worth reading: Java Memory Management: Understanding the JVM Heap, GC, and Tuning Your Application — which covers the mechanics of how G1, ZGC, and Shenandoah approach the heap differently from first principles.
Automating Profiler Runs
You don't want to manually run /spark profiler every time players complain. Set up automated profiling so you always have recent data when problems occur.
Spark supports a REST API if you're using certain admin plugins, but the simpler approach is a cron job that sends the command via RCON. Install RCON tools (mcrcon works on Linux) and script it:
#!/bin/bash
# Source: community pattern for automated Spark profiling
# Run this script via cron every 6 hours during peak times
RCON_HOST="127.0.0.1"
RCON_PORT="25575"
RCON_PASS="your_rcon_password"
LOG_DIR="/opt/minecraft/profiler-logs"
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
mkdir -p "$LOG_DIR"
# Start profiler, wait 5 minutes, stop and capture the URL
mcrcon -H "$RCON_HOST" -P "$RCON_PORT" -p "$RCON_PASS" \
"spark profiler --timeout 300" 2>&1 | tee "$LOG_DIR/spark-$TIMESTAMP.log"
# Also capture a health snapshot
mcrcon -H "$RCON_HOST" -P "$RCON_PORT" -p "$RCON_PASS" \
"spark health --upload" 2>&1 | tee -a "$LOG_DIR/spark-$TIMESTAMP.log"
echo "Profiler session logged to $LOG_DIR/spark-$TIMESTAMP.log"
Add this to cron:
0 18,21,0 * * * /opt/minecraft/scripts/auto-profile.sh
This fires profiling sessions at 6pm, 9pm, and midnight — your peak hours on most servers. When players report lag at 9:30pm, you have a profiler report from 9pm to look at instead of trying to reproduce the problem the next morning with no players online.
The Spark Health Report
/spark health --upload gives you a combined snapshot across multiple dimensions at once. The output covers:
- Current TPS and tick duration statistics
- GC pause history from the JVM's built-in GC monitoring (not just what happened while Spark's monitor was active)
- CPU usage breakdown by thread
- Heap usage and composition
- Platform and plugin list
The GC section of the health report is particularly useful because it reads the JVM's internal GC history, which goes back further than Spark's own monitor. If your server has been running for 8 hours and you just started Spark, the health report will show you GC events from before Spark was running. This is why you should install Spark from day one, not just when things break.
A Complete Debugging Workflow
When players report lag:
- Run
/spark tps. Look at the 95th percentile and max tick durations. - Run
/spark gcmonitor. Wait 2 minutes under load. AnyPause Fullevents? Any pause over 50ms? If yes, fix GC configuration first before anything else. - If GC is clean, run
/spark profiler --timeout 300. Let it capture during live player load. - Open the profiler URL. Find the widest boxes near the top of the main thread flame graph.
- Identify what plugin or vanilla system owns that box.
- Fix the specific cause: move database calls async, reduce entity counts, disable problematic scheduled tasks.
- Restart with the fix and run
/spark tpsagain to confirm improvement. - If still unresolved, run
/spark health --uploadand post the URL in the PaperMC Discord or the relevant plugin's issue tracker. The URL contains enough diagnostic information for maintainers to give you actionable help.
What Spark Can't Tell You
Spark is a sampling profiler. It catches the things that consume CPU time. It does not directly show you:
Network throughput. If your server is spending 30ms per tick just on packet encoding and sending, Spark will show that time attributed to Netty's pipeline code, but it won't tell you that a specific player's render distance is forcing chunk packet generation for 500 chunks per second. Watch your actual network bandwidth under load — iftop or nethogs on Linux shows you per-process bandwidth in real time.
Disk I/O. World saves, region file reads, plugin data saves — these can cause lag spikes that Spark shows as time spent in file I/O code, but the root cause might be slow disk hardware rather than bad code. Check iostat -x 1 during a lag spike. If your disk is at 100% utilization during world saves, consider moving your world directory to an NVMe drive or tuning auto-save-interval.
Memory leaks. Spark shows heap usage at a point in time. A plugin that slowly leaks objects shows up in the health report as increasing heap usage over time, but you won't see the leak rate from a single profiler run. Enable GC logging (the -Xlog:gc* flag above), let the server run for 8-12 hours, and check whether heap usage trends upward between major GC cycles. A healthy server's heap oscillates — it goes up between collections and comes back down after. A leaking server's heap oscillates but each trough is slightly higher than the last.
Making Sense of This With The Bigger Picture
The mental model you want is layers. At the hardware layer: CPU speed and core count, RAM, disk I/O, network. At the JVM layer: garbage collector choice, heap sizing, JIT warmup, which Java version you're running. At the server software layer: Paper's async chunk loading and entity optimizations, Folia's regional multithreading if you have the player count and hardware for it. At the plugin layer: everything your plugins are doing on the main thread.
Spark operates primarily at the plugin and server software layers. It shows you what your code is doing. If Spark shows clean code but you still have lag, the problem is at the JVM or hardware layer — and that's where the Java 21 vs Java 26 decision becomes relevant, where GC configuration matters, where a better GC or more heap or a faster CPU is the actual answer.
Most servers have problems at the plugin layer. Fix those first. You're measuring CPU time — that's where Spark shines. Use it before you spend three hours tuning JVM flags that aren't your problem.
If you want to go deeper on what the JVM is doing with your memory at a lower level than Spark shows, our custom garbage collector implementation article walks through how mark-sweep, mark-compact, and copying collectors work from scratch. And if you're curious about what Java 22 and Java 26 actually changed in the JVM that makes Spark's profiling job easier (the secondary supertype cache fix in Java 23 that eliminated multi-threaded type-checking contention, for example), those articles cover the JEPs in detail.
The tools exist. The data exists the moment you start profiling. Use it.