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.
| Hook | When it fires | Typical danger |
|---|---|---|
| OnTick | Every server tick | Any non-trivial work runs many times per second |
| OnPlayerInput | Player input updates, per player | Cost multiplies by the number of online players |
| OnEntityTakeDamage | Each hit on any damageable entity, including players, NPCs, animals and buildings | Heavy logic, permission lookups and allocations per hit |
| OnEntitySpawned | Each entity spawn, including bursts at server start and during map load | Work 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.
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>()ornew Dictionary<,>()per call- Lambdas that capture local variables (these allocate a closure)
params object[]formatting such asstring.Format
Prefer:
// 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:
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.
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.FindItemDefinitiononce 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.
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.
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.Oncefor delayed single actions rather than a repeating timer that checks a flag. - Always keep a
Timerreference and destroy it inUnload. - 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
dirtyflag 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.
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.
- Check the console and the logs in
oxide/logsfortookwarnings and note the hook and plugin names. - Isolate: run
oxide.unload <Name>for suspects, one at a time, and compare server FPS and the warnings.oxide.pluginslists what is loaded. See Oxide console commands. - Reproduce under load. Many problems only appear with a full player count or after a wipe has populated the map.
- Measure your own code with a stopwatch during development. Compare
UnityEngine.Time.realtimeSinceStartupbefore and after a block, and print the difference once from a command, not from a hot hook.
[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
tookwarnings 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.
More guides
- CovalencePlugin vs RustPlugin: Differences and Which to Use4 min read
- How to Debug a Rust Oxide Plugin: Compile Errors, Exceptions and Slow Hooks10 min read
- How to Give Items to Players in a Rust Plugin (Oxide/Carbon)5 min read
- How to Install Oxide on a Rust Dedicated Server (Windows and Linux)7 min read
Skip the C#: describe your plugin
Open generator