Back to blog

How to Add Folia Support to Your Minecraft Plugin

Stephen, co-founder

Folia is PaperMC's fork that splits a server's world into regions and ticks them in parallel, on several threads at once. Large servers run it to fit more players on one machine, and "Folia supported" is now a real selling point on a plugin listing.

The catch is that a lot of plugin code quietly assumes there's one main thread. On Folia there isn't one, so that code either throws an error or, worse, touches the world from the wrong thread. This guide shows you what to change.

Step 1: Declare support

Folia won't load a plugin unless it opts in. Add this to plugin.yml, or to paper-plugin.yml if that's what you use:

folia-supported: true

Only add it once you've done the rest of this guide. It's a promise that your plugin is thread-safe on Folia.

Step 2: Stop using BukkitScheduler

Bukkit.getScheduler() doesn't work on Folia: calls like runTask and runTaskTimer throw an UnsupportedOperationException. In its place, Folia gives you four schedulers. Each one answers the question: which thread owns the thing I'm about to touch?

Scheduler Use it for Get it with
Entity scheduler Anything involving a specific player or entity entity.getScheduler()
Region scheduler Blocks or chunks at a location Bukkit.getRegionScheduler()
Global region scheduler Server-wide things that aren't tied to a place: weather, world time, console commands Bukkit.getGlobalRegionScheduler()
Async scheduler Work that doesn't touch the world at all: HTTP calls, database queries, file I/O Bukkit.getAsyncScheduler()

The good news is that these schedulers also exist on Paper (since 1.19.4), where they simply run on the main thread. Write against them once and the same jar works on both.

Step 3: Swap each task for the right scheduler

Doing something to a player later

// Before (Bukkit)
Bukkit.getScheduler().runTaskLater(plugin, () -> player.sendMessage("Welcome!"), 40L);

// After (Paper and Folia)
player.getScheduler().runDelayed(plugin, task -> player.sendMessage("Welcome!"), null, 40L);

The null is the "retired" callback: code to run if the entity is removed (the player logs out, say) before your task fires. Pass a Runnable if you need to clean up.

Changing blocks at a location

Bukkit.getRegionScheduler().run(plugin, location, task -> {
    location.getBlock().setType(Material.GOLD_BLOCK);
});

A repeating server-wide task

Bukkit.getGlobalRegionScheduler().runAtFixedRate(plugin, task -> {
    // e.g. broadcast an announcement
}, 20L, 20L * 60);

The initial delay must be at least 1 tick.

Network or database work

Bukkit.getAsyncScheduler().runNow(plugin, task -> {
    Stats stats = database.loadStats(uuid); // off every game thread

    player.getScheduler().run(plugin, t -> {
        player.sendMessage("Kills: " + stats.kills());
    }, null);
});

It's the same pattern as on Paper: do the slow work off-thread, then hop onto the right thread to touch the game. (More on why that matters in don't block the main thread.)

Step 4: Watch for cross-region access

The schedulers make sure your code runs on the right thread, but they can't stop you reaching into another region. On Folia, a task running for one player must not read or change things in a distant part of the world, or another player who might be ticking in parallel.

Common culprits:

  • Teleporting. Use entity.teleportAsync(location) instead of teleport(...). It moves the entity safely between regions and returns a CompletableFuture<Boolean>.
  • Looping over every online player to change their state from one task. Schedule the change on each player's own scheduler.
  • Shared collections. Maps and lists that several regions update need to be thread-safe (ConcurrentHashMap, for example).

Step 5: Supporting Paper and Folia from one jar

If you use the Folia schedulers everywhere, a single jar already works on Paper 1.19.4 and newer and on Folia. You only need to detect Folia if you also support older servers or plain Spigot, where those methods don't exist:

private static final boolean FOLIA = isFolia();

private static boolean isFolia() {
    try {
        Class.forName("io.papermc.paper.threadedregions.RegionizedServer");
        return true;
    } catch (ClassNotFoundException e) {
        return false;
    }
}

Then pick the scheduler at runtime:

public void runRepeatingAsync(Runnable work, long periodSeconds) {
    if (FOLIA) {
        Bukkit.getAsyncScheduler().runAtFixedRate(plugin, t -> work.run(), periodSeconds, periodSeconds, TimeUnit.SECONDS);
    } else {
        Bukkit.getScheduler().runTaskTimerAsynchronously(plugin, work, periodSeconds * 20L, periodSeconds * 20L);
    }
}

This is exactly how the MC License library handles its background heartbeat, which is why adding a license check to a Folia plugin needs no extra work.

Step 6: Test on a real Folia server

Folia bugs only show up under real conditions. Download a Folia build from PaperMC, then test with players spread across different parts of the world, not all standing at spawn. Watch the console for errors about accessing something from the wrong thread.

Checklist

  • folia-supported: true in your plugin YAML
  • No Bukkit.getScheduler() calls left on code paths that run on Folia
  • Player and entity work on entity.getScheduler()
  • Block work on getRegionScheduler() with a location
  • I/O on getAsyncScheduler()
  • teleportAsync instead of teleport
  • Shared state is thread-safe
  • Tested on Folia with players spread out

Once it passes, say so on your listing. Folia support is one of the things buyers on large servers filter by. Selling the plugin? Read how to sell Minecraft plugins next.

Ready to protect your plugins?

Create a free account and get started in minutes.

Get started free