Performance#
Viscord runs inside the Minecraft server process, so every millisecond it holds the tick thread is a millisecond the server is not ticking. The design rule is simple: nothing on the tick thread does network or disk I/O, and no background task holds a thread while it waits.
This page documents the executor, the coalescing and caching strategies, the
off-tick I/O paths, and the shutdown sequence, with the real numbers from
Viscord.java, DiscordManager.java, WebhookClient.java, and
LinkedAccountsManager.java.
One executor for everything#
All asynchronous work goes through a single static pool:
public static final ScheduledThreadPoolExecutor ASYNC_EXECUTOR = buildExecutor();
| Property | Value |
|---|---|
| Core pool size | max(2, availableProcessors() / 2) |
| Maximum pool size | max(8, core * 2) |
| Keep-alive | 30 seconds |
| Core thread timeout | Enabled |
| Threads | Daemon, named Viscord-Async-N |
| Rejection policy | CallerRunsPolicy |
| Cancelled-task cleanup | setRemoveOnCancelPolicy(true) |
Four of those settings earn their place:
Bounded, not cached. The predecessor was an unbounded cached pool. Under network back-pressure — a Discord API slowdown, a webhook endpoint timing out — every queued task got its own thread and the JVM could run out of them. A fixed ceiling turns that into back-pressure instead of a thread explosion.
Daemon threads. A stuck task cannot keep the JVM alive after the server has finished stopping.
CallerRunsPolicy. When the pool and its queue are saturated, the submitting
thread runs the task itself. That is a deliberate choice over dropping: a chat
message that is late is better than a chat message that silently vanished. The
cost is that saturation pushes work onto the caller, which for an inbound
gateway event is a Javacord thread — acceptable, because that is already off-tick.
Context class loader pinning. Every thread is created with
setContextClassLoader(Viscord.class.getClassLoader()). On Forge, the
EventSubclassTransformer resolves a class's superclass through the thread
context loader, and Javacord's shaded classes loaded on a system-loader thread
fail to link with ClassNotFoundException:
...shadow.javacord.api.entity.message.MessageBuilderBase. Pinning the loader on
pool threads — and again around sendMessage in BotClient — is what makes
embed sends work from any thread.
Delayed work without held threads#
There are two helpers:
Viscord.executeAsync(runnable); // fire and forget
Viscord.scheduleAsync(runnable, delayMs); // run later
scheduleAsync uses ScheduledThreadPoolExecutor.schedule, which parks the task
in a delay queue and hands the thread back. The older pattern —
submit(() -> { Thread.sleep(1500); pushStatus(); }) — occupied a pool thread for
the entire delay. With a pool sized at half the CPU count, a handful of those
starves everything else. Every delay in the current code is a real schedule:
| Delay | Purpose |
|---|---|
| 500 ms | Status refresh after a join or leave |
| 500 ms | Fluxer startup embed after the bot connects |
| 1500 ms | Fluxer status push after READY |
| 2500 ms | Discord disconnect after the server begins stopping |
| 5000 ms | Discord startup embed after the bot connects |
Status coalescing#
Every player join and every player leave wants to update the bot's activity line. A mass reconnect after a network blip is a hundred joins in a second, and a naive implementation fires a hundred status API calls.
scheduleStatusUpdate collapses them with an AtomicBoolean:
if (!statusUpdatePending.compareAndSet(false, true)) return;
Viscord.scheduleAsync(() -> {
statusUpdatePending.set(false);
updateBotStatus();
}, delayMs);
At most one update is ever pending. The first caller flips the flag and schedules; every caller during the window returns immediately. When the scheduled task runs, it clears the flag before pushing, so a join arriving mid-push schedules the next update rather than being lost.
The practical effect: a hundred players reconnecting produce one status push carrying the post-reconnect count, not a hundred pushes carrying intermediate counts.
Bounded caches#
Two caches are bounded LRU maps with explicit size ceilings. Neither is allowed to grow with traffic.
| Cache | Where | Cap | Purpose |
|---|---|---|---|
recentAdvancements |
DiscordManager |
256 entries | Deduplicate advancement embeds per player and title |
recentDiscordBridges |
TridirectionalBridge |
512 entries | Suppress Discord → Fluxer → Discord echo |
Both are access-ordered LinkedHashMaps with removeEldestEntry returning true
past the cap, wrapped in Collections.synchronizedMap. The advancement cache also
applies a time window on read: the same player and the same advancement title
within 5000 ms is dropped, because one unlock can fire several server events. The
bridge cache applies the same five-second window when it looks up a fingerprint.
A note on the advancement map's locking. The map is synchronised, but
sendAdvancementEmbed also wraps its get-and-put in synchronized
(recentAdvancements). Without that, two events for the same advancement arriving
concurrently could both read the previous timestamp as absent and both emit.
Off-tick disk I/O#
Two files are written from code that can run on the server thread.
viscord-links.json. LinkedAccountsManager.save() serialises the map to a
String inside a synchronized block on the calling thread — that gives a
consistent snapshot and is pure in-memory work — then hands the actual write to
Viscord.executeAsync. A /viscord discord unlink therefore does not touch the
disk from the Brigadier thread.
viscord-preferences.json. PlayerPreferences.savePreferences() builds the
JSON on the calling thread and submits the Files.writeString to the executor.
This runs on every toggle, so a player flipping /discord messages twice does not
produce two synchronous writes.
Both files are small and write-whole-file, not append. There is no journal and no atomic-rename dance: a crash mid-write can leave a truncated file. Keep backups.
Volatile capture to locals#
Several methods read a volatile field more than once. A concurrent reload can
null that field between reads, which produces a NullPointerException on a
perfectly healthy server. The codebase handles it by capturing once:
DiscordApi local = api;
if (local == null) return;
return local.getYourself().getIdAsString();
The same shape appears in BotClient.updateStatus, BotClient.disconnect,
DiscordManager.handleSlashList (MinecraftServer current = server;), and the
/home handler. Any new code that reads server, api, or bridge more than
once should do the same — a reload resets the manager singleton and rebuilds the
platform objects underneath you.
The tick thread never blocks#
The only places Viscord touches the tick thread are unavoidable by nature:
- Chat capture, which runs on the packet-handling path and only enqueues.
server.execute(...)handoffs for anything that must run on the server thread — sending a chat component to players, reading the player list, dispatching/home.- The
/homecommand dispatch itself, which must be on the server thread.
Everything else — webhook HTTP, gateway work, embed construction, config reload,
file writes — is off-tick. The chat path in particular does the formatting and
the webhook submission without blocking: the mixin calls sendChatMessage, which
formats and hands off.
Shutdown sequence#
Stopping the server is the one time Viscord deliberately waits, so the "server offline" embed actually arrives before the JVM dies.
shuttingDownis set true. Player-leave handling checks this and skips the leave embed, so a hundred players disconnecting do not produce a hundred embeds.- The Discord disconnect is scheduled 2500 ms out rather than run inline.
DiscordManager.shutdown()sends the offline embed and awaits its future withorTimeout(3, SECONDS), then disconnects the bot and shuts the webhook client down through the samehandlechain.WebhookClient.shutdown()shuts the OkHttp dispatcher and callsawaitTermination(3, SECONDS), then evicts the connection pool.ASYNC_EXECUTOR.shutdown()runs in thefinallyblock.
The worst case is roughly 2.5 s of deliberate delay plus up to 3 s waiting for the
embed, so a stop can take around five to six seconds longer than it otherwise
would. If the config has general.enabled = false or the integration never
started, the executor is shut down immediately with no delay — which is why a
disabled Viscord adds nothing to shutdown time.
Interrupted embeds
If the embed does not finish within its timeout the shutdown continues anyway; the timeout is caught and logged, not propagated. A missing "Server Offline" message means the network was slow, not that the server failed to stop cleanly.
Tuning#
There is very little to tune, by design.
[advanced] declares queue_size and rate_limit, and both are written into a
generated config with range checks, but no code path reads either one in 5.2.1.
Executor sizing is derived from the CPU count and queueing is the executor's own.
If you are carrying these keys from an older build they are inert; leaving them
alone is correct.
What you can actually change:
| Knob | Effect |
|---|---|
bot_status.enabled = false |
Removes the periodic status API calls entirely. |
messages.events.* |
Disabling a category skips the embed build and the network call, not just the display. |
filters.chat.enabled |
Suppresses matching in-game messages before any network work. |
discord_rate_limit.* |
Caps platform-side command volume; 0 disables a bucket. |
Measuring#
For a rough read on whether Viscord is contributing to tick time, enable
general.debug and watch for repeated rate-limit lines, reconnect attempts, or
Failed to send warnings. Persistent webhook failures are the usual cause of
queue growth, because each retry occupies a pool slot until its 5-second timeout
expires. The webhook HTTP client is built with 5-second connect, write, and read
timeouts, so a dead endpoint costs at most 5 seconds per in-flight message before
CallerRunsPolicy starts pushing work onto callers.
If you are diagnosing a stall, the useful question is not "is Viscord slow" but
"is the executor saturated" — look for Viscord-Async- thread names in a thread
dump and count how many are in OkHttp socket reads.