Skip to content
RustPluginGenerator

Rust Plugin Performance: Hot Hooks, Allocations, Timers and Profiling

Rust plugin performance comes down to keeping code in frequently fired hooks (OnEntityTakeDamage, OnTick, OnPlayerInput, OnEntitySpawned) cheap: exit early, avoid LINQ and allocations, cache lookups and permissions, never scan BaseNetworkable.serverEntities repeatedly, and spread heavy work over timers. Oxide logs 'Calling X took Yms' for hooks slower than 100ms, which is your first profiling signal.

8 min readUpdated Oct 26, 2026

The fastest way to a well-performing Rust plugin is to keep code in frequently fired hooks tiny: check the cheap conditions first, return early, allocate nothing, and move anything expensive out of the hook into a cached value or a timer. Everything else in this guide is a variation on that rule.

Why hooks decide performance

Oxide patches game methods so that a call to the game also calls your plugin method. Whenever the game method runs, every plugin implementing that hook runs too. A hook that fires thousands of times per second turns a small inefficiency into measurable server lag. A slow chat command that one admin runs once a day does not.

Most plugin performance problems fall into a few categories:

  • Work done in a hot hook that should be done once or rarely.
  • Allocations (lists, strings, LINQ, closures) in hot hooks, which feed the garbage collector.
  • Scanning all entities when a targeted lookup or a tracked set would do.
  • Heavy work executed in one frame instead of being spread out.
  • Saving large data too often.

The hot hooks

These hooks fire constantly. Implement them only when you must, and keep them light.

HookWhen it firesTypical danger
OnTickEvery server tickAny non-trivial work runs many times per second
OnPlayerInputPlayer input updates, per playerCost multiplies by the number of online players
OnEntityTakeDamageEach hit on any damageable entity, including players, NPCs, animals and buildingsHeavy logic, permission lookups and allocations per hit
OnEntitySpawnedEach entity spawn, including bursts at server start and during map loadWork per entity multiplied by tens of thousands

Other busy hooks include OnDispenserGather, CanMoveItem and OnLootSpawn. The pattern is the same for all of them.

Rule 1: exit early with the cheapest check first

Order conditions from cheapest to most expensive. Type checks and null checks cost almost nothing. Permission lookups and collection searches cost more.

C#
private object OnEntityTakeDamage(BasePlayer victim, HitInfo info)
{
    if (info == null || victim == null) return null;
    var attacker = info.InitiatorPlayer;
    if (attacker == null || attacker == victim) return null;

    // cheap checks passed, now the more expensive logic
    if (!protectedPlayers.Contains(victim.userID)) return null;

    info.damageTypes.ScaleAll(0f);
    return null;
}

Because the parameter type is BasePlayer, Oxide skips this method entirely when the damaged entity is a building, animal or NPC that is not a BasePlayer. Declaring the most specific parameter type you need is a free filter. Remember that for OnEntityTakeDamage any non-null return cancels the damage, so return null unless you intend to block it.

Rule 2: no Puts or PrintWarning in hot hooks

Console output is slow and floods the log. Use logging only in startup code or rare warnings. For debugging, log once per condition, not once per call.

Rule 3: prefer a narrower hook

If you only care about a specific action, use the specific hook instead of a general one. OnEntityDeath fires once per death, while OnEntityTakeDamage fires for every hit. OnEntityBuilt is far cheaper than inspecting every OnEntitySpawned call. Likewise, a repeating check at one second intervals replaces OnTick in nearly every case.

Avoid LINQ and allocations in hot paths

LINQ is fine in a rarely used command. In a hot hook it allocates iterators, delegates and result collections on every call. That garbage accumulates and eventually triggers garbage collection pauses.

Avoid in hot hooks:

  • .Where(...).ToList(), .Select(...), .Any(...), .FirstOrDefault(...)
  • String concatenation or interpolation to build keys or messages
  • new List<T>() or new Dictionary<,>() per call
  • Lambdas that capture local variables (these allocate a closure)
  • params object[] formatting such as string.Format

Prefer:

C#
// Allocates an iterator and delegate on every call
bool bad = player.inventory.containerBelt.itemList.Any(i => i.info.shortname == "rock");

// No allocation
bool good = false;
var list = player.inventory.containerBelt.itemList;
for (int i = 0; i < list.Count; i++)
{
    var item = list[i];
    if (item != null && item.info != null && item.info.shortname == "rock")
    {
        good = true;
        break;
    }
}

When you need a temporary collection, create it once as a field and clear it before reuse:

C#
private readonly List<BasePlayer> tempPlayers = new List<BasePlayer>();

private void CollectNearby(Vector3 center, float radius)
{
    tempPlayers.Clear();
    foreach (var p in BasePlayer.activePlayerList)
    {
        if (p != null && Vector3.Distance(p.transform.position, center) <= radius)
            tempPlayers.Add(p);
    }
}

Be careful when reusing a field list across nested calls or timers: if two code paths can use it at the same time, one will corrupt the other.

A foreach over a List<T> does not allocate in modern runtimes, but modifying a collection while enumerating it throws an exception; see collection was modified during enumeration.

Cache lookups and permissions

Anything you compute repeatedly with the same answer should be computed once.

Cache permission results

permission.UserHasPermission performs lookups by user and group. Calling it on every hit or input event adds up. Cache the result per player and refresh it when permissions can change, such as on connect, on a timer, or when Oxide reports a grant through OnUserPermissionGranted.

C#
private readonly HashSet<ulong> vipCache = new HashSet<ulong>();

private void OnPlayerConnected(BasePlayer player)
{
    if (player == null) return;
    RefreshPermission(player);
}

private void OnPlayerDisconnected(BasePlayer player, string reason)
{
    if (player != null) vipCache.Remove(player.userID.Get());
}

private void RefreshPermission(BasePlayer player)
{
    ulong id = player.userID.Get();
    if (permission.UserHasPermission(player.UserIDString, PermVip)) vipCache.Add(id);
    else vipCache.Remove(id);
}

Hot hooks then do a HashSet.Contains instead of a permission lookup. Add a refresh path for permission changes, otherwise players keep stale results until they reconnect. See permissions and groups for the model.

Cache other repeated values

  • Item definitions: call ItemManager.FindItemDefinition once and store the result, not per event.
  • Config values: read them into fields at load time; do not parse strings per call.
  • Lang messages: fetch only when you send them, not in checks.
  • Entity references: store the entity you track instead of searching for it again.

Use HashSet and Dictionary

Replace List.Contains on large collections with HashSet<ulong> or Dictionary<ulong, T>. A list search is linear; a hash lookup is effectively constant.

Do not scan BaseNetworkable.serverEntities repeatedly

BaseNetworkable.serverEntities holds every networked entity on the server, which on a populated map is a very large number. Enumerating it and casting with as in a command or timer can take long enough to freeze the server for a moment. Doing so in a hot hook is a guaranteed problem.

Better options:

  • Track what you need as it appears. Add entities of your type in OnEntitySpawned (filter by type first) and remove them in OnEntityKill.
  • Do one startup scan in OnServerInitialized to catch entities that already exist, then rely on hooks.
  • If you must scan, do it rarely (once per command invocation for an admin tool, or once per several minutes) and never per player action.
C#
private readonly HashSet<BaseEntity> tracked = new HashSet<BaseEntity>();

private void OnServerInitialized(bool initial)
{
    foreach (var net in BaseNetworkable.serverEntities)
    {
        var box = net as StorageContainer;
        if (box != null) tracked.Add(box);
    }
}

private void OnEntitySpawned(StorageContainer box)
{
    if (box != null) tracked.Add(box);
}

private void OnEntityKill(StorageContainer box)
{
    if (box != null) tracked.Remove(box);
}

The typed parameters make Oxide skip every non-storage entity before your code runs. Remember that destroyed entities must be removed from your tracked collections, or you hold stale references and leak memory. Also clear these collections in Unload.

Spread work over timers

If a task has to touch many things, do not do all of it in one frame. Process a batch per tick and continue later. See timers for the API.

C#
private Timer batchTimer;
private readonly Queue<BaseEntity> pending = new Queue<BaseEntity>();

private void StartCleanup(IEnumerable<BaseEntity> source)
{
    foreach (var e in source) pending.Enqueue(e);
    if (batchTimer == null || batchTimer.Destroyed)
        batchTimer = timer.Every(0.1f, ProcessBatch);
}

private void ProcessBatch()
{
    int budget = 50;
    while (budget-- > 0 && pending.Count > 0)
    {
        var e = pending.Dequeue();
        if (e != null && !e.IsDestroyed) e.Kill();
    }
    if (pending.Count == 0 && batchTimer != null)
    {
        batchTimer.Destroy();
        batchTimer = null;
    }
}

private void Unload()
{
    if (batchTimer != null) batchTimer.Destroy();
    pending.Clear();
}

Guidelines:

  • Choose the longest interval that still feels responsive.
  • Use timer.Once for delayed single actions rather than a repeating timer that checks a flag.
  • Always keep a Timer reference and destroy it in Unload.
  • Remember that an exception inside a timer callback destroys the timer; see failed to run timer.

Data saving strategy

Saving data means serializing an object and writing a file. For small data this is trivial; for large dictionaries it is not.

  • Keep the data in memory and write it only at sensible points: in OnServerSave, in Unload, and at most on a slow timer such as every few minutes.
  • Do not write inside hot hooks. Set a dirty flag instead, then save when the flag is set during one of the points above.
  • Split unrelated data into separate files if one part changes much more often than another.
  • Prune old entries (players who have not played for a long time) so files do not grow forever.
C#
private bool dirty;

private void MarkDirty() => dirty = true;

private void OnServerSave() => SaveIfDirty();

private void Unload() => SaveIfDirty();

private void SaveIfDirty()
{
    if (!dirty) return;
    Interface.Oxide.DataFileSystem.WriteObject(Name, data);
    dirty = false;
}

Details on formats and loading are in data files. Do not use System.IO directly; the Oxide data system exists for this.

Calling X took Yms warnings

Oxide measures hook calls and logs a warning when a call to a plugin takes longer than 100ms, in the form Calling '<Hook>' on '<Name> v<Version>' took <N>ms. If a garbage collection happened during the call, the message ends with [GARBAGE COLLECT]. The error pages took ms hook performance warning and took average ms garbage collect explain the variants.

How to read them:

  • A one-off warning right after startup or a wipe is often harmless: loading data, map generation and mass entity spawns are slow moments.
  • Repeated warnings on the same hook point to real work in that hook: a scan, a large loop, a synchronous save.
  • Warnings with [GARBAGE COLLECT] suggest allocation pressure, either from your plugin or another. The call itself may be fast, and the collection merely happened to land inside it.
  • The plugin named in the message is where the time was spent, but a slow call may also include time in other code the hook called, such as another plugin through Call.

Fix the cause rather than raising a threshold: add early exits, cache, replace scans, and move work to timers.

Profiling basics

You do not need special tooling to find most problems.

  1. Check the console and the logs in oxide/logs for took warnings and note the hook and plugin names.
  2. Isolate: run oxide.unload <Name> for suspects, one at a time, and compare server FPS and the warnings. oxide.plugins lists what is loaded. See Oxide console commands.
  3. Reproduce under load. Many problems only appear with a full player count or after a wipe has populated the map.
  4. Measure your own code with a stopwatch during development. Compare UnityEngine.Time.realtimeSinceStartup before and after a block, and print the difference once from a command, not from a hot hook.
C#
[ChatCommand("benchmark")]
private void CmdBenchmark(BasePlayer player, string command, string[] args)
{
    if (player == null || !player.IsAdmin) return;
    float start = UnityEngine.Time.realtimeSinceStartup;
    int count = 0;
    foreach (var net in BaseNetworkable.serverEntities) count++;
    float ms = (UnityEngine.Time.realtimeSinceStartup - start) * 1000f;
    player.ChatMessage("Counted " + count + " entities in " + ms.ToString("0.0") + " ms");
}

Use such a command to measure a candidate scan once on a real server, then decide whether it is affordable. For broader troubleshooting steps see debugging Rust plugins.

Performance checklist

  • Remove hook methods you do not use.
  • Use the most specific parameter types so Oxide skips irrelevant calls.
  • Null-check and exit early, cheapest condition first.
  • No LINQ, string building, or new collections in hot hooks.
  • Cache permissions, item definitions and config values.
  • Track entities through hooks instead of scanning BaseNetworkable.serverEntities.
  • Batch heavy work over timers and destroy timers in Unload.
  • Save in OnServerSave and Unload, not on every change.
  • Clear caches and tracked collections when players disconnect and when the plugin unloads.
  • Treat repeated took warnings as bugs to fix.

To start from a clean structure that already follows these rules, see how to make a Rust plugin, or describe the behavior you want in the plugin generator.

FAQ

Does a plugin that is not doing anything still cost performance?

Yes, if it implements hot hooks. Oxide calls a plugin's method every time the hook fires, even if your code returns on the first line. Remove hook methods you do not need, and keep the early-exit check as the first statement in the ones you keep.

Is it safe to use timer.Every with a very short interval instead of OnTick?

It is cheaper than OnTick when you need a result a few times per second, but a short interval still runs often. Pick the longest interval your feature tolerates, such as 1 to 5 seconds for checks. If a timer callback throws, the timer is destroyed, so keep the callback defensive.

How do I find which plugin is causing server lag?

Search the console log for 'Calling' and 'took' lines, which name the hook and plugin. Then unload suspects one at a time with oxide.unload <Name> and compare server FPS. Repeated warnings from one plugin point to a scan, a heavy loop or synchronous file work in a hook.

Should I save data in OnServerSave or on every change?

Save in OnServerSave, in Unload, and optionally on a slow timer. Writing a data file on every change inside a frequent hook is costly because the whole object is serialized each time. Mark data dirty in memory and flush it on a schedule.

Do allocations really matter in a C# plugin?

Yes. Allocations create garbage, and when the garbage collector runs it pauses the server briefly. Oxide appends [GARBAGE COLLECT] to slow-hook warnings when a collection happened during the call. Hot paths that create lists, strings or closures per call are the usual cause.

Skip the C#: describe your plugin

Open generator