When running a game with thousands of active entities—such as enemies moving around, projectiles flying, or rotating environment items—defining a standard void Update() method on each of their individual scripts can severely degrade overall performance.

Intriguingly, even if these Update methods are completely empty, having thousands of them in your active scene can still trigger massive frame-rate drops. The culprit behind this phenomenon is an internal Unity mechanism known as 'Managed-to-Native' overhead.

This article explores why this overhead occurs by diving into the Unity Profiler, and walks you through a step-by-step implementation of a Centralized Update Pattern that invokes all update operations through a fast C# loop managed by a single manager class, complete with a clean auto-registration system.

1. The Root Cause: The High Cost of Crossing the "Managed-to-Native" Boundary

Why do individual Update calls cost so much?

Unity's core engine logic (handling physics, rendering, GameObject hierarchy, etc.) is written in high-performance C++ (native code). On the other hand, the custom gameplay logic we write runs in C# (managed code).

When a GameObject becomes active, Unity checks if its MonoBehaviour defines magic methods like Update() and adds them to an internal list. Every frame, the native C++ engine loops through this list and calls the C# method for each script.

[Unity Native (C++) Engine] ──(Heavy Boundary Crossing)──> [MonoBehaviour (C#) Update()]
  

Figure: Costly managed-to-native transitions happening thousands of times every frame

Every time the native engine invokes a managed C# method (similar to P/Invoke transitions), it must perform security checks, sync state data, and handle exception management. This transition boundary has a fixed performance cost.

Calling a method within C# (e.g., from one class method to another) is extremely fast, taking only a fraction of a nanosecond. However, crossing the Managed-to-Native boundary is tens of times more expensive.
If you only have 100 active objects, this boundary cost is negligible. But when you scale up to 1,000 or 5,000 objects, the cumulative transition overhead wastes several milliseconds of CPU time, creating a major performance bottleneck.

2. The Solution: Centralized Update Pattern

The Centralized Update Pattern is a design architecture designed to bypass this transition overhead.

The concept is simple:

  1. Remove standard void Update() methods from individual scripts.
  2. Make these scripts implement a common update interface, such as IUpdateable.
  3. Introduce a single MonoBehaviour manager class, UpdateManager, into the scene.
  4. The UpdateManager acts as the single recipient of Unity's native Update() call, and propagates the frame update to all registered IUpdateable instances using a standard C# for loop.
[Unity Native (C++) Engine]
          │
    (Single Call)
          ▼
[UpdateManager (C#)]
          │
   (Fast C# Loop)
          ├─> [Enemy 1 (IUpdateable.OnUpdate)]
          ├─> [Enemy 2 (IUpdateable.OnUpdate)]
          └─> [Projectile (IUpdateable.OnUpdate)]
  

Figure: Reducing the native transition count to once per frame and looping purely in C#

With this architecture, the Managed-to-Native transition happens exactly once per frame instead of thousands of times.

3. Implementation Guide: Step-by-Step Code

Let us implement a production-ready, safe UpdateManager.
A critical challenge in managing update lists is preventing collection-modified exceptions (Concurrent Modification) when scripts register, unregister, or destroy themselves while the update loop is actively running. We solve this by buffering registration requests and applying them outside the main loop.

1. The Interface (IUpdateable.cs)

Define a basic interface for objects that need centralized frame updates.

public interface IUpdateable
{
    void OnUpdate(float deltaTime);
}

2. The Manager Script (UpdateManager.cs)

This singleton class manages registrations and processes the core update loop. Additions and deletions are buffered during execution to avoid crashing the iterator.

using System.Collections.Generic;
using UnityEngine;

public class UpdateManager : MonoBehaviour
{
    private static UpdateManager _instance;
    public static UpdateManager Instance
    {
        get
        {
            if (_instance == null)
            {
                var go = new GameObject("UpdateManager");
                _instance = go.AddComponent<UpdateManager>();
                DontDestroyOnLoad(go);
            }
            return _instance;
        }
    }

    private readonly List<IUpdateable> _updateables = new List<IUpdateable>();
    private readonly List<IUpdateable> _toAdd = new List<IUpdateable>();
    private readonly List<IUpdateable> _toRemove = new List<IUpdateable>();
    
    private bool _isUpdating;

    private void Awake()
    {
        if (_instance != null && _instance != this)
        {
            Destroy(gameObject);
            return;
        }
        _instance = this;
        DontDestroyOnLoad(gameObject);
    }

    // Register objects to the update loop
    public void Register(IUpdateable updateable)
    {
        if (updateable == null) return;
        
        if (_isUpdating)
        {
            if (!_toAdd.Contains(updateable) && !_updateables.Contains(updateable))
            {
                _toAdd.Add(updateable);
                _toRemove.Remove(updateable);
            }
        }
        else
        {
            if (!_updateables.Contains(updateable))
            {
                _updateables.Add(updateable);
            }
        }
    }

    // Unregister objects
    public void Unregister(IUpdateable updateable)
    {
        if (updateable == null) return;

        if (_isUpdating)
        {
            if (!_toRemove.Contains(updateable))
            {
                _toRemove.Add(updateable);
                _toAdd.Remove(updateable);
            }
        }
        else
        {
            _updateables.Remove(updateable);
        }
    }

    private void Update()
    {
        _isUpdating = true;
        
        float deltaTime = Time.deltaTime;
        int count = _updateables.Count;
        
        // Use a standard for-loop to scan items to avoid GC Allocations
        for (int i = 0; i < count; i++)
        {
            // Null check in case an object was destroyed during the frame
            if (_updateables[i] != null)
            {
                _updateables[i].OnUpdate(deltaTime);
            }
        }

        _isUpdating = false;

        // Apply buffered add/remove operations after the loop completes
        ProcessPendingRequests();
    }

    private void ProcessPendingRequests()
    {
        if (_toRemove.Count > 0)
        {
            for (int i = 0; i < _toRemove.Count; i++)
            {
                _updateables.Remove(_toRemove[i]);
            }
            _toRemove.Clear();
        }

        if (_toAdd.Count > 0)
        {
            for (int i = 0; i < _toAdd.Count; i++)
            {
                if (!_updateables.Contains(_toAdd[i]))
                {
                    _updateables.Add(_toAdd[i]);
                }
            }
            _toAdd.Clear();
        }
    }
}

3. Auto-Registration Base Class (ManagedMonoBehaviour.cs)

To avoid writing manual registration code for every script, implement a base class that wraps registration events automatically within the Unity lifecycle.

using UnityEngine;

public abstract class ManagedMonoBehaviour : MonoBehaviour, IUpdateable
{
    protected virtual void OnEnable()
    {
        // Automatically registers when active in scene
        UpdateManager.Instance.Register(this);
    }

    protected virtual void OnDisable()
    {
        // Automatically unregisters when deactivated or destroyed
        if (UpdateManager.Instance != null)
        {
            UpdateManager.Instance.Unregister(this);
        }
    }

    // Derived classes override this instead of using void Update()
    public abstract void OnUpdate(float deltaTime);
}

4. Usage Example

Inheriting from our base class integrates a script into the centralized manager automatically.

using UnityEngine;

public class Projectile : ManagedMonoBehaviour
{
    [SerializeField] private float speed = 10f;
    private Vector3 _direction = Vector3.forward;

    public override void OnUpdate(float deltaTime)
    {
        // Movement calculation run centralized
        transform.Translate(_direction * (speed * deltaTime));
    }
}

4. Performance Metrics: Benchmark Results

We measured the CPU overhead of individual MonoBehaviours vs. the centralized `UpdateManager` using 10,000 active GameObjects executing an empty update call.

  • Test Environment: PC (Core i7-12700 / Windows 11 / Unity 2022.3 LTS)
  • Item Count: 10,000 objects
Execution Method Profiler Metric (CPU Time) GC Alloc
Standard Method (Individual Update x 10,000) BehaviourUpdate: 8.2ms 0 B
Centralized Update Method UpdateManager.Update: 1.4ms 0 B

Result: For a load of 10,000 elements executing the exact same logic, switching to a centralized model reduces the update overhead by 83% (8.2ms to 1.4ms).
Saving 7 milliseconds per frame frees up a massive amount of CPU budget that can be used for game graphics, AI systems, or physics simulations.

5. Summary & Best Practices for Production

While the centralized update pattern is highly effective, consider these factors when implementing it in production:

  1. Controlling Execution Order: If certain systems need to update before or after others, you must sort the active list or split update pipelines by priority. You can extend the register logic (e.g., UpdateManager.Register(IUpdateable, int priority)) to handle sorting.
  2. Extending to FixedUpdate and LateUpdate: You can apply the same logic to physics loops and camera-follow systems by defining IFixedUpdateable and ILateUpdateable interfaces alongside separate list runners.
  3. Inspector Enable/Disable Support: Because ManagedMonoBehaviour registers and unregisters within OnEnable and OnDisable, checking or unchecking the component in the Unity Inspector behaves exactly as expected. However, toggling components frequently can cause list insertions and deletions, which introduce slight overhead. For frequently toggled entities, combine this pattern with an Object Pooler.

As games grow in complexity, small framework overheads accumulate quickly. Understanding internal structures like native-to-managed boundaries allows you to design high-performance architectures, keeping your framerates fluid even under heavy entity counts.