Profiling (experimental)
/bench profile shows which code keeps your server's tick threads busy: which plugins, which parts of the server, which methods. It samples the tick threads with Java Flight Recorder (JFR), the profiler built into every Java runtime VoxelBench supports, so no extra download or native library is needed.
Experimental. The profiler works and is safe to use, but its output and settings may still change between versions. Profiles are written to
plugins/VoxelBench/reports/profiles/on your server. Nothing is sent to voxelbench.com unless you upload a profile to your account or share one by public link yourself, one at a time, with a command that names it (addpreviewto see what it contains before anything leaves).
It answers "who is running on the tick thread?", in percentages. It does not replace a benchmark: benchmarks measure how fast your server is, the profiler tells you where its time goes.
Quick Start
/bench profile → a player gets a form (duration, interval, keep/share/upload); the console captures 30 s
/bench profile 30 → profile the tick threads for 30 s, then show a summary
/bench profile 60 → the same over 60 s
/bench profile list → saved profiles
/bench profile show 1 → show the newest saved profile again
Start the capture before the load you want to look at. From the console, a command typed while the tick thread is blocked only runs once it is free again.
Commands
All profiler commands need voxelbench.profile (operators only by default), on top of voxelbench.use; upload also needs voxelbench.profile.upload, and share / unshare need voxelbench.profile.share. They all work from the console.
| Command | Description |
|---|---|
/bench profile [seconds] [period-ms] [upload|share] | Timed capture: 30 s by default (5 to 300), one sample requested every 10 ms by default (10 to 50). Values outside the bounds are refused, not silently changed. With upload (alias send) or share, the new profile is sent as soon as it is saved. Typed alone by a player, it opens a form to pick these options (see Forms) |
/bench profile ring on | Start the continuous ring buffer (see below) |
/bench profile ring off | Stop it; its content is discarded |
/bench profile ring dump [seconds] [upload|share] | Analyse the last N seconds of the ring buffer (60 by default, capped at max-age-seconds) without stopping it; with upload or share, send the result once saved |
/bench profile ring status | Ring buffer and automatic dump state |
/bench profile list | Saved profiles, newest first, with a number (#1 = newest) |
/bench profile show <number|id|last> | Print the summary of a saved profile again. A unique prefix of the id works too |
/bench profile delete <number|id|last> | Delete a saved profile (and its raw .jfr, if kept) |
/bench profile upload <number|id|last> [preview] | Send the profile to your voxelbench.com account now (alias send); preview only shows what would leave (see below) |
/bench profile share <number|id|last> [preview] | Publish a cleaned copy behind a public link (no account, 7 days) now; preview only shows what would leave (see below) |
/bench profile unshare <number|id|last> | Delete the public link of a shared profile before it expires (works after the profile was deleted locally) |
/bench profile status | JFR availability, running capture, ring buffer, automatic dumps, saved profiles |
Only one timed capture runs at a time, and profiles are analysed one after the other.
Capture and send in one command. /bench profile 30 share captures for 30 seconds, prints the summary, then shares the new profile by public link; /bench profile 30 upload sends it to your account instead, and /bench profile ring dump 60 share does the same with the ring buffer. What would stop the send — a missing permission, the feature turned off, a server that is not linked, a benchmark in progress — is reported before the capture starts. An automatic lag dump is never sent.
Reading the Summary
Profile 20260924-153012-manual - 30 s, one sample requested every 10 ms (experimental)
Samples: 1045 on the tick thread (Server thread), 2016 on all threads. These are counts, not durations.
self = code at the top of the stack (JDK calls count for their caller); incl. = anywhere in the stack.
Who runs on the tick thread (self / incl.):
server: 83.5% self, 100.0% incl. (873 samples)
VoxelBench: 16.4% self, 17.0% incl. (171 samples)
spark: 0.1% self, 0.3% incl. (1 samples)
Top methods (self):
Level.tickChunk [server] 22.1% (231 samples)
...
Saved to plugins/VoxelBench/reports/profiles/20260924-153012-manual.json (21 KB)
- Owners. Each sampled method is attributed to an owner:
- a plugin name, for code that comes from that plugin's jar. Plugins declared with
paper-plugin.ymland the libraries a plugin declares inlibraries:count for that plugin; serverfor Minecraft, Bukkit, Paper and the libraries the server ships;jvmfor Java itself;libraryfor alibraries:jar that several plugins declare, or whose plugin could not be determined;sparkfor the copy of spark bundled inside Paper;otherfor anything else (generated code, agents).
- a plugin name, for code that comes from that plugin's jar. Plugins declared with
- self adds up to 100 %: each sample goes to the owner of the innermost plugin frame of the stack, so the server code a plugin calls counts for that plugin. When no plugin is on the stack, the innermost non-JDK frame decides. A listener of plugin B, called while plugin A does something, counts for B.
- incl. (inclusive) counts a sample for every owner present anywhere in the stack. Plugin A above gets back the time of B that it triggered.
- Top methods are the methods on top of the stack, with calls into the JDK folded into their caller (
HashMap.gettells you nothing; the server or plugin method that calls it does). - Counts, not durations. JFR delivers fewer samples than the period promises (on Windows the timer is coarse, and JFR samples only a few threads per round), so "samples × period" is not a time. Read the percentages; use the sample counts to judge how reliable they are.
- Who runs is not who caused it. A profile shows the code that runs. A plugin that places a thousand hoppers does not appear when the server ticks them: that time is
server. - Obfuscated plugins are attributed correctly, but their method names are meaningless.
Quality Warnings
The summary (and the JSON file) may add:
| Warning | Meaning |
|---|---|
| Only N tick samples | Fewer than 300 samples on the tick threads: treat the percentages as rough. Capture longer, or while the server is busy |
| N% of the stacks were deeper than JFR's limit | JFR keeps 64 frames by default; deeper stacks lose their root. Start the server with -XX:FlightRecorderOptions:stackdepth=256 if this shows up often |
| N threads were busy at the same time | JFR samples only about five Java threads per round, so the tick thread got fewer samples. The shares stay fair but are noisier |
| Another JFR recording samples every N ms | Another tool (or a manual jcmd JFR.start) asked for a shorter period, which JFR applies to every recording. Sample counts are higher than expected; percentages are unaffected. Only recordings still running at dump time are detected: a ring dump that covers a shorter-period capture which has already ended carries no warning |
| N samples had their stack cut short | The profile tables were full (very large dump). The samples still count, attributed to a caller |
| The ring buffer only covered N s | The ring buffer had not been running for the whole window you asked for |
Ring Buffer
The ring buffer is a continuous JFR recording that keeps only the last few minutes of samples, so that a lag can be analysed after it happened. It is off by default:
profiling.ring.enabled-on-start: truestarts it with the server;/bench profile ring on/offswitches it at runtime (until the next restart).
It samples every 20 ms by default and keeps the last 300 s, at most 32 MB, in JFR's repository in the JVM's temporary directory (JFR deletes it on a clean shutdown). /bench profile ring dump analyses part of it without stopping it.
Automatic Dump on Lag
While the ring buffer runs, VoxelBench can dump and analyse it by itself when the server lags (profiling.auto-dump.enabled, on by default; it does nothing while the ring buffer is off).
How a lag is detected. A timer runs every tick and measures the time between two ticks (on Spigot and Paper, that is the length of the previous tick). A lag is:
consecutive-slow-ticksticks in a row (40 by default) that each take at leastslow-tick-ms(100 ms by default, i.e. below 10 TPS for at least 4 seconds), or- a single tick of at least
freeze-ms(1000 ms by default). A freeze is only seen once it is over, when the next tick finally runs; the ring buffer has kept its samples anyway.
What is dumped. The profile covers from 10 s before the lag started to 5 s after it was detected (VoxelBench waits those 5 s before dumping). It is saved with the trigger lag, and its summary says what triggered it. The server log gets one line with the file name and the top owners; nothing is sent to players.
What prevents a dump:
cooldown-seconds(300 by default): at most one automatic dump per period, so a struggling server does not write a profile every few seconds;- the first 30 s after the ring buffer (re)starts, whose ticks are always slow;
- a VoxelBench test in progress, whose load is intentional, and the first 30 s after it ends (its cleanup runs once the test has released its lock, and can hold a tick for seconds);
- another profile being analysed at that moment.
Empty servers. Minecraft 1.21.2 and later can pause a server with no player online (pause-when-empty-seconds in server.properties: 60 by default on Spigot, disabled on Paper). The tick that resumes such a pause would look like a freeze, so when that setting is enabled, a long tick right after the server was empty is ignored. Streaks of slow ticks are still detected.
On Folia, the timer runs on the global region. A lag confined to one region, on a server with several region threads, does not slow the global region down and is not detected; use /bench profile ring dump instead. With a single region thread, the global region shares it and lags are detected.
Scored Runs Are Never Profiled
A benchmark run that produces a score must not depend on anything else running. While a scored run is in progress — /bench start (including multi-run and custom profiles), /bench stresslimit, /bench tier, a job started by the auto-bench agent, or a /bench test whose result is sent to voxelbench.com — the profiler:
- refuses to start a capture or a ring dump, with a message;
- cancels a timed capture that was running (nothing is saved);
- interrupts an analysis in progress;
- stops the ring buffer (its content is discarded) and the lag detection.
It resumes 30 s after the scored run ends, which also covers the pause between the iterations of a multi-run. The ring buffer then restarts empty.
A test started with /bench test is a scored run only when its result leaves the machine: when unit-test submission to voxelbench.com is enabled (reports.backend.unit-tests and a linked server), or when the auto-bench agent started it. Otherwise it stays local and can be profiled — a good way to see what a test spends its time on. To profile a test on a server that submits its results, turn reports.backend.unit-tests off for that session.
Saved Profiles
Each profile is a JSON file in plugins/VoxelBench/reports/profiles/, named <date>-<time>-<trigger>.json (server local time), for example 20260924-153012-lag.json. The trigger is manual (timed capture), ring (ring dump) or lag (automatic dump).
- Retention. After each new profile, the oldest ones are deleted while there are more than
storage.max-files(20) or they take more thanstorage.max-total-mb(100 MB). The profile just written is never deleted by its own retention. - Raw dump. With
storage.keep-raw-jfr: true, the raw.jfrfile is kept next to the JSON; open it with JDK Mission Control. A ring dump holds the whole buffer, every thread included. Off by default. - Format. The
schemafield (voxelbench-profile/1) identifies the format. The file holds the capture settings, platform and Java version, sample counts per thread kind (tick, scheduler, server, jvm, other), the per-owner attribution, the top methods, the warnings, a pruned call tree (nodes under 0.5 % of the samples are merged into their parent, at most 1,500 nodes) and diagnostics. It may change while the feature is experimental. - Folder. Profiles live in the
profiles/folder of the reports folder (reports.folder,reportsby default), next to your reports. Profiles saved by an earlier build inplugins/VoxelBench/profiles/are moved there when the server starts, upload and share records included; nothing is overwritten (if a name is already taken, the moved profile gets a-2suffix and the server log says so), and the old folder is removed once empty. - Reports cleanup.
/bench reports cleanupand the Cleanup button of the Reports menu delete old reports only. They never touch profiles, which keep their own retention above.
In the Menu and the Web Dashboard
/bench gui → Profiler (main menu) drives the profiler without typing:
- Capture 30 s / 60 s / 120 s, and Dump the ring buffer (60 s);
- After saving: keep the profile on the server (default), share it by public link, or send it to voxelbench.com. The last two only appear with their permission, and ask for a confirmation before the capture starts;
- Ring buffer on/off, and Full status in chat (
/bench profile status); - the current state: capture in progress, ring buffer, pause around a scored run, saved profiles.
/bench gui → Reports → Profiles lists the saved profiles, newest first: date, trigger, window, tick samples, heaviest plugin, and the Sent to voxelbench.com / Shared until … badges. Clicking one opens its sheet: header (id, date, window, server and Java versions), who runs on the tick thread (self / incl.), busiest methods, warnings, upload and public link state (click it for the links in chat), and the buttons Show in chat, Preview (click: what a public link would publish; shift-click: what an upload would send), Share by public link, Send to voxelbench.com, Delete the public link and Delete. Share, send and delete ask for a confirmation first.
Every button runs the matching /bench profile command for you, so the permissions, refusals and messages are exactly those of the command: the screens need voxelbench.profile, and Share / Send are greyed out without voxelbench.profile.share / voxelbench.profile.upload.
Web dashboard. When the monitoring dashboard is enabled, its page has a read-only Profiles section: the same list, and the summary of a profile (plugins, methods, warnings, whether it was sent or shared, with the links). It is served by GET /api/profiles and GET /api/profiles/<id>, with the same login or API key as /api/metrics and both IP whitelists (dashboard and api modes). Nothing can be captured, shared, sent or deleted from the web, and the dashboard never shows the token that deletes a public link. The section is not available in API-only mode (monitor.dashboard.enabled: false).
Uploading a Profile to voxelbench.com
A saved profile can be sent to the voxelbench.com account your server is linked to, to read it there or to show it to whoever helps you. It is always manual: VoxelBench never uploads a profile by itself (not even an automatic lag dump), and never anything other than the profile you name.
- Link the server first:
/bench link(see Account Linking). /bench profile upload <number|id|last>sends it right away —/bench profile upload lastsends the newest profile, and/bench profile 30 uploadcaptures one and sends it once saved. If a thread or method name looks like it holds an address or a path, the chat says so first: an upload sends the file as it is, to your account only.- To look before sending, add
preview:/bench profile upload <number|id|last> previewshows what would leave the server, and sends nothing:Send profile 20260924-153012-manual to voxelbench.com? (experimental) It goes to the account this server is linked to on voxelbench.com, where you can view and delete it. What leaves: the file plugins/VoxelBench/reports/profiles/20260924-153012-manual.json as it is, 21 KB (6 KB compressed). It contains: - the names of 3 plugins: VoxelBench, LuckPerms, spark - 212 method names (class and method of the sampled code, private plugins included) and 18 thread names; - the server version (1.21.11-...), Java 21.0.11 (Eclipse Adoptium), Linux amd64, 8 CPU threads, and the capture times. No player name, UUID, IP address, world name, coordinate or command. To send it, run /bench confirm within 60 s. Nothing is sent otherwise./bench profile upload <id> confirm(or/bench confirm, a click on that line, or Yes in the window that opens) then sends exactly what was shown: within 60 s, for the profile you just reviewed, from the same player or console, and only if the file has not changed since.confirmwithout a preview first is refused.
Once it is sent, /bench profile list marks the profile sent and /bench profile show prints the link to its page on voxelbench.com.
What is sent. Exactly the JSON file you can open in plugins/VoxelBench/reports/profiles/, compressed: never the raw .jfr dump, never another profile, nothing added. Its contents are described under Privacy.
When it is refused:
- the server is not linked, or
profiling.upload.enabledisfalse; - you do not have
voxelbench.profile.upload(operators have it by default;voxelbench.profiledoes not grant it); - a scored run is in progress, or ended less than 30 s ago: an upload uses the network that the benchmark measures;
- the file is larger than
profiling.upload.max-size-kb(1024 KB by default); - another upload is in progress, or the previous one was less than 30 s ago (or voxelbench.com asked to wait longer).
Answers from voxelbench.com. The chat says what happened: sent (with a link), already on the site, link no longer recognised (run /bench link force), plan without profile uploads, too large, too many uploads (with the delay to wait), site error, no answer. Until voxelbench.com accepts profiles, the answer is "voxelbench.com cannot receive profiles yet": nothing is stored and there is nothing to fix on your side.
Deleting. /bench profile delete removes the local copy only, and reminds you that the uploaded copy stays on voxelbench.com: delete it there.
Sharing a Profile by Public Link
To show a profile to someone who is not on your account — typically, pasting a link in a Discord channel to get help — share it by public link. This mode needs no account and no linked server: voxelbench.com answers with an unlisted link that anyone who has it can open, and that expires after 7 days. Nothing lists or indexes it; it is as private as the link itself.
/bench profile share <number|id|last>publishes it right away —/bench profile share lastshares the newest profile, and/bench profile 30 sharecaptures one and shares it once saved. The chat first says what was removed from the copy, then shows the link (clickable) and its expiry date.- To look before publishing, add
preview:/bench profile share <number|id|last> previewshows what would be published, and sends nothing:Share profile 20260924-153012-manual with a public link? (experimental) PUBLIC link: anyone who has the link will see this profile. It expires after 7 days. No account is involved, and nothing is listed or indexed. It goes to voxelbench.com, which answers with the link. The site uses this server's id only to limit abuse and never shows it. What leaves: a cleaned copy of plugins/VoxelBench/reports/profiles/20260924-153012-manual.json, 21 KB (6 KB compressed). It contains: - the names of 3 plugins: VoxelBench, LuckPerms, spark - 212 method names (class and method of the sampled code, private plugins included) and 18 thread names; - the server version (1.21.11-...), Java 21.0.11 (Eclipse Adoptium), Linux amd64, 8 CPU threads, and the capture times. Removed before sharing: 2 thread names and 0 other names contained an address, a path or a name, now replaced by [ip], [host]. Never in a shared profile: player names, UUIDs, IP addresses, world names, file paths, this server's id, its link token or your account. To publish it, run /bench confirm within 60 s. Nothing is sent otherwise./bench profile share <id> confirm(or/bench confirm, a click on that line, or Yes in the window that opens) then publishes exactly that copy, with the same rules as an upload: within 60 s, same player or console, same profile, file unchanged.
/bench profile list marks the profile shared until <date>, and /bench profile show prints the link while it is valid.
What is published. A cleaned copy of the JSON file, never the file itself and never the raw .jfr:
- Kept — what makes the profile useful: plugin names, class and method names, thread names, the per-owner attribution, the call tree, the warnings, the server and Java versions, OS and CPU count, the capture times.
- Removed from thread, method and class-loader names, replaced by a marker such as
[ip]: IP addresses (Minecraft's RCON thread is named after the client's IP) and their port, host names (database drivers put theirs in their thread names), URLs, e-mail addresses, file paths, UUIDs, long hexadecimal strings (tokens, hashes), and the names this server knows: online players, worlds (except the defaultworld,world_nether,world_the_end), the OS user and machine name, the server folder, the server id and the link token. A class that a script engine named after a script's path loses that path. - Not copied at all: any field VoxelBench does not know to be safe. A future version that adds a field to profiles will not publish it until it has been reviewed for public sharing.
The chat says how many names were cleaned and with which markers. The cleaning recognises patterns and the names above; it cannot guess the name of an offline player that a plugin put in a thread name — use preview to check the plugin list and the counts first when in doubt.
Who can share. Only official VoxelBench builds: voxelbench.com checks the jar's signature, as it does for benchmark reports. A development or modified build is told "This VoxelBench build cannot share profiles" and nothing is sent; /bench profile upload still works with a linked server. The site receives this server's id with the request, only to limit abuse (so many shares per server and per address); it never shows it or connects the link to your server.
Deleting the link before it expires. /bench profile unshare <number|id|last> deletes it on voxelbench.com at once: the link stops working. It uses a deletion token that voxelbench.com returned when the link was created, kept only in plugins/VoxelBench/reports/profiles/<id>.share.json (anyone who has that file can delete the link, nothing more). unshare works even after /bench profile delete — deleting a profile locally does not delete its public link, and the delete message reminds you of it — and even with profiling.share.enabled: false. Once the link has expired, the local record goes away by itself.
When it is refused:
profiling.share.enabledisfalse, or you do not havevoxelbench.profile.share(operators have it by default; neithervoxelbench.profilenorvoxelbench.profile.uploadgrants it);- a scored run is in progress, or ended less than 30 s ago;
- the cleaned copy is larger than
profiling.share.max-size-kb(1024 KB by default); - another share is in progress, or the previous one was less than 30 s ago (or voxelbench.com asked to wait longer);
- the profile already has a live public link:
unshareit first to create a new one.
Until voxelbench.com supports public links, the answer is "voxelbench.com cannot share profiles yet": nothing is published and there is nothing to fix on your side.
Requirements and Limits
- Java Flight Recorder must be available. It is part of every HotSpot-based runtime VoxelBench supports (Java 16 to 25: Temurin, Oracle, Zulu, Corretto…).
/bench profile statussays why it is not:- no
jdk.jfrmodule: a minimal runtime built withjlinkwithout JFR; - disabled or unsupported: the JVM was started with
-XX:-FlightRecorder, or it is not HotSpot (OpenJ9 / IBM Semeru have no JFR). Nothing else in VoxelBench is affected.
- no
- Only the tick threads are analysed:
Server threadon Spigot and Paper, the region scheduler threads on Folia. Other threads are only counted. - Only running code is sampled. JFR samples a thread while it runs Java code, not while it waits (for chunk generation on worker threads, for the disk, for a lock) or sleeps between ticks. An idle server yields almost no samples, and a freeze with very few tick samples points to the tick thread waiting rather than computing.
- A lag profile with no tick samples at all. The tick thread was not running Java code during the window: waiting, sleeping, or starved of CPU by the host (an overloaded shared machine, for instance). The profile reports it as such; the cause lies outside the JVM.
- Overhead. In our measurements JFR sampling at 10 or 20 ms added no measurable tick time. Analysing a dump takes a fraction of a second of one CPU core, off the tick thread.
- Coexistence. The profiler runs alongside spark (including the copy bundled in Paper) and other JFR recordings. Several JFR recordings share the shortest requested period.
- Stack depth. JFR keeps 64 frames per stack unless the server is started with
-XX:FlightRecorderOptions:stackdepth=N. - Memory. Parsing is streamed and bounded (distinct frames, call-tree nodes and stack depth are capped), even for a large ring dump.
Privacy
A profile contains the class and method names of the sampled code, which reveal your installed plugins (including private ones), the plugin names, thread names, your server and Java versions, OS, CPU count and timestamps. Its fields hold no player name, UUID, world name, coordinates, command, file path or JVM argument. Thread names are recorded as Java reports them, and can occasionally hold an address or a name: Minecraft's RCON thread is named RCON Client /<client IP>, and database drivers put their server's host name in theirs.
Profiles stay on your server. Only two commands send one, when an operator types them — directly, or after a preview — one profile at a time, never automatically:
/bench profile upload <id>: the JSON file as it is goes to the voxelbench.com account the server is linked to, private to that account. If a thread name looks like it holds an address or a path, the chat says so./bench profile share <id>: a cleaned copy (addresses, host names, paths, player, world and user names removed; see above) is published behind a public, unlisted link that expires after 7 days. No account is involved.
No benchmark report or monitoring payload carries profile data. profiling.upload.enabled: false and profiling.share.enabled: false turn each off entirely. Deleting a profile locally deletes neither the uploaded copy (delete it on voxelbench.com) nor its public link (/bench profile unshare <id>, or wait for it to expire).
Configuration
profiling:
enabled: true
ring:
enabled-on-start: false
period-ms: 20
max-age-seconds: 300
max-size-mb: 32
auto-dump:
enabled: true
slow-tick-ms: 100
consecutive-slow-ticks: 40
freeze-ms: 1000
cooldown-seconds: 300
storage:
max-files: 20
max-total-mb: 100
keep-raw-jfr: false
upload:
enabled: true
max-size-kb: 1024
share:
enabled: true
max-size-kb: 1024
See Configuration - Profiling for each key and its bounds.