How to Choose and Manage Collections for Performance in Unity

- Unity collections performance directly impacts your game's frame rate and memory usage - choosing between managed collections (List, Array, Dictionary) and native collections (NativeArray, Nativ...

Key Takeaways

Ready to Start Building Your First Game?

Understanding Unity collections performance is crucial for building games that run smoothly across different devices. Whether you're optimizing a mobile game or building a large-scale multiplayer experience, choosing the right data structures makes all the difference.

Want to go from Unity basics to building professional game experiences? Check out our comprehensive course that takes you through everything from core concepts to advanced optimization techniques. You'll learn by building real projects and solving actual game development challenges.

Start Your Game Dev Journey →

Why Your Game's Frame Rate Depends on Collections

Here's the thing - I've seen way too many student projects that look amazing but run at 15 FPS because of one simple mistake: using the wrong collection type in Update loops. You spend weeks building beautiful game mechanics, polish your visuals until they shine, and then your game stutters every few seconds because garbage collection decides to kick in during an intense combat sequence.

Been there. Early in my career, I built this enemy wave system that worked perfectly with 10 enemies. Scaled it to 100 enemies for a bigger level, and suddenly the game was unplayable. The culprit? I was creating new List instances every frame and using LINQ queries in Update(). That's when I learned that Unity collections performance isn't just an optimization concern - it's fundamental to whether your game actually works.

The difference between a List and a NativeArray isn't just academic. It's the difference between your game running smoothly at 60 FPS or players rage-quitting because of frame drops. Let me show you exactly how to choose and manage collections so your games perform the way they should.

What Makes Collections a Performance Bottleneck

Let me start with something that took me months to figure out when I was learning Unity: collections aren't just containers for your data. They're active participants in your game's performance story, and they can absolutely wreck your frame rate if you choose wrong.

Collections determine three critical things: how your data is stored in memory, whether garbage collection will interrupt your gameplay, and whether you can leverage Unity's modern multithreading systems. That third point is huge - Unity Job System and Unity DOTS ECS only work with specific collection types, so your choice literally determines whether you can use Unity's most powerful performance features.

Unity offers two completely different collection ecosystems. Traditional C# managed collections (List, Array, Dictionary<TKey,TValue>) allocate on the managed heap and trigger garbage collection when you're done with them. Unity native collections like NativeArray, NativeList, and NativeHashMap use unmanaged memory with zero GC overhead but require manual disposal.

The key insight I wish someone had told me earlier: understanding when to use each type isn't optional knowledge for "advanced" developers. It's essential for building games that actually run well. Let's dig into how these systems work and when you should use each one.

The Two Collection Ecosystems You Need to Know

Unity gives you two completely separate worlds of collections, and understanding the difference is crucial.

Managed Collections are the traditional .NET types you might already know: List, Array, Dictionary<TKey,TValue>, HashSet, Queue, and Stack. These allocate memory on the garbage-collected heap, which means Unity's runtime automatically manages their memory. The convenience is nice - you create them, use them, and forget about them. The problem? That "forget about them" part triggers garbage collection cycles that cause visible frame stutters.

According to Unity's official documentation, "The Unity runtime engine doesn't return managed memory to the operating system for reuse until the user terminates the application." That means every time you allocate managed memory, you're permanently growing your game's memory footprint until the player quits. This isn't theoretical - I've debugged games where memory just kept climbing because developers were creating new collections in Update loops.

Official Documentation: Understanding Managed Memory

Native Collections live in unmanaged memory outside the garbage collector's reach. Unity.Collections types like NativeArray, NativeList, NativeHashMap use memory you explicitly allocate and must explicitly dispose. This manual management eliminates GC overhead entirely but introduces memory leak risks if you forget disposal.

The magic of native collections is that they're "thread-safe C# wrappers for unmanaged memory" that enable jobs to access shared data with the main thread rather than working with copies. This is what makes Unity Job System actually work - you can pass NativeArray references to worker threads safely.

Official Documentation: Unity.Collections Package Overview

Here's the practical difference: managed collections for prototyping and non-critical systems, native collections for performance-critical gameplay loops and anything using Job System or DOTS.

When Memory Management Becomes Your Problem

Garbage Collection (GC) is Unity's system for automatically reclaiming unused managed memory. Sounds helpful, right? Actually, it's one of the biggest performance pitfalls in Unity development.

Unity uses the Boehm-Demers-Weiser garbage collector, which is a conservative, non-generational collector. The critical detail: it's not generational, meaning it can't efficiently sweep small, frequent temporary allocations. Every time you create a new List in Update() or use LINQ, you're creating work for the GC. When the collector eventually runs, it stops all program execution - this "stop-the-world" behavior is what causes those visible frame rate hitches.

Official Documentation: Garbage Collection Best Practices

The primary optimization strategy Unity recommends is to "avoid triggering the garbage collector frequently." This means minimizing heap allocations, reusing collections via Clear() instead of creating new instances, and preferring native collections in performance-critical scenarios.

Unity 2019+ includes an incremental GC mode that spreads collection work across multiple frames, reducing individual spike magnitude even if total GC work remains unchanged. This helps, but doesn't eliminate the problem.

Memory Allocators for Native Collections

When you create native collections, you choose one of three allocator types that determine lifetime and performance:

Allocator.Temp - "The fastest allocator, for short-lived allocations." Maximum lifetime of one frame. The main thread creates one Temp allocator per frame and deallocates it automatically at frame end. Minimum alignment is 64 bytes. Critical restriction: thread-scoped usage only - you cannot pass main thread Temp allocations to jobs.

Allocator.TempJob - Moderate speed allocator allowing cross-thread usage in jobs. Critical constraint: "you must deallocate TempJob allocations within 4 frames of their creation." Native collections throw exceptions if TempJob allocations exceed this window. Minimum alignment is 16 bytes.

Allocator.Persistent - "The slowest allocator for indefinite lifetime allocations." Supports unlimited lifetime throughout application runtime. "You must deallocate a Persistent allocation when you no longer need it" since safety checks cannot detect lifetime violations. Minimum alignment is 16 bytes.

Official Documentation: Allocator Overview

csharp
// Temp - fastest, single frame
NativeArray<int> tempData = new NativeArray<int>(100, Allocator.Temp);

// TempJob - jobs, max 4 frames
NativeArray<int> jobData = new NativeArray<int>(100, Allocator.TempJob);
// Must dispose within 4 frames

// Persistent - long-lived, slowest
NativeArray<int> persistentData = new NativeArray<int>(100, Allocator.Persistent);
// Must dispose manually when done

Choose allocators based on data lifetime: Temp for immediate single-frame use, TempJob for job-scheduled operations spanning up to 4 frames, and Persistent only for truly long-lived data where the performance cost is acceptable.

Traditional C# Collections: Your Starting Point

Unity supports the full range of .NET Standard generic collections: List, Array, Dictionary<TKey,TValue>, HashSet, Queue, and Stack. These types allocate on the managed heap and trigger garbage collection Unity when unreferenced.

Official Documentation: .NET Collections Selection Guide

For prototyping and early development, these collections are perfect. They're convenient, familiar if you know C#, and have rich APIs for manipulation. The problems emerge when you start scaling - more enemies, more projectiles, more data processed per frame.

I always tell students to start with traditional collections. Get your game working first. Then profile to find bottlenecks. Only then should you migrate performance-critical systems to native collections. Premature optimization wastes time on systems that don't matter.

Arrays - The Speed Champion for Fixed Data

Arrays represent Unity's fastest traditional collection type for fixed-size data. Performance benchmarking from Jackson Dunstan shows arrays are approximately 5x faster than List for both reads and writes, with writes experiencing particularly dramatic differences (List writes are 695% slower).

csharp
// Fixed-size array - best performance
int[] fixedData = new int[1000];

// Direct element access - no overhead
for (int i = 0; i < fixedData.Length; i++)
{
    fixedData[i] = i * 2;
}

Use Arrays When:

Performance Characteristics:

Limitations:

I use arrays extensively for game grids (tilemap data), lookup tables (damage calculations by level), and any fixed-size buffer that processes every frame. The speed difference vs List is real and measurable.

List - The Flexible Workhorse

List provides the best balance between performance and flexibility for traditional Unity development. It offers dynamic resizing, indexed access, and a rich API for manipulations. Performance testing shows Unity List vs Array has List about 2x slower for iteration but significantly faster than Dictionary (8-10x faster for iteration).

csharp
// Dynamic list with pre-allocated capacity
private List<Enemy> enemies = new List<Enemy>(100);

void ProcessEnemies()
{
    // Reuse list - avoid new allocation
    enemies.Clear();

    // Add enemies
    foreach (var enemy in allEnemies)
    {
        if (enemy.isActive)
            enemies.Add(enemy);
    }

    // Process enemies
    for (int i = 0; i < enemies.Count; i++)
    {
        enemies[i].Update();
    }
}

Official Documentation: List Performance Optimization

Use List When:

Performance Characteristics:

Critical Performance Tip - Capacity Pre-allocation:

When a List exceeds its capacity, it must allocate a new larger array (typically double size), copy all elements, and deallocate the old array. This is expensive. Pre-allocate capacity when size is predictable:

csharp
// Bad - starts small, triggers multiple resizes
List<Item> items = new List<Item>(); // Default capacity ~4
for (int i = 0; i < 1000; i++)
{
    items.Add(new Item()); // Triggers ~8 resize operations
}

// Good - pre-allocate to avoid resizing
List<Item> items = new List<Item>(1000);
for (int i = 0; i < 1000; i++)
{
    items.Add(new Item()); // No resizing needed
}

Collection Reuse Pattern:

Creating new List instances every frame causes continuous GC pressure. Reuse collections via Clear():

csharp
// Bad - allocates 40+ bytes per frame
void Update()
{
    List<Enemy> nearby = new List<Enemy>();
    // populate and use
}

// Good - reuse collection
private List<Enemy> nearbyEnemies = new List<Enemy>(100);

void Update()
{
    nearbyEnemies.Clear(); // Capacity remains 100
    // populate and use - no allocation
}

This pattern alone eliminated 90% of GC allocations in one of my student projects. Simple change, massive impact.

Dictionary and HashSet - Fast Lookups with Hidden Costs

Dictionary provides O(1) average-case lookup by key but performs 8-10x slower than List when iterating over all elements. The hash table structure introduces memory overhead beyond simple element storage.

csharp
// Dictionary for fast key-value lookups
private Dictionary<int, Item> itemsById = new Dictionary<int, Item>(100);

void RegisterItem(Item item)
{
    itemsById[item.id] = item; // O(1) insertion
}

Item GetItem(int id)
{
    if (itemsById.TryGetValue(id, out Item item))
        return item;
    return null;
}

Use Dictionary<TKey, TValue> When:

Performance Characteristics:

Critical Pitfall - Enum Keys:

Using enum keys with Dictionary causes heap allocations on every lookup due to boxing. This is problematic for performance-critical code:

csharp
// Bad - boxes enum on every lookup (allocates 24+ bytes)
private Dictionary<EnemyType, int> enemyCounts = new Dictionary<EnemyType, int>();
int count = enemyCounts[EnemyType.Zombie]; // Boxing allocation!

// Good - use enum as integer array index
private int[] enemyCounts = new int[(int)EnemyType.Count];
int count = enemyCounts[(int)EnemyType.Zombie]; // No allocation

I learned this one the hard way during a game jam. My ability cooldown system was boxing enums every Update(), causing continuous allocations. Switching to array indexing made the problem disappear instantly.

HashSet stores unique values with O(1) Contains() checks. Use for uniqueness guarantees and set operations (union, intersection, subset).

csharp
// HashSet for uniqueness and fast membership testing
private HashSet<int> processedEntityIds = new HashSet<int>();

void ProcessEntity(int entityId)
{
    if (!processedEntityIds.Add(entityId))
        return; // Already processed - Add returns false for duplicates

    // Process entity
}

Use HashSet When:

Performance Characteristics:

Queue and Stack - Specialized Ordering

Queue provides FIFO (First-In-First-Out) ordering while Stack provides LIFO (Last-In-First-Out) ordering.

csharp
// Queue for FIFO processing
private Queue<Action> actionQueue = new Queue<Action>();

void EnqueueAction(Action action)
{
    actionQueue.Enqueue(action);
}

void ProcessActions()
{
    while (actionQueue.Count > 0)
    {
        Action action = actionQueue.Dequeue();
        action.Invoke();
    }
}

Use Queue When: Need FIFO behavior (breadth-first traversal, action queues) Use Stack When: Need LIFO behavior (depth-first traversal, undo systems)

I use queues for command systems and event processing. Stacks are perfect for undo/redo functionality and certain pathfinding algorithms.

Object Pooling for Collection Reuse

Unity 2021+ includes built-in CollectionPool for reusing List, Dictionary, and HashSet instances to minimize GC pressure:

csharp
using UnityEngine.Pool;

// Get pooled list
var list = ListPool<int>.Get();

try
{
    // Use list
    list.Add(1);
    list.Add(2);
}
finally
{
    // Return to pool (automatically cleared)
    ListPool<int>.Release(list);
}

// Also available:
// DictionaryPool<TKey, TValue>
// HashSetPool<T>

Official Documentation: CollectionPool API

Benchmarking shows object pooling can improve spawning performance by 77% compared to instantiation (~33% average improvement). For scenarios creating/destroying 600+ GameObjects per second, pooling prevents frame rates from dropping to 10-20 fps on mid-range CPUs.

Important Caveat: CollectionPool is not thread-safe and can break domain reload in the editor if used carelessly, as pooled elements persist across editor runs.

Unity Native Collections - Zero Garbage, Maximum Speed

Here's where things get interesting. Unity native collections are the foundation of Unity's high-performance programming stack. They eliminate garbage collection entirely, enable true multithreading through the Job System, and integrate seamlessly with the Unity Burst compiler for extreme optimization.

The Unity.Collections package provides three tiers of collections:

Official Documentation: Collections Package Manual

1. Native Collections (Unity.Collections namespace): Include safety checks ensuring proper disposal and thread-safe usage. Types begin with "Native" prefix (NativeArray, NativeList, NativeHashMap, NativeHashSet). These should be your default choice.

csharp
NativeArray<float> data = new NativeArray<float>(1000, Allocator.TempJob);
// Use data in jobs...
data.Dispose(); // Required - manual disposal

Official Documentation: NativeArray API Reference

2. Unsafe Collections (Unity.Collections.LowLevel.Unsafe namespace): Omit safety checks for maximum performance. Types begin with "Unsafe" prefix (UnsafeList, UnsafeHashMap). Use when safety checks verified at higher level or nesting required.

3. Fixed Collections: Stack-allocated types with fixed byte capacity (FixedList32Bytes through FixedList4096Bytes, FixedString32Bytes through FixedString4096Bytes). These require no disposal as they're value types stored on the stack.

A critical architectural constraint: "Native collection types can't contain other Native collections" due to how the safety system functions. For nested structures, use hybrid patterns like NativeList<UnsafeList<T>>.

Official Documentation: Unsafe Collections Namespace

The real power of native collections becomes clear when combined with Job System and Burst. I've seen performance improvements ranging from 10x to 50x just by switching from managed collections in Update() to native collections in jobs. The combination is genuinely transformative.

NativeArray - Your First Step into High Performance

NativeArray Unity is "a fixed-size unmanaged memory buffer accessible from managed code" that enables "data sharing between managed and native code without marshalling costs." It's Unity's foundational collection type for the Job System.

csharp
// Create NativeArray with TempJob allocator
NativeArray<float> positions = new NativeArray<float>(1000, Allocator.TempJob);

// Schedule job using the array
var job = new ProcessPositionsJob
{
    positions = positions
};
JobHandle handle = job.Schedule();

// Wait for completion
handle.Complete();

// Must manually dispose
positions.Dispose();

Official Documentation: NativeArray API Reference

Use NativeArray When:

Performance Characteristics:

Real-world benchmark: Unity forum test with 100k points showed regular arrays at 60 FPS while NativeArray achieved 79 FPS in production context, demonstrating NativeArray's superior performance when safety checks disabled.

Type Constraints:

API Operations:

csharp
// Creation
NativeArray<int> data = new NativeArray<int>(100, Allocator.TempJob);

// Element access
data[0] = 5;
int value = data[0];

// Copy operations
int[] managedArray = new int[100];
data.CopyFrom(managedArray);
data.CopyTo(managedArray);

// Read-only variant
NativeArray<int>.ReadOnly readOnly = data.AsReadOnly();

// Check if allocated
bool isValid = data.IsCreated;

// Disposal
data.Dispose();

// Scheduled disposal after job
JobHandle handle = myJob.Schedule();
data.Dispose(handle); // Disposes after job completes

Critical Disposal Pattern:

All native collections must be explicitly disposed. Forgetting disposal causes memory leaks with the error "A Native Collection has not been disposed, resulting in a memory leak." Use try-finally patterns or Dispose(JobHandle) for scheduled jobs:

csharp
NativeArray<int> data = new NativeArray<int>(100, Allocator.TempJob);
try
{
    // Use data
}
finally
{
    data.Dispose();
}

I recommend wrapping NativeArray usage in try-finally blocks during development. It's saved me from countless memory leaks when exceptions occurred before reaching disposal code.

NativeList - Dynamic Sizing Without GC Overhead

NativeList is "an unmanaged, resizable list" that stores elements contiguously, providing dynamic sizing in unmanaged memory. Access performance matches NativeArray since the underlying representation is identical - just a pointer to an array.

csharp
// Create NativeList with initial capacity
NativeList<float> dynamicData = new NativeList<float>(100, Allocator.TempJob);

// Add elements (auto-resizes if needed)
for (int i = 0; i < 200; i++)
{
    dynamicData.Add(i * 1.5f);
}

// Access like array
float value = dynamicData[50];

// Use in job
var job = new ProcessListJob
{
    data = dynamicData
};
job.Schedule().Complete();

// Dispose
dynamicData.Dispose();

Official Documentation: NativeList API Reference

Use NativeList When:

Performance Characteristics:

Key API Methods:

csharp
NativeList<int> list = new NativeList<int>(10, Allocator.TempJob);

// Adding elements
list.Add(5);
list.AddNoResize(10); // Throws if capacity exceeded
list.AddRange(otherArray);
list.AddReplicate(100, 5); // Add value 5 times

// Removing elements
list.RemoveAt(0); // Shifts remaining items - O(n)
list.RemoveAtSwapBack(0); // Swaps with last - O(1)
list.Clear(); // Removes all, keeps capacity

// Capacity management
list.Capacity = 200; // Set capacity
list.TrimExcess(); // Match capacity to length

// Conversion
NativeArray<int> array = list.AsArray(); // View as array
list.Dispose();

Parallel Writing:

csharp
[BurstCompile]
struct ParallelAppendJob : IJobParallelFor
{
    public NativeList<int>.ParallelWriter results;

    public void Execute(int index)
    {
        results.AddNoResize(index * 2); // Thread-safe append
    }
}

NativeList<int> results = new NativeList<int>(1000, Allocator.TempJob);
results.Resize(1000, NativeArrayOptions.UninitializedMemory);

var job = new ParallelAppendJob
{
    results = results.AsParallelWriter()
};
job.Schedule(1000, 64).Complete();

results.Dispose();

Important Limitation: NativeList cannot change capacity during parallel jobs. Pre-allocate sufficient capacity before parallel writes, or use AsParallelWriter() with AddNoResize().

NativeHashMap and Parallel Variants

NativeHashMap is "an unordered, expandable associative array" providing key-value storage in unmanaged memory. Both key and value must be unmanaged types with TKey implementing IEquatable.

csharp
// Create hash map
NativeHashMap<int, float> scoreMap = new NativeHashMap<int, float>(100, Allocator.TempJob);

// Add key-value pairs
scoreMap.Add(1, 100.5f);

// Safe addition (doesn't throw if key exists)
bool added = scoreMap.TryAdd(2, 250.0f);

// Retrieve values
if (scoreMap.TryGetValue(1, out float score))
{
    // Use score
}

// Check key existence
bool hasKey = scoreMap.ContainsKey(1);

// Remove entries
bool removed = scoreMap.Remove(1);

// Clear all
scoreMap.Clear();

// Dispose
scoreMap.Dispose();

Official Documentation: NativeHashMap API Reference

Use NativeHashMap<TKey, TValue> When:

Performance Characteristics:

NativeParallelHashMap<TKey, TValue>

NativeParallelHashMap extends NativeHashMap with thread-safe parallel write capabilities. The critical difference is the AsParallelWriter() method which "enables concurrent writes across multiple jobs."

csharp
// Create parallel hash map
NativeParallelHashMap<int, float> parallelMap =
    new NativeParallelHashMap<int, float>(1000, Allocator.TempJob);

// Use in parallel job
[BurstCompile]
struct BuildMapJob : IJobParallelFor
{
    public NativeParallelHashMap<int, float>.ParallelWriter map;

    public void Execute(int index)
    {
        map.TryAdd(index, index * 2.5f); // Thread-safe add
    }
}

var job = new BuildMapJob
{
    map = parallelMap.AsParallelWriter()
};
job.Schedule(1000, 64).Complete();

parallelMap.Dispose();

Official Documentation: NativeParallelHashMap API Reference

Use NativeParallelHashMap<TKey, TValue> When:

NativeParallelMultiHashMap<TKey, TValue>

NativeParallelMultiHashMap allows multiple values per key. "The keys are not deduplicated: two key-value pairs with the same key are stored as fully separate key-value pairs." This enables one-to-many relationships.

csharp
// Create multi hash map
NativeParallelMultiHashMap<int, Entity> cellToEntities =
    new NativeParallelMultiHashMap<int, Entity>(1000, Allocator.TempJob);

// Add multiple values for same key
cellToEntities.Add(5, entity1);
cellToEntities.Add(5, entity2);
cellToEntities.Add(5, entity3);

// Iterate all values for a key
if (cellToEntities.TryGetFirstValue(5, out Entity entity, out var iterator))
{
    do
    {
        // Process entity
    } while (cellToEntities.TryGetNextValue(out entity, ref iterator));
}

// Count values for key
int count = cellToEntities.CountValuesForKey(5);

cellToEntities.Dispose();

Official Documentation: NativeParallelMultiHashMap API Reference

Use NativeParallelMultiHashMap<TKey, TValue> When:

I use NativeParallelMultiHashMap extensively for spatial queries in my projects - dividing the world into grid cells and storing which entities are in each cell. It's perfect for optimizing collision detection and AI perception systems.

NativeQueue and NativeStream for Specialized Workflows

NativeQueue provides FIFO (First-In-First-Out) ordering in unmanaged memory. "A resizable queue. Has thread and disposal safety checks."

csharp
// Create queue
NativeQueue<int> queue = new NativeQueue<int>(Allocator.TempJob);

// Enqueue elements
queue.Enqueue(1);
queue.Enqueue(2);
queue.Enqueue(3);

// Dequeue elements
int first = queue.Dequeue(); // Returns 1

// Safe dequeue
if (queue.TryDequeue(out int value))
{
    // Use value
}

// Peek without removing
int front = queue.Peek();

// Check if empty
bool empty = queue.IsEmpty();

queue.Dispose();

Official Documentation: NativeQueue API Reference

Use NativeQueue When:

Performance Note: Count property has O(n) complexity (traverses internal structure) - cache if used repeatedly.

NativeStream provides "a set of untyped, append-only buffers" designed for concurrent operations with variable-length data. The architecture excels because "each individual buffer is written in one thread and read in one thread, multiple threads can read and write the stream concurrently."

Official Documentation: NativeStream API Reference

Unlike traditional dynamic arrays, NativeStream uses "a chain of blocks" for storage. When capacity exceeded, new blocks are appended rather than copying existing data - a significant efficiency advantage for growing datasets.

csharp
// Create stream with one buffer per thread
int threadCount = JobsUtility.MaxJobThreadCount;
NativeStream stream = new NativeStream(threadCount, Allocator.TempJob);

// Writing in job
[BurstCompile]
struct WriteStreamJob : IJobParallelFor
{
    public NativeStream.Writer writer;

    public void Execute(int index)
    {
        writer.BeginForEachIndex(index);
        writer.Write(index);
        writer.Write(index * 2.0f);
        writer.EndForEachIndex();
    }
}

var writeJob = new WriteStreamJob
{
    writer = stream.AsWriter()
};
writeJob.Schedule(threadCount, 1).Complete();

// Reading after all writes complete
NativeStream.Reader reader = stream.AsReader();
for (int i = 0; i < threadCount; i++)
{
    reader.BeginForEachIndex(i);
    while (!reader.EndOfStream())
    {
        int intValue = reader.Read<int>();
        float floatValue = reader.Read<float>();
    }
    reader.EndForEachIndex();
}

stream.Dispose();

Use NativeStream When:

Critical Usage Pattern: "All writing to a stream should be completed before the stream is first read. Do not write to a stream after the first read."

FixedList - Stack-Allocated Collections

FixedList variants (FixedList32Bytes, FixedList64Bytes, FixedList128Bytes, FixedList512Bytes, FixedList4096Bytes) are "unmanaged, resizable lists whose content is all stored directly in the struct." This design allows entire lists to reside on the stack rather than requiring heap allocation.

csharp
// Stack-allocated list - no heap allocation
FixedList128Bytes<int> smallList = new FixedList128Bytes<int>();

// Add elements
smallList.Add(1);
smallList.Add(2);
smallList.Add(3);

// Access elements
int value = smallList[0];

// Use in ECS component
public struct PathComponent : IComponentData
{
    public FixedList128Bytes<float3> waypoints; // No disposal needed
}

// No disposal required - value type

Official Documentation: FixedList32Bytes API Reference

Use FixedList When:

Performance Characteristics:

Size Constraints:

The struct has fixed byte capacity. Actual element capacity depends on sizeof(T):

Choose size variant based on maximum expected element count. Attempting to copy from larger FixedList variants throws IndexOutOfRangeException if source length exceeds capacity.

NativeReference

NativeReference is "the functional equivalent of an array of length 1. When you need just one value, NativeReference can be preferable to an array because it better conveys the intent."

csharp
// Single value container
NativeReference<int> counter = new NativeReference<int>(0, Allocator.TempJob);

[BurstCompile]
struct IncrementJob : IJob
{
    public NativeReference<int> counter;

    public void Execute()
    {
        counter.Value++;
    }
}

var job = new IncrementJob { counter = counter };
job.Schedule().Complete();

int result = counter.Value;
counter.Dispose();

Official Documentation: NativeReference API Reference

Use NativeReference When:

Unsafe Collections

For performance-critical scenarios, Unity provides Unity.Collections.LowLevel.Unsafe types like UnsafeList and UnsafeHashMap. Key characteristics:

Official Documentation: Unsafe Collections Namespace

When to Use Unsafe Collections:

csharp
// Nested collections require Unsafe variants
NativeList<UnsafeList<int>> nestedLists = new NativeList<UnsafeList<int>>(10, Allocator.TempJob);

UnsafeList<int> innerList = new UnsafeList<int>(10, Allocator.TempJob);
innerList.Add(1);
innerList.Add(2);
nestedLists.Add(innerList);

// Dispose in reverse order
for (int i = 0; i < nestedLists.Length; i++)
{
    nestedLists[i].Dispose();
}
nestedLists.Dispose();

ECS Collections - When You're Building at Scale

DOTS (Data-Oriented Technology Stack) is Unity's architecture for building high-performance, scalable applications through data-oriented design principles. The Entities package provides "a data-oriented implementation of the Entity Component System (ECS) architecture" where data and logic are strictly separated.

Official Documentation: Unity Entities Package

Entities are "a unique identifier, like a lightweight unmanaged alternative to a GameObject" - they serve as IDs that associate with components rather than containing code. Components are pure data containers attached to entities that "contain data about the entity" with "entities contain no code." Systems contain all processing logic that iterates over entities with specific component combinations.

The architecture enables archetypes which "group together components" - entities with identical component sets share an archetype. This allows Unity to store entity data in contiguous memory chunks optimized for cache efficiency. The memory layout is the foundation of ECS performance benefits.

Production games using DOTS report CPU performance improvements ranging from 5-50x depending on parallelization effectiveness. Conway's Game of Life implementation using DOTS achieved "almost 200 times" speedup compared to non-DOTS versions. Hardspace: Shipbreaker reduced specific processes from 1 hour to 100 milliseconds (360x improvement).

DynamicBuffer - The ECS Way to Handle Arrays

DynamicBuffer is the ECS-native collection type for dynamic arrays within entities. Unlike separate native collections, DynamicBuffer integrates directly as a component type with ECS managing its lifecycle.

csharp
// Define buffer element
public struct PathPoint : IBufferElementData
{
    public float3 position;
}

// Add buffer to entity
Entity pathEntity = entityManager.CreateEntity();
DynamicBuffer<PathPoint> pathBuffer = entityManager.AddBuffer<PathPoint>(pathEntity);

// Add elements
pathBuffer.Add(new PathPoint { position = new float3(0, 0, 0) });
pathBuffer.Add(new PathPoint { position = new float3(1, 0, 0) });
pathBuffer.Add(new PathPoint { position = new float3(2, 0, 0) });

// Access in system
partial class PathFollowSystem : SystemBase
{
    protected override void OnUpdate()
    {
        Entities.ForEach((Entity entity,
                         ref Translation translation,
                         in DynamicBuffer<PathPoint> path) =>
        {
            if (path.Length > 0)
                translation.Value = path[0].position;
        }).Schedule();
    }
}

Official Documentation: Dynamic Buffer Components

Use DynamicBuffer When:

Performance Characteristics:

DynamicBuffer vs NativeList:

Choose DynamicBuffer when data is attached to entities as components. DynamicBuffer integrates with entity queries and component system. Choose NativeList for temporary collections within jobs not attached to entities.

Internal Capacity Configuration:

csharp
// Specify internal capacity (default varies)
[InternalBufferCapacity(64)]
public struct LargePathPoint : IBufferElementData
{
    public float3 position;
    public float3 velocity;
}

Higher internal capacity stores more elements inline but increases entity size in chunks. Balance between inline storage benefits and chunk Unity memory optimization.

Thread Safety and the Job System Safety Net

Unity's native collections integrate with a sophisticated safety system tracking read/write access across jobs. The safety system enforces deterministic behavior by preventing simultaneous writes to the same container by different jobs, allowing multiple parallel reads from identical data, blocking main thread access until dependent jobs complete, and throwing clear exceptions when violations occur.

Official Documentation: Job System Native Containers

By default, jobs have read and write access to native collections. This prevents Unity from scheduling multiple jobs writing to the same collection simultaneously. The [ReadOnly] attribute enables crucial optimizations.

ReadOnly Attribute

The ReadOnlyAttribute marks native container fields in job structs as read-only, enabling parallel execution of multiple jobs accessing the same data.

csharp
[BurstCompile]
public struct ParallelReadJob : IJobParallelFor
{
    [ReadOnly]
    public NativeArray<float> input; // Multiple jobs can read

    public NativeArray<float> output; // Only this job writes

    public void Execute(int index)
    {
        output[index] = math.sqrt(input[index]);
    }
}

// Can schedule multiple jobs reading same input
NativeArray<float> sharedInput = new NativeArray<float>(1000, Allocator.TempJob);
NativeArray<float> output1 = new NativeArray<float>(1000, Allocator.TempJob);
NativeArray<float> output2 = new NativeArray<float>(1000, Allocator.TempJob);

var job1 = new ParallelReadJob { input = sharedInput, output = output1 };
var job2 = new ParallelReadJob { input = sharedInput, output = output2 };

// Both jobs can run in parallel - both only read input
JobHandle handle1 = job1.Schedule(1000, 64);
JobHandle handle2 = job2.Schedule(1000, 64);

JobHandle.CombineDependencies(handle1, handle2).Complete();

sharedInput.Dispose();
output1.Dispose();
output2.Dispose();

Official Documentation: ReadOnlyAttribute API Reference

Benefits of [ReadOnly]:

Job System and Burst Compiler

The Unity C# Job System enables developers to "write simple and safe multithreaded code so that your application can use all available CPU cores to execute your code." The system automatically manages worker threads and distributes work, eliminating manual thread management. Jobs are implemented as structs containing an Execute() method that runs on worker threads.

Official Documentation: Job System Overview

Two primary job interfaces exist:

IJob - Executes once on a worker thread. "After a job is scheduled, the job's Execute method is invoked on a worker thread." Use for single operations that benefit from running in parallel with the main thread.

csharp
[BurstCompile]
public struct MyJob : IJob
{
    public NativeArray<float> result;

    public void Execute()
    {
        for (int i = 0; i < result.Length; i++)
        {
            result[i] = math.sqrt(i);
        }
    }
}

Official Documentation: IJob Interface

IJobParallelFor - "A job that performs the same independent operation for each element of a native container or for a fixed number of iterations." Unlike IJob, this interface "invokes the Execute method once per item in the data source" with automatic work distribution across CPU cores. Each iteration must be independent. The system employs work stealing: "When a native job completes its batches before others, it steals remaining batches from the other native jobs."

csharp
[BurstCompile]
public struct ParallelJob : IJobParallelFor
{
    [ReadOnly] public NativeArray<float> input;
    public NativeArray<float> output;

    public void Execute(int index)
    {
        output[index] = math.sqrt(input[index]);
    }
}

Official Documentation: IJobParallelFor Interface

The Unity Burst compiler translates a subset of C# called High-Performance C# (HPC#) into highly-optimized native CPU code. It "uses LLVM to translate .NET Intermediate Language (IL) to code that's optimized for performance on the target CPU architecture." Apply the [BurstCompile] attribute to jobs or static methods to enable compilation. When combined with native collections, Burst can achieve 10x performance improvements through SIMD optimization and native code generation.

Official Documentation: Burst Compiler Package

csharp
[BurstCompile]
public struct BurstOptimizedJob : IJobParallelFor
{
    public NativeArray<float> data;

    public void Execute(int index)
    {
        // This code Burst-compiled to native instructions
        data[index] = math.sqrt(data[index] * 2.0f);
    }
}

Real-world performance data shows dramatic improvements: one documented case showed a job going from 7ms to 0.5ms (14x improvement) simply by enabling Burst compilation. The combination of Job System + Burst + Native Collections represents Unity's highest-performance code path.

Job Dependencies

JobHandle is a struct that "uniquely identifies a job scheduled in the job system." Dependencies control execution order when one job relies on another's results.

csharp
// Basic dependency chain
JobHandle firstHandle = firstJob.Schedule();
JobHandle secondHandle = secondJob.Schedule(firstHandle); // Waits for first

// Combining multiple dependencies
NativeArray<JobHandle> handles = new NativeArray<JobHandle>(3, Allocator.Temp);
handles[0] = job1.Schedule();
handles[1] = job2.Schedule();
handles[2] = job3.Schedule();

JobHandle combined = JobHandle.CombineDependencies(handles);
finalJob.Schedule(combined).Complete();

handles.Dispose();

Official Documentation: Job Dependencies

Best Practices:

AtomicSafetyHandle System

Each native collection has an AtomicSafetyHandle that locks when a job is scheduled and releases when the job completes. The safety system tracks usage and enforces deterministic behavior.

Official Documentation: NativeContainer Attribute

Safety checks include:

Critical Note: Safety system operates only in Unity Editor and Development Builds. Release builds strip these checks entirely for maximum performance, making safety system a zero-cost abstraction in production.

Real Performance Numbers You Can Expect

Let me show you actual performance data from benchmarks and production games. These aren't theoretical - these are real numbers from real projects.

Managed Collection Performance

Performance testing across multiple sources establishes clear hierarchies:

Array vs List:

Source: Jackson Dunstan - "Array vs. List Performance" (https://www.jacksondunstan.com/articles/3058)

List vs Dictionary Iteration:

Array Performance in Unity:

Native Collection Performance

NativeArray vs Regular Arrays:

In Unity Editor (Development Builds):

In Production Builds:

Source: Medium - "[Unity ECS] Native container performance test vs normal array by 5argon"

With Job System and Burst:

Source: RetroStyle Games Medium - "Unity Job System in Practice"

NativeHashMap vs Dictionary:

NativeList vs List:

DOTS/ECS Performance Gains

Production games using DOTS report dramatic improvements:

Conway's Game of Life:

Hardspace: Shipbreaker:

General DOTS Performance:

Source: Medium - "Unity DOTS/ECS Performance: Amazing" by Anton Antich

Garbage Collection Impact

GC Allocation Goals:

Common Allocation Sources:

Source: Jackson Dunstan - "Just How Much Garbage Does LINQ Create?"

Object Pooling Performance:

Frame Rate Targets:

Games That Got It Right (And Their Techniques)

Let me share some real games that nailed collection performance. These examples show how the techniques we've discussed actually work in production.

V Rising - Full DOTS Production at Scale

Game: V Rising by Stunlock Studios What They Built: Open-world multiplayer survival game

V Rising is an open-world multiplayer survival game that used Entity Component System throughout development. The game handles demanding requirements of persistent open-world multiplayer gameplay with hundreds of concurrent entities. Stunlock Studios leveraged native collections (NativeArray, NativeHashMap, DynamicBuffer) for entity data management, enabling efficient multithreading of game systems. The ECS architecture allowed them to process thousands of entities (players, NPCs, items, projectiles) across multiple systems in parallel using the Job System and Burst compiler.

Key Lessons:

Source: Unity ECS page showcasing shipped games

How to Apply This:

csharp
// Entity-based inventory management like V Rising
public struct InventoryItem : IBufferElementData
{
    public Entity itemEntity;
    public int quantity;
    public int slotIndex;
}

// System processes inventories efficiently
[BurstCompile]
partial struct InventorySystem : ISystem
{
    public void OnUpdate(ref SystemState state)
    {
        // Process thousands of inventories in parallel
        foreach (var (inventory, entity) in
                 SystemAPI.Query<DynamicBuffer<InventoryItem>>()
                          .WithEntityAccess())
        {
            // Efficient iteration over player inventories
        }
    }
}

Ocean Keeper - 35x Performance Win with Job System

Game: Ocean Keeper by RetroStyle Games The Problem: Searching one million spawn points took 140ms

RetroStyle Games needed to spawn many enemies and find spawn points - an expensive operation. Initially, searching one million items took 140ms. By implementing Unity's Job System with NativeArray Unity, they achieved dramatic optimization: searching one million positions took only 4ms, a 35x performance improvement. The team used NativeArray to pass spawn point data between main thread and worker threads, leveraging IJobParallelFor to distribute the search work across CPU cores with Burst compilation enabled.

Key Lessons:

Source: RetroStyle Games Medium article - "Unity Job System in Practice: How we increased FPS from 15 to 70"

The Code Pattern:

csharp
[BurstCompile]
public struct FindSpawnPointsJob : IJobParallelFor
{
    [ReadOnly] public NativeArray<float3> candidatePositions;
    [ReadOnly] public NativeArray<float3> obstaclePositions;
    public NativeArray<int> validIndices;
    public float minDistance;

    public void Execute(int index)
    {
        float3 candidate = candidatePositions[index];
        bool isValid = true;

        // Check against all obstacles
        for (int i = 0; i < obstaclePositions.Length; i++)
        {
            if (math.distance(candidate, obstaclePositions[i]) < minDistance)
            {
                isValid = false;
                break;
            }
        }

        validIndices[index] = isValid ? 1 : 0;
    }
}

// Usage
NativeArray<float3> candidates = new NativeArray<float3>(1000000, Allocator.TempJob);
NativeArray<float3> obstacles = new NativeArray<float3>(500, Allocator.TempJob);
NativeArray<int> valid = new NativeArray<int>(1000000, Allocator.TempJob);

// Fill candidates and obstacles...

var job = new FindSpawnPointsJob
{
    candidatePositions = candidates,
    obstaclePositions = obstacles,
    validIndices = valid,
    minDistance = 5.0f
};

job.Schedule(candidates.Length, 64).Complete();

// Process results - took 4ms instead of 140ms!

candidates.Dispose();
obstacles.Dispose();
valid.Dispose();

This is the pattern I recommend to all my students when they're dealing with large-scale spatial queries or collision detection. The performance improvement is genuinely game-changing.

Academia: School Simulator - Turning Reviews Around with DOTS

Game: Academia: School Simulator by Coffee Brain Games The Problem: Poor simulation performance causing mixed Steam reviews

Coffee Brain Games faced poor simulation performance causing mixed Steam reviews. After integrating Unity DOTS ECS for their custom 2D renderer and porting A* pathfinding code to Burst compilation, performance improved dramatically with "wiggle room in the performance budget." They used a hybrid architecture: MonoBehaviour for presentation layer, UI, and high-level logic; DOTS/ECS for simulation, AI pathfinding, and physics-heavy systems. The team used NativeList and NativeMultiHashMap extensively for pathfinding node storage and spatial queries.

Key Lessons:

Source: Coffee Brain Games blog series on Academia development

Pathfinding Pattern:

csharp
// Pathfinding with native collections
[BurstCompile]
public struct AStarPathfindingJob : IJob
{
    [ReadOnly] public NativeArray<float3> nodes;
    [ReadOnly] public NativeMultiHashMap<int, int> nodeConnections;
    public NativeList<int> resultPath;
    public int startNode;
    public int goalNode;

    public void Execute()
    {
        // A* implementation using native collections
        NativeList<int> openSet = new NativeList<int>(100, Allocator.Temp);
        NativeHashMap<int, float> gScore = new NativeHashMap<int, float>(nodes.Length, Allocator.Temp);

        openSet.Add(startNode);
        gScore.Add(startNode, 0);

        while (openSet.Length > 0)
        {
            // A* algorithm with native collections
            // Burst-compiled for maximum performance
        }

        openSet.Dispose();
        gScore.Dispose();
    }
}

Crash Drive 3 - Multi-Platform Optimization

Game: Crash Drive 3 by M2H Challenge: Shipping on 9 platforms simultaneously

M2H shipped Crash Drive 3 on 9 platforms simultaneously using 13+ years of Unity optimization experience. Key collection-related insights include using Unity's incremental garbage collection feature to greatly reduce GC spikes without necessarily reducing allocations, aggressive profiling of memory allocations in Update methods, and collection reuse patterns. The team emphasized that with incremental GC, developers might not need to reduce GC allocations as aggressively since individual spike magnitude becomes manageable.

Key Lessons:

Source: Game Developer - "Optimizing Crash Drive 3: Battle Tested Plan"

Collection Reuse Pattern:

csharp
// Enable Incremental GC (Unity 2019+)
// Player Settings → Configuration → Garbage Collector → Incremental

// Collection reuse pattern for mobile performance
// For comprehensive mobile optimization, check our [mobile build size guide](https://outscal.com/blog/unity-mobile-build-size-optimization)
public class MobileOptimizedManager : MonoBehaviour
{
    // Pre-allocated collections
    private List<GameObject> activeObjects = new List<GameObject>(100);
    private Dictionary<int, Transform> transformCache = new Dictionary<int, Transform>(100);

    void Update()
    {
        // Reuse collections - never allocate new
        activeObjects.Clear();

        // Populate with current frame data
        foreach (var obj in allObjects)
        {
            if (obj.activeInHierarchy)
                activeObjects.Add(obj);
        }

        // Process without additional allocations
        for (int i = 0; i < activeObjects.Count; i++)
        {
            ProcessObject(activeObjects[i]);
        }
    }
}

Megacity - Unity's Ultimate DOTS Showcase

Game: Megacity / Megacity Metro by Unity Technologies Scale: 4.5 million mesh renderers, 5,000 dynamic vehicles

Megacity represents Unity's flagship DOTS demonstration, showcasing extreme scale:

The technical implementation uses NativeParallelHashMap for spatial partitioning of entities into grid cells, DynamicBuffer for vehicle spline path data, NativeArray for massive mesh renderer data, and NativeQueue for work distribution across Job System workers. Megacity Metro extends this to multiplayer supporting 128-150+ players using Netcode for Entities.

Key Lessons:

Source: Unity Technologies - Megacity Demo and accompanying technical presentations

Spatial Partitioning Pattern:

csharp
// Spatial partitioning like Megacity
[BurstCompile]
partial struct SpatialPartitionSystem : ISystem
{
    public void OnUpdate(ref SystemState state)
    {
        // Build spatial hash of all entities
        var spatialMap = new NativeParallelHashMap<int, Entity>(10000, Allocator.TempJob);

        var buildMapJob = new BuildSpatialMapJob
        {
            spatialMap = spatialMap.AsParallelWriter()
        };

        state.Dependency = buildMapJob.ScheduleParallel(state.Dependency);
        state.Dependency.Complete();

        // Query entities near positions efficiently
        // Process queries in parallel using spatialMap

        spatialMap.Dispose();
    }
}

[BurstCompile]
partial struct BuildSpatialMapJob : IJobEntity
{
    public NativeParallelHashMap<int, Entity>.ParallelWriter spatialMap;

    void Execute(Entity entity, in Translation translation)
    {
        int cellKey = GetSpatialHash(translation.Value);
        spatialMap.TryAdd(cellKey, entity);
    }

    int GetSpatialHash(float3 position)
    {
        // Grid-based spatial hash
        int x = (int)(position.x / 10.0f);
        int z = (int)(position.z / 10.0f);
        return x + z * 1000;
    }
}

These games prove that mastering Unity collections performance isn't just academic - it's what separates games that ship successfully from those that get abandoned due to performance issues.

Your Collection Selection Decision Tree

Here's the practical decision tree I use when choosing collections. Follow this systematically and you'll make the right choice every time.

For Fixed-Size Data:

plaintext
Is data fixed-size at initialization?
├─ Yes → Are you using Job System?
│  ├─ Yes → Use NativeArray<T>
│  └─ No → Use Array
└─ No → (Continue to dynamic sizing)

For Dynamic Data:

plaintext
Need dynamic sizing?
├─ Using Job System/DOTS?
│  ├─ Yes → Is data attached to entity?
│  │  ├─ Yes → Use DynamicBuffer<T>
│  │  └─ No → Use NativeList<T>
│  └─ No → Is data < 5000 elements?
│     ├─ Yes → Use List<T>
│     └─ No → Use Array with manual resizing
└─ Need key-value lookups?
   ├─ Using Job System?
   │  ├─ Yes → Need parallel writes?
   │  │  ├─ Yes → NativeParallelHashMap<TKey, TValue>
   │  │  └─ No → NativeHashMap<TKey, TValue>
   │  └─ No → Use Dictionary<TKey, TValue>
   └─ Keys are enums?
      └─ Use Array with enum cast to int

For Small Fixed-Maximum Collections:

plaintext
Collection inside ECS component?
├─ Yes → Maximum size < 4096 bytes?
│  ├─ Yes → Use FixedList variant
│  └─ No → Use DynamicBuffer<T>
└─ No → Use appropriate type from above

I keep this decision tree printed next to my desk. It's saved me countless hours of second-guessing collection choices.

Memory Management Rules That Actually Matter

Let me give you the practical rules I follow for memory management. These aren't theoretical - they're the patterns that prevent memory leaks and GC spikes in real projects.

1. Always Dispose Native Collections:

csharp
// Use try-finally pattern
NativeArray<int> data = new NativeArray<int>(100, Allocator.TempJob);
try
{
    // Use data
}
finally
{
    data.Dispose(); // Guaranteed disposal
}

// Or use Dispose(JobHandle) for jobs
JobHandle handle = myJob.Schedule();
data.Dispose(handle); // Automatically disposes after job completes

Official Documentation: Collections Allocation

2. Choose Appropriate Allocator:

3. Pre-allocate Collection Capacity:

csharp
// Bad - multiple resize operations
List<Item> items = new List<Item>();
for (int i = 0; i < 1000; i++)
{
    items.Add(new Item()); // Triggers ~8 resizes
}

// Good - single allocation
List<Item> items = new List<Item>(1000);
for (int i = 0; i < 1000; i++)
{
    items.Add(new Item()); // No resizing
}

4. Reuse Collections via Clear():

csharp
// Bad - allocates new collection every frame
void Update()
{
    List<Enemy> nearby = new List<Enemy>(); // 40+ bytes per frame
}

// Good - reuse collection
private List<Enemy> nearbyEnemies = new List<Enemy>(100);

void Update()
{
    nearbyEnemies.Clear(); // Keeps capacity, reuses memory
}

5. Cache Component References:

csharp
// Bad - GetComponent every frame (extremely expensive)
void Update()
{
    GetComponent<Rigidbody>().AddForce(force);
}

// Good - cache in Start
private Rigidbody rb;

void Start()
{
    rb = GetComponent<Rigidbody>();
}

void Update()
{
    rb.AddForce(force);
}

Component caching is critical for performance - learn more in our guide to component communication.

Official Documentation: Understanding Performance in Unity

Job System Patterns That Work

Here are the Job System patterns I use consistently in my projects. These maximize performance while keeping code maintainable.

1. Use [ReadOnly] Attribute Extensively:

csharp
[BurstCompile]
public struct OptimizedJob : IJobParallelFor
{
    [ReadOnly] public NativeArray<float> input; // Enables parallel reads
    [ReadOnly] public NativeArray<Vector3> positions;
    public NativeArray<float> output;

    public void Execute(int index)
    {
        output[index] = math.length(positions[index]) * input[index];
    }
}

2. Schedule Jobs Early, Complete Late:

csharp
void Update()
{
    // Schedule job early in frame
    var job = new MyJob { data = nativeData };
    JobHandle handle = job.Schedule();

    // Do other work...
    ProcessUI();
    HandleInput();

    // Complete only when results needed
    handle.Complete();

    // Use results
    ProcessResults();
}

3. Use Explicit Job Dependencies:

csharp
// Chain dependent jobs
JobHandle job1Handle = job1.Schedule();
JobHandle job2Handle = job2.Schedule(job1Handle); // Waits for job1
JobHandle job3Handle = job3.Schedule(job2Handle); // Waits for job2

// Complete final job completes entire chain
job3Handle.Complete();

4. Enable Burst Compilation:

csharp
[BurstCompile] // Always add to jobs
public struct MyJob : IJob
{
    public NativeArray<float> data;

    public void Execute()
    {
        // Burst optimizes this to SIMD instructions
        for (int i = 0; i < data.Length; i++)
        {
            data[i] = math.sqrt(data[i]);
        }
    }
}

Official Documentation: Burst Compiler Package

5. Tune Batch Count for IJobParallelFor:

csharp
// Start with batch size of 64
int batchSize = 64;
JobHandle handle = job.Schedule(dataLength, batchSize);

// For compute-heavy operations, reduce batch size to 1-16
// For simple operations, increase to 128-256
// Profile to find optimal value

Official Documentation: Parallel For Jobs

Mistakes I've Watched Students Make (And How to Avoid Them)

Let me share the most common mistakes I see students make with collections. I've debugged these issues so many times that I can spot them from across the room.

1. Never Use LINQ in Update/FixedUpdate:

csharp
// Bad - LINQ in Update creates 32-88 bytes per frame
void Update()
{
    var enemies = allEnemies.Where(e => e.health > 0).ToList();
}

// Good - manual filtering with reused collection
private List<Enemy> aliveEnemies = new List<Enemy>();

void Update()
{
    aliveEnemies.Clear();
    foreach (var enemy in allEnemies)
    {
        if (enemy.health > 0)
            aliveEnemies.Add(enemy);
    }
}

Understanding when to use Update vs FixedUpdate is crucial for proper collection management in your game loop.

Source: Jackson Dunstan - "How Much Garbage Does LINQ Create?"

This mistake alone accounts for probably 50% of the GC issues I see in student projects. LINQ is convenient but deadly for performance.

2. Avoid String Concatenation in Hot Paths:

csharp
// Bad - creates garbage every frame
void Update()
{
    string debug = "Health: " + health + " Score: " + score; // Boxing + allocation
}

// Better - StringBuilder (but reuse instance!)
private StringBuilder sb = new StringBuilder();

void Update()
{
    sb.Clear();
    sb.Append("Health: ").Append(health).Append(" Score: ").Append(score);
}

// Best - avoid string operations in Update entirely

3. Replace Enum-Keyed Dictionaries with Arrays:

csharp
// Bad - boxes enum on every lookup
private Dictionary<EnemyType, int> enemyCounts = new Dictionary<EnemyType, int>();
int count = enemyCounts[EnemyType.Zombie]; // Allocates 24+ bytes!

// Good - use enum as array index
private int[] enemyCounts = new int[(int)EnemyType.Count];
int count = enemyCounts[(int)EnemyType.Zombie]; // Zero allocation

4. Always Check Native Collection Disposal:

csharp
// Enable Full Stack Traces in Project Settings
// Unity shows detailed allocation site for undisposed collections

// Use conditional compilation for disposal checks
#if UNITY_EDITOR
void OnDestroy()
{
    if (myNativeArray.IsCreated)
        Debug.LogError("Native collection not disposed!");
}
#endif

5. Don't Forget TempJob 4-Frame Limit:

csharp
// Bad - TempJob allocation lives beyond 4 frames
NativeArray<int> data = new NativeArray<int>(100, Allocator.TempJob);
// If not disposed within 4 frames → Exception!

// Good - dispose promptly
NativeArray<int> data = new NativeArray<int>(100, Allocator.TempJob);
// Use data in job
data.Dispose(); // Within 4 frames

I learned this one during a game jam when I was storing TempJob allocations in a field and disposing them "eventually." Unity threw exceptions after exactly 4 frames, and I spent half a day figuring out why my game crashed intermittently. Read the documentation, folks.

How to Profile and Optimize Your Collections

"Before you make any changes, you must profile your application to identify the cause of the problem." Unity emphasizes a profile-first approach to optimization, and I can't stress this enough.

Official Documentation: Graphics Performance Optimization

1. Use CPU Usage Profiler:

The CPU Profiler tracks time spent across eight main categories (Rendering, Scripts, Physics, Animation, GarbageCollector, VSync, Global Illumination, UI, Others) with four view modes:

Official Documentation: CPU Usage Profiler

2. Track GC.Alloc:

The GC.Alloc column shows bytes allocated per frame on the managed heap. Enable Call Stacks for GC.Alloc samples to identify allocation sources:

plaintext
Unity Profiler → CPU Module → Call Stacks → GC.Alloc

Goal: Make GC.Alloc column "as consistently close to zero as possible."

3. Use Memory Profiler:

The Memory Profiler package provides detailed memory analysis:

Official Documentation: Memory Profiler Package

4. Profile on Target Devices:

Editor profiling differs significantly from device profiling. Always profile development builds on actual target hardware.

5. Use ProfilerMarker for Custom Sections:

csharp
using Unity.Profiling;

public class GameManager : MonoBehaviour
{
    static readonly ProfilerMarker s_PrepareMarker = new ProfilerMarker("GameManager.Prepare");

    void PrepareGameState()
    {
        using (s_PrepareMarker.Auto())
        {
            // Code to profile
        }
    }
}

Official Documentation: Profiler Markers

Optimization Strategies:

1. Minimize Per-Frame Allocations:

2. Choose Right Collection Type:

3. Leverage Job System + Burst:

4. Optimize Data Layout:

5. Reduce Structural Changes in ECS:

Official Documentation: Entity Command Buffer

Frame Rate Targets and Budgets:

60 FPS Target:

30 FPS Target:

VR Requirements:

Wrapping Up Unity Collections Performance

Choosing and managing Unity collections performance isn't just an optimization detail - it's fundamental to whether your game actually runs well on players' devices. The difference between using a List in Update() and a properly configured NativeArray with Job System can literally be 35x performance improvement, as Ocean Keeper demonstrated.

Start simple with traditional collections while prototyping. Profile early and often to identify real bottlenecks. When you find performance issues, systematically migrate to native collections and Unity Job System. Don't try to optimize everything upfront - profile-guided optimization is always more effective than guessing.

The key principles: target zero garbage collection Unity allocations per frame, choose collection types based on data access patterns and scale, leverage Unity Burst compiler and multithreading for heavy operations, and always dispose native collections explicitly. These aren't optional practices for "advanced" developers - they're essential for shipping games that perform well.

The games we looked at - V Rising, Ocean Keeper, Academia, Crash Drive 3, Megacity - all prove that mastering collections is what separates shipped games from abandoned prototypes. Your choice of Unity List vs Array, when to use NativeArray Unity, and how to structure Unity DOTS ECS systems directly impacts whether players enjoy your game or rage-quit due to stuttering.

Now you have the knowledge and decision trees to choose correctly. Start applying these patterns in your projects, profile the results, and watch your frame rates climb. The performance improvements are genuinely transformative when you get this right.

Common Questions

What is the difference between managed and native collections in Unity? +

Managed collections (List, Array, Dictionary) allocate on the garbage-collected heap and are automatically managed by C#'s runtime, but trigger garbage collection cycles that cause frame stutters. Native collections (NativeArray, NativeList, NativeHashMap) use unmanaged memory outside the garbage collector with zero GC overhead, but require explicit manual disposal using Dispose() or they'll cause memory leaks.

When should I use NativeArray instead of a regular array? +

Use NativeArray when you're working with the Job System or DOTS, need zero garbage collection overhead, have large datasets requiring parallel processing, or are using Burst compilation. Use regular arrays for prototyping, small datasets under 5,000 elements, or traditional GameObject-based code where convenience outweighs maximum performance.

How do I prevent garbage collection spikes in my Unity game? +

Target zero GC allocations per frame by reusing collections with Clear() instead of creating new instances, pre-allocating List capacity when size is predictable, avoiding LINQ in Update/FixedUpdate loops, caching component references instead of calling GetComponent repeatedly, and using native collections for performance-critical code. Enable the CPU Profiler's GC.Alloc tracking to identify allocation sources.

What does Allocator.TempJob mean and when should I use it? +

Allocator.TempJob is a moderate-speed memory allocator that allows native collections to be passed between the main thread and job worker threads. You must deallocate TempJob allocations within 4 frames of their creation. Use TempJob for job-scheduled operations spanning up to 4 frames - it's faster than Persistent but allows cross-thread usage unlike Temp.

How does the Unity Job System work with native collections? +

The Job System requires native collections because they're thread-safe C# wrappers for unmanaged memory that enable jobs to access shared data without copies. Native collections integrate with Unity's safety system that tracks read/write access, prevents simultaneous writes from different jobs, and enforces proper disposal. Use [ReadOnly] attribute to enable multiple jobs to read the same collection in parallel.

What is DynamicBuffer in ECS and when should I use it? +

DynamicBuffer is the ECS-native collection type for dynamic arrays within entities. Unlike separate native collections, DynamicBuffer integrates directly as a component type with ECS managing its lifecycle automatically. Small buffers (typically 16-32 elements) are stored inline within ArchetypeChunks for maximum cache efficiency. Use DynamicBuffer when you need dynamic arrays as entity components and want ECS to handle memory management.

Why is my NativeArray slower than a regular array in the Editor? +

In Unity Editor and Development Builds, NativeArray includes safety checks that add approximately 10x performance overhead, making them slower than regular arrays during development. In production builds, these safety checks are completely disabled, making NativeArray performance competitive with or superior to managed arrays while providing zero garbage collection overhead.

How do I properly dispose of native collections? +

Always dispose native collections using Dispose() method, ideally in try-finally blocks to guarantee disposal even if exceptions occur. For collections used in jobs, you can call Dispose(JobHandle) to automatically dispose after the job completes. Enable Full Stack Traces in Project Settings to help identify undisposed collections. Forgetting disposal causes "Native Collection has not been disposed" memory leak errors.

What is the Burst compiler and how does it improve performance? +

The Burst compiler translates a subset of C# called High-Performance C# (HPC#) into highly-optimized native CPU code using LLVM. Apply the [BurstCompile] attribute to job structs to enable compilation. When combined with Job System and native collections, Burst achieves 10-50x performance improvements through SIMD optimization and native code generation. Real cases show jobs improving from 7ms to 0.5ms (14x) just by enabling Burst.

Should I use Dictionary with enum keys for fast lookups? +

No - using enum keys with Dictionary causes heap allocations on every lookup due to boxing, allocating 24+ bytes per access. Instead, use arrays with enum cast to int as the index: int[] data = new int[(int)EnumType.Count]; int value = data[(int)EnumType.Zombie]; This approach is dramatically faster and allocation-free while providing the same functionality.

How do I choose between List and Array for my game data? +

Use Array when element count is fixed at initialization, you need maximum performance (200k+ items per frame), or you're working with grids and lookup tables. Use List when you need dynamic sizing with frequent additions/removals, collection size is under 5,000 elements, or you're implementing object pooling. Arrays are approximately 5x faster but cannot resize; List provides flexibility at a moderate performance cost.

What is the performance difference between List and Dictionary iteration? +

Dictionary is 8-10x slower than List when iterating over all elements due to hash table structure overhead. Dictionary excels at O(1) key-value lookups but performs poorly for full iteration. Use List when primarily iterating through collections, and Dictionary only when you need fast lookups by non-integer keys and primarily access individual elements rather than iterating all data.