← Back to blog

Don't Block the Main Thread: Doing License Checks (and Other Network Calls) Right

August 14, 2026 · MC License Team

If you’ve ever integrated a licensing check, an update checker, or any “phone home” call into a plugin, there’s a good chance you wrote it like this first:

@Override
public void onEnable() {
    boolean valid = checkLicense(licenseKey); // blocking HTTP call
    if (!valid) {
        getServer().getPluginManager().disablePlugin(this);
        return;
    }
    registerListeners();
}

It works on your machine. It works in most testing. And then one day the licensing server is 400ms slower than usual, or the developer’s home DNS hiccups, or a firewall silently drops the packet instead of rejecting it, and the whole server hangs.

Why this is worse than it looks

Bukkit and Paper are single-threaded for anything that touches the world, entities, or most of the plugin lifecycle. onEnable() runs on the main thread, during server startup, before the server accepts the first tick. Any blocking call in there doesn’t just delay your plugin. It delays every plugin that loads after yours, and it delays the server actually starting.

If the call hangs for more than a few seconds, you’ll see it in the console as the classic:

[Server] Can't keep up! Is the server overloaded? Running 2134ms behind, skipping 42 tick(s)

If it hangs long enough (the default is 300 seconds in spigot.yml’s timeout-time), the watchdog assumes the server is deadlocked and kills the process. A slow license check can take down a server that was otherwise completely healthy. That’s not a hypothetical; “my server won’t start and the console just stops” is one of the most common support requests plugin developers get, and a synchronous network call in onEnable() is one of the most common causes.

A plain HttpURLConnection or HttpClient call with no timeout set makes this worse, because “slow” becomes “potentially infinite”: a hung TCP connection with no read timeout will sit there until the OS gives up, which can be minutes.

The fix: get off the main thread, then get back on it

The pattern is always the same shape: do the network call off-thread, then hop back to the main thread only for the part that actually needs it (registering listeners, calling Bukkit API methods, disabling the plugin).

Classic Bukkit scheduler:

@Override
public void onEnable() {
    Bukkit.getScheduler().runTaskAsynchronously(this, () -> {
        boolean valid = checkLicense(licenseKey); // now off the main thread

        Bukkit.getScheduler().runTask(this, () -> {
            if (!valid) {
                getLogger().severe("License check failed, disabling.");
                getServer().getPluginManager().disablePlugin(this);
                return;
            }
            registerListeners();
        });
    });
}

Paper’s scheduler (1.19.4+, and required if you’re targeting Folia):

@Override
public void onEnable() {
    getServer().getAsyncScheduler().runNow(this, task -> {
        boolean valid = checkLicense(licenseKey);

        getServer().getGlobalRegionScheduler().run(this, t -> {
            if (!valid) {
                getServer().getPluginManager().disablePlugin(this);
                return;
            }
            registerListeners();
        });
    });
}

The Paper version matters if you care about Folia compatibility. Folia doesn’t have a single global main thread the way Bukkit does, so code written against BukkitScheduler assumptions can behave unexpectedly. If you’re writing new integrations in 2026, target Paper’s scheduler API and treat BukkitScheduler as the legacy path.

Still set a timeout

Getting off the main thread stops the server from freezing, but an async task that hangs forever is still a leaked thread and a plugin that silently never finishes enabling. Always bound the call itself:

HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setConnectTimeout(5000);
conn.setReadTimeout(5000);

Or with java.net.http.HttpClient:

HttpClient client = HttpClient.newBuilder()
    .connectTimeout(Duration.ofSeconds(5))
    .build();

HttpRequest request = HttpRequest.newBuilder(uri)
    .timeout(Duration.ofSeconds(5))
    .GET()
    .build();

Five seconds is a reasonable default for a licensing check: long enough to absorb normal network jitter, short enough that a genuinely broken connection fails fast instead of stalling your plugin’s startup indefinitely.

What to do on failure

Decide up front what happens when the check can’t complete at all (timeout, DNS failure, 5xx from the server) as distinct from a check that completed and came back invalid. Treating “the network is having a bad day” identically to “this license is fake” means a routine outage on your end (or ours) takes every legitimate customer’s server offline with it. Most licensing integrations, including MC License’s, are worth wrapping in a short grace period or a soft-fail path for transient errors, reserving hard disablement for a check that actually came back and said no.

The mechanism matters less than doing it deliberately. Getting the threading right is the easy 80%; deciding what “I couldn’t reach the server” means for your plugin is the part worth actually thinking about.

See the MC License integration docs →

Ready to protect your plugins?

Create a free account and get started in minutes.

Get started free